Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eb3e310cf | |||
| eca475c6e3 | |||
| 92822303ef | |||
| 9649fb55cf | |||
| 8f39017b59 | |||
| bd18642045 | |||
| 820e968e1a | |||
| a5cd566dea | |||
| b5599579a7 | |||
| 61a232dfc6 | |||
| a45bf42335 | |||
| 77a1ae3977 | |||
| fb5461b661 | |||
| e15421699e | |||
| 48d574e225 | |||
| 1dba0c37b9 | |||
| 07ea171af9 | |||
| 420b4f5f3c | |||
| f8c28f055b | |||
| a7df4d49b9 | |||
| 320b2c57be | |||
| d323ca52af | |||
| c5e4b41100 | |||
| 335fcd0382 | |||
| c1c820bb5c | |||
| f441a8a51f | |||
| 005eb5cf39 | |||
| 21acb19fed | |||
| 1fe4f83e73 | |||
| 7e5c322792 | |||
| b010677d75 | |||
| 9275e581c2 | |||
| 3f3b1f79a0 | |||
| 83842c50ad | |||
| fbedd5966c | |||
| eca2c13018 | |||
| 48d000107d | |||
| 7ceb9528cc | |||
| 5fabaec477 | |||
| e40b799101 | |||
| 7e9784e723 | |||
| 209dee14fd | |||
| 81351f45fd | |||
| fd451b4b73 | |||
| d0dbd1dceb | |||
| 3e2e291819 | |||
| 5975ea38d8 | |||
| 8ad548f4a3 | |||
| cbb4d73df5 | |||
| 47cb47ebdb | |||
| 22b0f8af7e | |||
| 08ca1429ae | |||
| e8da1a30ff | |||
| fb754b1a07 | |||
| 9a2c164207 | |||
| 78c1329a83 | |||
| 05f43ed88f | |||
| 05e4f39e7d | |||
| 3dcb3b6d3a | |||
| db4e6f5c6b | |||
| aa7fc45a67 | |||
| 03fe66238f | |||
| a5ae616a94 | |||
| ff7924de7d | |||
| 1690e291d2 | |||
| 7f818809ef | |||
| 597b40f3f2 | |||
| 80108f9ca8 | |||
| b33623c731 | |||
| 9ff59ce405 | |||
| 9c6f393f92 | |||
| a418798a4d | |||
| baafffb1be | |||
| 1c930ca9bd | |||
| 3e37035786 | |||
| 5805358ef4 | |||
| 44c6bcbc2d | |||
| 78fcbdd4a9 | |||
| 4fd1acb68c | |||
| 9f7599fdb1 | |||
| 57a0b491ea | |||
| f76cd94c64 | |||
| ca1c3e0dba | |||
| 9ee50d0058 | |||
| bc67a53442 | |||
| 147cf663a6 | |||
| e41d9b9335 | |||
| 5c5c5e9ff2 | |||
| c53ab7e44c | |||
| 1b0d5bd2f3 | |||
| 7281f60ba0 | |||
| bfe345747d | |||
| 31c4b86d6e | |||
| b5bad37afc | |||
| ea2dcd7d96 | |||
| 989e84c44c | |||
| 2cfc0a61e9 | |||
| 11bd5e8f7f | |||
| cbfa23c4c4 | |||
| e1104eeebc | |||
| 968f85f622 | |||
| 5f7e6a9b1a | |||
| ded6563d2e | |||
| 5b7817f104 | |||
| fb916e857e | |||
| 81ced97bd6 | |||
| 79d3907004 | |||
| 5e8773a2c6 | |||
| 9a99bffc6b | |||
| bffb8c3f94 | |||
| bb0ee435e8 | |||
| c2804de1d7 | |||
| 251c1970f9 | |||
| 1b9ede4750 | |||
| bc47944d8f | |||
| bed5bb46df | |||
| 5f6fb9ec64 | |||
| bed73b083a | |||
| 4cdcf76301 | |||
| 54e113ff3c | |||
| c55da9d67d | |||
| e196d97d3f | |||
| 3456a15237 | |||
| ef1b5b6258 | |||
| 63b0baceed | |||
| 7bbd4853a8 | |||
| 2b48b09ffa | |||
| ac8c22f183 | |||
| b1d4a979f8 | |||
| d64fea05bf | |||
| 5297a2b188 | |||
| 240ae2f803 | |||
| 4cc3f5bee4 | |||
| a888b6c9c7 | |||
| bd2799c761 | |||
| e03b29983a | |||
| 4883d624f9 | |||
| e2cae35bca | |||
| f1f907bca0 | |||
| 315bb89836 | |||
| 73a90616dd | |||
| d723475931 | |||
| 0be956e56d | |||
| 8892ade56a | |||
| 77989fe413 | |||
| b8a282cdbc | |||
| 5ce1dca4f8 | |||
| 4f48dcae5c | |||
| 62f228f95c | |||
| c78c242024 | |||
| d9846b1c01 | |||
| f3ba340c46 | |||
| 464ebb1a25 | |||
| 05f04a0a31 |
@@ -4,11 +4,15 @@
|
||||
Auto-generated by cleanup script.
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="https://standards.mokoconsulting.tech/moko-platform/1.0 https://git.mokoconsulting.tech/MokoConsulting/moko-platform/raw/branch/main/definitions/manifest-schema.xsd"
|
||||
schema-version="1.0">
|
||||
<identity>
|
||||
<name>moko-platform</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories</description>
|
||||
<version>09.01.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,10 +82,11 @@ jobs:
|
||||
|
||||
- name: Setup PHP ${{ env.PHP_VERSION }}
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||
php${{ env.PHP_VERSION }}-intl >/dev/null 2>&1
|
||||
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
|
||||
php -v
|
||||
|
||||
- name: Install Composer dependencies
|
||||
@@ -114,7 +115,7 @@ jobs:
|
||||
|
||||
- name: "PHPCS (PSR-12)"
|
||||
run: |
|
||||
vendor/bin/phpcs --standard=phpcs.xml --report=summary lib/ validate/ automation/ 2>&1 || {
|
||||
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
|
||||
echo "::error::PHPCS found coding standard violations"
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -123,16 +124,16 @@ jobs:
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "PHPStan (Level 5)"
|
||||
- name: "PHPStan (Level 6)"
|
||||
run: |
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=github 2>&1 || {
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
|
||||
echo "::error::PHPStan found type errors"
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis (level 5): passed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Psalm"
|
||||
continue-on-error: true
|
||||
@@ -164,10 +165,11 @@ jobs:
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
|
||||
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
|
||||
php${{ matrix.php }}-intl >/dev/null 2>&1
|
||||
php${{ matrix.php }}-intl composer >/dev/null 2>&1
|
||||
php -v
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -175,11 +177,14 @@ jobs:
|
||||
|
||||
- name: "PHPUnit (PHP ${{ matrix.php }})"
|
||||
run: |
|
||||
vendor/bin/phpunit --testdox 2>&1
|
||||
{
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})"
|
||||
echo "All tests passed."
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
vendor/bin/phpunit --testdox 2>&1 || {
|
||||
echo "::error::PHPUnit tests failed"
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 3 — Self-Health (Dogfood)
|
||||
@@ -198,9 +203,11 @@ jobs:
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip >/dev/null 2>&1
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||
composer >/dev/null 2>&1
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
@@ -245,9 +252,10 @@ jobs:
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl >/dev/null 2>&1
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform-api" ] && [ -f "/tmp/moko-platform-api/composer.json" ]; then
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/moko-platform-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/moko-platform-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/moko-platform-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/moko-platform-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
|
||||
- name: Post-deploy health check
|
||||
if: success() && steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
if [ -f "deploy/health-check.php" ]; then
|
||||
SITE_URL="${{ vars.DEV_SITE_URL }}"
|
||||
if [ -n "$SITE_URL" ]; then
|
||||
php deploy/health-check.php --url "$SITE_URL" --checks http --timeout 30 || echo "::warning::Health check failed after deploy"
|
||||
else
|
||||
echo "DEV_SITE_URL not configured, skipping health check"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
name: Create feature branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||
|
||||
# Check dev branch exists
|
||||
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||
echo "No dev branch -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch from dev
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/branches" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP}" = "201" ]; then
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
|
||||
echo "Commented on issue #${ISSUE_NUM}"
|
||||
else
|
||||
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||
fi
|
||||
@@ -194,3 +194,21 @@ jobs:
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,246 +1,314 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" /tmp/moko-platform-api
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
MOKO_API="/tmp/moko-platform-api/cli"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump patch version
|
||||
BUMP_OUTPUT=$(php ${MOKO_API}/version_bump.php --path .)
|
||||
VERSION=$(echo "$BUMP_OUTPUT" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
||||
echo "Version: ${VERSION}"
|
||||
|
||||
# Update platform-specific manifest
|
||||
php ${MOKO_API}/version_set_platform.php --path . --version "${VERSION}"
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Detect element from Joomla/Dolibarr manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
EXT_ELEMENT=$(php ${MOKO_API}/manifest_read.php --path . --field name 2>/dev/null | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
|
||||
# For Joomla, prefer <element> tag
|
||||
if [ "$PLATFORM" = "joomla" ]; then
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
ELEM=$(grep -oP "<element>\K[^<]+" "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -n "$ELEM" ] && EXT_ELEMENT="$ELEM"
|
||||
fi
|
||||
fi
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
id: zip
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SUFFIX="${{ steps.meta.outputs.suffix }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
|
||||
if [ "$PLATFORM" = "joomla" ]; then
|
||||
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --suffix "${SUFFIX}" --output build --github-output
|
||||
else
|
||||
# Generic build: zip src/ directory
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "::error::No src/ or htdocs/"; exit 1; }
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
mkdir -p build
|
||||
cd "$SOURCE_DIR" && zip -r "../build/${ZIP_NAME}" . && cd ..
|
||||
SHA256=$(sha256sum "build/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_path=build/${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
continue-on-error: true
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.zip.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@${{ steps.zip.outputs.zip_path }}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: "Update updates.xml"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
php /tmp/moko-platform-api/cli/updates_xml_build.php --path . --version "$VERSION" --stability "$STABILITY" --sha "$SHA256" --gitea-url "$GITEA_URL" --org "$GITEA_ORG" --repo "$GITEA_REPO"
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update $STABILITY channel $VERSION [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/updates_xml_sync.php --path . --current "${{ github.ref_name }}" --branches main,dev --version "${{ steps.meta.outputs.version }}" --token "${{ secrets.GA_TOKEN }}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" --gitea-url "${GITEA_URL}"
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
else
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||
|
||||
# Verify version consistency across all files
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}"
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
echo "Syncing updates.xml -> ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -49,7 +49,7 @@ env:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
@@ -60,7 +60,7 @@ env:
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
WORKFLOWS_DIR: .mokogitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Update server XML feed with stable/rc/beta/alpha/dev entries (universal)
|
||||
#
|
||||
# Writes updates.xml with multiple <update> entries:
|
||||
# - <tag>stable</tag> on push to main (from auto-release)
|
||||
# - <tag>rc</tag> on push to rc/**
|
||||
# - <tag>development</tag> on push to dev or dev/**
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/tmp/moko-platform" ]; then
|
||||
echo "moko-platform already available — skipping clone"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
id: update
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
BUMPED=$(php /tmp/moko-platform/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
||||
git add -A
|
||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
||||
git push 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Determine stability from branch or input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Parse manifest (portable — no grep -P)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla manifest found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract fields using sed (works on all runners)
|
||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
||||
|
||||
# Fallbacks
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||
|
||||
# Derive element if not in manifest: try XML filename, then repo name
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Use manifest version if README version is empty
|
||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
||||
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||
|
||||
# Joomla requires <client> on ALL extension types for update matching
|
||||
if [ -n "$EXT_CLIENT" ]; then
|
||||
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
else
|
||||
CLIENT_TAG="<client>site</client>"
|
||||
fi
|
||||
|
||||
FOLDER_TAG=""
|
||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
|
||||
PHP_TAG=""
|
||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
|
||||
# Version suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
|
||||
# Each stability level has its own release tag
|
||||
case "$STABILITY" in
|
||||
development) RELEASE_TAG="development" ;;
|
||||
alpha) RELEASE_TAG="alpha" ;;
|
||||
beta) RELEASE_TAG="beta" ;;
|
||||
rc) RELEASE_TAG="release-candidate" ;;
|
||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
||||
esac
|
||||
|
||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
||||
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
||||
cd ..
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Ensure release exists on Gitea
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create release
|
||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
||||
'body': '${STABILITY} release',
|
||||
'prerelease': True,
|
||||
'target_commitish': 'main'
|
||||
}))")" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
# Delete existing assets with same name before uploading
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_FILE}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Upload both formats
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
SHA256=""
|
||||
fi
|
||||
|
||||
# -- Build the new entry (canonical format matching release.yml) --
|
||||
NEW_ENTRY=""
|
||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||
|
||||
# -- Write new entry to temp file --------------------------------
|
||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||
|
||||
# -- Merge into updates.xml ----------------------------------------
|
||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
||||
TARGETS=""
|
||||
for entry in $CASCADE_MAP; do
|
||||
key="${entry%%:*}"
|
||||
vals="${entry#*:}"
|
||||
if [ "$key" = "${STABILITY}" ]; then
|
||||
TARGETS="$vals"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
||||
|
||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
||||
|
||||
# Create updates.xml if missing
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
||||
printf '%s\n' "<updates>" >> updates.xml
|
||||
printf '%s\n' "</updates>" >> updates.xml
|
||||
fi
|
||||
|
||||
# Update existing blocks or create missing ones
|
||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
targets = os.environ["PY_TARGETS"].split(",")
|
||||
version = os.environ["PY_VERSION"]
|
||||
date = os.environ["PY_DATE"]
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
with open("/tmp/new_entry.xml") as f:
|
||||
new_entry_template = f.read()
|
||||
|
||||
for tag in targets:
|
||||
tag = tag.strip()
|
||||
# Build entry with this tag's name
|
||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
||||
|
||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(block_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
# Update in place — replace entire block
|
||||
content = content.replace(match.group(1), new_entry.strip())
|
||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
||||
else:
|
||||
# Create — insert before </updates>
|
||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
||||
|
||||
# Clean up excessive blank lines
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'ERROR: failed to sync updates.xml to main: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "::error::failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::could not get updates.xml SHA from main — file may not exist on main yet" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Validate updates.xml integrity
|
||||
run: |
|
||||
ERRORS=0
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "::error::updates.xml not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Well-formed XML
|
||||
if ! python3 -c "import xml.etree.ElementTree as ET; ET.parse('updates.xml')" 2>/dev/null; then
|
||||
echo "::error::updates.xml is not valid XML"
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import xml.etree.ElementTree as ET, sys, re, os
|
||||
|
||||
tree = ET.parse("updates.xml")
|
||||
root = tree.getroot()
|
||||
updates = root.findall("update")
|
||||
errors = 0
|
||||
warnings = 0
|
||||
seen_tags = set()
|
||||
|
||||
# All 5 channels MUST be present
|
||||
REQUIRED_CHANNELS = {"stable", "rc", "beta", "alpha", "dev"}
|
||||
VALID_TAGS = REQUIRED_CHANNELS | {"development"} # accept legacy alias
|
||||
REPO = os.environ.get("GITEA_REPO", "")
|
||||
ORG = os.environ.get("GITEA_ORG", "MokoConsulting")
|
||||
REPO_BASE = f"https://git.mokoconsulting.tech/{ORG}/"
|
||||
|
||||
# Gitea release tag names per channel (Moko standard)
|
||||
RELEASE_TAG_MAP = {
|
||||
"stable": "stable",
|
||||
"rc": "release-candidate",
|
||||
"beta": "beta",
|
||||
"alpha": "alpha",
|
||||
"dev": "development",
|
||||
"development": "development",
|
||||
}
|
||||
|
||||
# Joomla update XML required fields per
|
||||
# https://docs.joomla.org/Deploying_an_Update_Server
|
||||
REQUIRED_FIELDS = ["name", "element", "type", "version", "infourl"]
|
||||
|
||||
for i, u in enumerate(updates):
|
||||
tag_el = u.find("tags/tag")
|
||||
tag = tag_el.text.strip() if tag_el is not None and tag_el.text else None
|
||||
label = f"Entry {i+1} (<tag>{tag or '?'}</tag>)"
|
||||
|
||||
# -- Required Joomla fields --
|
||||
for field in REQUIRED_FIELDS:
|
||||
el = u.find(field)
|
||||
if el is None or not (el.text or "").strip():
|
||||
print(f"::error::{label}: missing required <{field}>")
|
||||
errors += 1
|
||||
|
||||
# -- <downloads><downloadurl> --
|
||||
dl = u.find("downloads/downloadurl")
|
||||
if dl is None or not (dl.text or "").strip():
|
||||
print(f"::error::{label}: missing <downloads><downloadurl>")
|
||||
errors += 1
|
||||
else:
|
||||
dl_url = dl.text.strip()
|
||||
# Must point to org repo
|
||||
if REPO_BASE not in dl_url:
|
||||
print(f"::error::{label}: download URL not under {REPO_BASE}: {dl_url}")
|
||||
errors += 1
|
||||
# Must end in .zip
|
||||
if not dl_url.endswith(".zip"):
|
||||
print(f"::error::{label}: download URL must end in .zip: {dl_url}")
|
||||
errors += 1
|
||||
# Must use correct Gitea release tag in path
|
||||
if tag and tag in RELEASE_TAG_MAP:
|
||||
expected_tag = RELEASE_TAG_MAP[tag]
|
||||
if f"/download/{expected_tag}/" not in dl_url:
|
||||
print(f"::error::{label}: download URL should contain /download/{expected_tag}/ but got: {dl_url}")
|
||||
errors += 1
|
||||
|
||||
# -- <client> (required for Joomla to match update) --
|
||||
client = u.find("client")
|
||||
if client is None or not (client.text or "").strip():
|
||||
print(f"::error::{label}: missing <client> (required for Joomla update matching)")
|
||||
errors += 1
|
||||
|
||||
# -- <targetplatform> --
|
||||
tp = u.find("targetplatform")
|
||||
if tp is None:
|
||||
print(f"::error::{label}: missing <targetplatform>")
|
||||
errors += 1
|
||||
else:
|
||||
tp_name = tp.get("name", "")
|
||||
tp_ver = tp.get("version", "")
|
||||
if tp_name != "joomla":
|
||||
print(f"::error::{label}: targetplatform name should be 'joomla', got '{tp_name}'")
|
||||
errors += 1
|
||||
if not tp_ver:
|
||||
print(f"::error::{label}: targetplatform missing version regex")
|
||||
errors += 1
|
||||
elif "5" not in tp_ver or "6" not in tp_ver:
|
||||
print(f"::warning::{label}: targetplatform version may not cover Joomla 5+6: {tp_ver}")
|
||||
warnings += 1
|
||||
|
||||
# -- <type> must be valid Joomla type --
|
||||
type_el = u.find("type")
|
||||
if type_el is not None and type_el.text:
|
||||
valid_types = {"component", "module", "plugin", "template", "library", "package", "file"}
|
||||
if type_el.text.strip() not in valid_types:
|
||||
print(f"::error::{label}: invalid type '{type_el.text}' (expected: {valid_types})")
|
||||
errors += 1
|
||||
|
||||
# -- <version> format (XX.YY.ZZ with optional suffix) --
|
||||
ver_el = u.find("version")
|
||||
if ver_el is not None and ver_el.text:
|
||||
if not re.match(r"^\d{2}\.\d{2}\.\d{2}(-\w+)?$", ver_el.text.strip()):
|
||||
print(f"::warning::{label}: version '{ver_el.text}' does not match XX.YY.ZZ format")
|
||||
warnings += 1
|
||||
|
||||
# -- <maintainer> and <maintainerurl> --
|
||||
for field in ["maintainer", "maintainerurl"]:
|
||||
el = u.find(field)
|
||||
if el is None or not (el.text or "").strip():
|
||||
print(f"::warning::{label}: missing <{field}>")
|
||||
warnings += 1
|
||||
|
||||
# -- Valid stability tag --
|
||||
if tag is None:
|
||||
print(f"::error::{label}: missing <tags><tag>")
|
||||
errors += 1
|
||||
elif tag not in VALID_TAGS:
|
||||
print(f"::error::{label}: invalid tag '{tag}' (expected: {VALID_TAGS})")
|
||||
errors += 1
|
||||
|
||||
# -- Duplicate tag check --
|
||||
norm_tag = "dev" if tag == "development" else tag
|
||||
if norm_tag in seen_tags:
|
||||
print(f"::error::{label}: duplicate channel '{tag}'")
|
||||
errors += 1
|
||||
if norm_tag:
|
||||
seen_tags.add(norm_tag)
|
||||
|
||||
# -- All 5 channels must exist --
|
||||
missing = REQUIRED_CHANNELS - seen_tags
|
||||
if missing:
|
||||
print(f"::error::Missing required update channels: {', '.join(sorted(missing))}")
|
||||
errors += 1
|
||||
|
||||
# -- Version ordering: higher stability must not exceed dev version --
|
||||
channel_versions = {}
|
||||
for u in updates:
|
||||
tag_el = u.find("tags/tag")
|
||||
ver_el = u.find("version")
|
||||
if tag_el is not None and ver_el is not None and tag_el.text and ver_el.text:
|
||||
norm = "dev" if tag_el.text.strip() == "development" else tag_el.text.strip()
|
||||
# Strip suffix for comparison (01.00.18-dev -> 01.00.18)
|
||||
base_ver = re.sub(r"-\w+$", "", ver_el.text.strip())
|
||||
channel_versions[norm] = base_ver
|
||||
|
||||
# Cascade check: dev >= alpha >= beta >= rc >= stable
|
||||
ORDER = ["dev", "alpha", "beta", "rc", "stable"]
|
||||
for j in range(1, len(ORDER)):
|
||||
current = ORDER[j]
|
||||
previous = ORDER[j - 1]
|
||||
if current in channel_versions and previous in channel_versions:
|
||||
if channel_versions[current] > channel_versions[previous]:
|
||||
print(f"::error::{current} version ({channel_versions[current]}) is ahead of {previous} ({channel_versions[previous]})")
|
||||
errors += 1
|
||||
|
||||
# -- Summary --
|
||||
print(f"\nupdates.xml validation: {len(updates)} entries, {errors} error(s), {warnings} warning(s)")
|
||||
if errors > 0:
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -18,6 +18,78 @@ Version format: `XX.YY.ZZ` (zero-padded semver).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [09.00.00] - 2026-05-26
|
||||
|
||||
### Added
|
||||
- PHPDoc on Priority 1 Enterprise classes (CliFramework, adapters, ApiClient)
|
||||
- Wiki: Coding-Standards page with PHPDoc standard, PHPCS exclusions, file patterns
|
||||
- CI: PHPStan enforced at level 6 (was advisory), PHPUnit blocks on failure
|
||||
|
||||
### Fixed
|
||||
- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
|
||||
- `updates_xml_build.php`: separate Joomla stability tags (`dev`, `rc`) from Gitea release tags (`development`, `release-candidate`) — download URLs now point to correct release assets
|
||||
- `updates_xml_build.php`: only emit `<client>site</client>` for templates and modules, not packages or components
|
||||
- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
|
||||
|
||||
## [08.00.00] - 2026-05-26
|
||||
|
||||
### Changed
|
||||
- PHPStan: level 5 → 6 (401 baselined, 0 new errors)
|
||||
- Branch protection: 5 required checks enabled on main
|
||||
- Workflows synced to all governed repos (72+ repos across 3 orgs)
|
||||
- Flushed 44 stale runners from Gitea admin (3 active remain)
|
||||
|
||||
### Fixed
|
||||
- PHPStan level 3→4: removed 13 dead properties, 41 defensive patterns baselined
|
||||
- PHPStan level 4→5: fixed metrics `increment()` bug (labels passed as value param)
|
||||
- PHPStan level 5→6: 360 missing array generic types baselined
|
||||
|
||||
## [07.00.00] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- `cli/client_provision.php` — end-to-end client onboarding from JSON config (closes #4)
|
||||
- `cli/client_dashboard.php` — unified HTML dashboard: health, SSL, uptime, releases (closes #3)
|
||||
- `cli/client_health_check.php`, `cli/joomla_compat_check.php`, `cli/theme_lint.php` — new CLI tools
|
||||
- `lib/Enterprise/ConfigValidator.php` — JSON schema validator for plugin configs (closes #105)
|
||||
- PHPUnit test infrastructure: `phpunit.xml` + 19 tests (closes #102)
|
||||
- `bin/moko list` — auto-grouped command list with 45 commands, plugin command dispatcher (closes #104)
|
||||
- `templates/client-provision-example.json` — example config for client provisioning
|
||||
|
||||
### Fixed
|
||||
- `bin/moko` COMMAND_MAP: all paths pointed to non-existent `api/` directory (closes #100)
|
||||
- `release_cascade.php`: accept `release-candidate` as stability value (was silently skipping)
|
||||
- `package_build.php`: fix 0-byte ZIP for Joomla packages — correct structure, no double prefix (closes #92)
|
||||
- PHPStan: level 0 to 2, 67 type errors fixed, 0 exclusions
|
||||
- `ApiClient::delete()`: accept optional body parameter for Gitea Contents API
|
||||
|
||||
### Changed
|
||||
- Migrated all 7 CLIApp scripts to CliFramework (closes #101)
|
||||
- Updated CLAUDE.md with current architecture, CLI patterns, code quality (closes #103)
|
||||
- Wiki CLI_AUTOMATION page updated with all tools
|
||||
|
||||
## [06.00.00] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- `cli/bulk_workflow_push.php` — push a workflow file to all governed repos via Gitea Contents API (closes #52)
|
||||
- `cli/grafana_dashboard.php` — manage Grafana dashboards: push, delete, list, export (closes #53)
|
||||
- Wiki CLI_AUTOMATION page — comprehensive reference for all 30 CLI tools (closes #66)
|
||||
|
||||
### Fixed
|
||||
- `version_read.php` / `version_bump.php`: handle suffixed versions in XML manifests (e.g. `01.00.00-dev`)
|
||||
- `version_read.php` / `version_bump.php`: match `VERSION:` inside HTML comments (`<!-- VERSION: ... -->`)
|
||||
- Pre-release RC builds now work after a development pre-release has been built
|
||||
- auto-release workflow: switch trigger from `pull_request closed` to `push` on main (closes #54)
|
||||
- CI Gate 1: add ondrej/php PPA + composer package for PHP 8.2 on runners
|
||||
- CI repo-health: use `.mokogitea/workflows/` instead of `.gitea/workflows/`
|
||||
- PHPCS: fix all 7,539 PSR-12 violations across 74 files (0 errors remaining)
|
||||
- PHPStan: fix deprecated config options, mark as advisory until errors addressed
|
||||
- Branch protection: update check names from `MokoStandards CI` to `moko-platform CI`
|
||||
- Runner-03: fix Docker image label (`moko/runner-images` → self-hosted `git.mokoconsulting.tech/mokoconsulting/runner-image`)
|
||||
- Runbook 08: update with 3-runner fleet overview, per-runner configs, troubleshooting
|
||||
|
||||
### Changed
|
||||
- Rename MokoStandards references to moko-platform in config files
|
||||
|
||||
## [05.00.00] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,34 +4,100 @@ This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**moko-platform** -- Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | generic |
|
||||
| **Language** | HCL |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Version** | 09.01.00 |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Run repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
|
||||
# Run all checks
|
||||
composer check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See the [wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) for architecture details.
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `definitions/` | Repository structure definitions (HCL format) |
|
||||
| `templates/` | Workflow templates, config templates, docs templates |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||
|
||||
Pattern for new tools:
|
||||
```php
|
||||
class MyTool extends CliFramework {
|
||||
protected function configure(): void {
|
||||
$this->setDescription('What this tool does');
|
||||
$this->addArgument('--name', 'Description', 'default');
|
||||
}
|
||||
protected function run(): int {
|
||||
$name = $this->getArgument('--name');
|
||||
// ... business logic ...
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$app = new MyTool();
|
||||
exit($app->execute());
|
||||
```
|
||||
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — for github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|------|-------|--------|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||
|
||||
@@ -6,11 +6,14 @@ DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /README.md
|
||||
VERSION: 09.02.00
|
||||
BRIEF: Project overview and documentation
|
||||
-->
|
||||
|
||||
# MokoStandards Enterprise API
|
||||
|
||||
  
|
||||
|
||||
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
||||
|
||||
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
|
||||
|
||||
+563
-553
File diff suppressed because it is too large
Load Diff
+111
-90
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -25,7 +26,7 @@ use MokoEnterprise\{
|
||||
AuditLogger,
|
||||
CheckpointManager,
|
||||
CircuitBreakerOpen,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
GitPlatformAdapter,
|
||||
MetricsCollector,
|
||||
@@ -40,18 +41,18 @@ use MokoEnterprise\{
|
||||
|
||||
/**
|
||||
* Bulk Repository Synchronization Tool
|
||||
*
|
||||
*
|
||||
* Synchronizes MokoStandards files across multiple repositories using
|
||||
* the Enterprise library for robust, audited operations.
|
||||
*/
|
||||
class BulkSync extends CLIApp
|
||||
class BulkSync extends CliFramework
|
||||
{
|
||||
/**
|
||||
* Default organization for bulk sync operations
|
||||
* Public to allow script instantiation with class constants
|
||||
*/
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
|
||||
|
||||
/**
|
||||
* Script version number
|
||||
* Public to allow script instantiation with class constants
|
||||
@@ -64,55 +65,52 @@ class BulkSync extends CLIApp
|
||||
private RepositorySynchronizer $synchronizer;
|
||||
private AuditLogger $logger;
|
||||
private CheckpointManager $checkpoints;
|
||||
private SecurityValidator $security;
|
||||
private PluginFactory $pluginFactory;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private MetricsCollector $metrics;
|
||||
private Config $config;
|
||||
|
||||
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
||||
private bool $interrupted = false;
|
||||
|
||||
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: MokoConsulting)',
|
||||
'repos:' => 'Specific repositories to sync (space-separated)',
|
||||
'exclude:' => 'Repositories to exclude (space-separated)',
|
||||
'skip-archived' => 'Skip archived repositories',
|
||||
'yes' => 'Auto-confirm prompts',
|
||||
'resume' => 'Resume from last checkpoint, skipping already-processed repositories',
|
||||
'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files',
|
||||
'protect' => 'Apply/enforce main branch protection rules on all synced repositories',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
'update-branches' => 'After sync, merge main into all other open PR branches in each repo',
|
||||
'health' => 'Run repo health checks after sync and include results in the report',
|
||||
];
|
||||
$this->setDescription('Bulk repository synchronization');
|
||||
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Specific repos', '');
|
||||
$this->addArgument('--exclude', 'Repos to exclude', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--resume', 'Resume from checkpoint', false);
|
||||
$this->addArgument('--force', 'Force overwrite', false);
|
||||
$this->addArgument('--protect', 'Apply branch protection', false);
|
||||
$this->addArgument('--no-issue', 'Skip tracking issue', false);
|
||||
$this->addArgument('--update-branches', 'Merge main into branches', false);
|
||||
$this->addArgument('--health', 'Run health checks', false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
protected function run(): int
|
||||
{
|
||||
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
|
||||
|
||||
|
||||
// Initialize enterprise components
|
||||
if (!$this->initializeComponents()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
// Get configuration
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->hasOption('skip-archived');
|
||||
$autoConfirm = $this->hasOption('yes');
|
||||
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->getArgument('--skip-archived', false);
|
||||
$autoConfirm = $this->getArgument('--yes', false);
|
||||
|
||||
// Get repository filters
|
||||
$specificRepos = $this->parseRepositoryList($this->getOption('repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getOption('exclude', ''));
|
||||
|
||||
$specificRepos = $this->parseRepositoryList($this->getArgument('--repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', ''));
|
||||
|
||||
$this->log("Organization: {$org}", 'INFO');
|
||||
if (!empty($specificRepos)) {
|
||||
$this->log("Repositories: " . implode(', ', $specificRepos), 'INFO');
|
||||
@@ -120,25 +118,25 @@ class BulkSync extends CLIApp
|
||||
if (!empty($excludeRepos)) {
|
||||
$this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO');
|
||||
}
|
||||
|
||||
|
||||
// Get repositories
|
||||
$this->log("📋 Fetching repositories...", 'INFO');
|
||||
$repositories = $this->synchronizer->getRepositories($org, $skipArchived);
|
||||
|
||||
|
||||
// Apply filters
|
||||
$repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos);
|
||||
|
||||
$count = count($repositories);
|
||||
$this->log("Found {$count} repositories to sync", 'INFO');
|
||||
|
||||
|
||||
if ($count === 0) {
|
||||
$this->log("No repositories to process", 'WARN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Load resume checkpoint if --resume is set
|
||||
$alreadyProcessed = [];
|
||||
if ($this->hasOption('resume')) {
|
||||
if ($this->getArgument('--resume', false)) {
|
||||
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
|
||||
if ($checkpoint !== null) {
|
||||
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
|
||||
@@ -158,10 +156,15 @@ class BulkSync extends CLIApp
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sync universal workflows from Template-Generic → other templates first
|
||||
$this->log("📋 Syncing universal workflows to template repos...", 'INFO');
|
||||
$templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org);
|
||||
$this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO');
|
||||
|
||||
// Execute synchronization
|
||||
$this->log("🔄 Starting synchronization...", 'INFO');
|
||||
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
||||
|
||||
|
||||
// Display results
|
||||
$this->displayResults($results);
|
||||
|
||||
@@ -187,7 +190,7 @@ class BulkSync extends CLIApp
|
||||
|
||||
return $results['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize enterprise components
|
||||
*/
|
||||
@@ -203,7 +206,6 @@ class BulkSync extends CLIApp
|
||||
$this->logger = new AuditLogger('bulk_sync');
|
||||
$this->metrics = new MetricsCollector();
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints');
|
||||
$this->security = new SecurityValidator();
|
||||
$this->synchronizer = new RepositorySynchronizer(
|
||||
$this->api,
|
||||
$this->logger,
|
||||
@@ -214,18 +216,15 @@ class BulkSync extends CLIApp
|
||||
);
|
||||
|
||||
// Initialize plugin system
|
||||
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
|
||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||
|
||||
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse repository list from string
|
||||
*/
|
||||
@@ -234,13 +233,13 @@ class BulkSync extends CLIApp
|
||||
if (empty($input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return array_filter(
|
||||
array_map('trim', preg_split('/[\s,]+/', $input)),
|
||||
fn($r) => !empty($r)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filter repositories based on include/exclude lists
|
||||
*/
|
||||
@@ -288,7 +287,7 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_merge($priority, $rest));
|
||||
return array_merge($priority, $rest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,11 +298,11 @@ class BulkSync extends CLIApp
|
||||
if ($this->quiet) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
echo "\n⚠️ About to synchronize {$count} repositories.\n";
|
||||
echo "This will update files across all repositories.\n";
|
||||
echo "\nContinue? [y/N]: ";
|
||||
|
||||
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$line = fgets($handle);
|
||||
if ($handle) {
|
||||
@@ -314,7 +313,7 @@ class BulkSync extends CLIApp
|
||||
// treat that as a non-confirmation rather than crashing.
|
||||
return is_string($line) && strtolower(trim($line)) === 'y';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute synchronization across repositories
|
||||
*
|
||||
@@ -343,8 +342,12 @@ class BulkSync extends CLIApp
|
||||
// instead of leaving the run in an unknown state.
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGINT, function () { $this->interrupted = true; });
|
||||
pcntl_signal(SIGTERM, function () { $this->interrupted = true; });
|
||||
pcntl_signal(SIGINT, function () {
|
||||
$this->interrupted = true;
|
||||
});
|
||||
pcntl_signal(SIGTERM, function () {
|
||||
$this->interrupted = true;
|
||||
});
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
@@ -409,7 +412,6 @@ class BulkSync extends CLIApp
|
||||
$results['repositories'][$repoName] = 'skipped';
|
||||
$this->log(" ⊘ {$repoName} skipped", 'INFO');
|
||||
}
|
||||
|
||||
} catch (SynchronizationNotImplementedException $e) {
|
||||
$this->log("", 'ERROR');
|
||||
$this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR');
|
||||
@@ -431,12 +433,10 @@ class BulkSync extends CLIApp
|
||||
$this->log("Until this is implemented, bulk sync will not function.", 'ERROR');
|
||||
$this->log("", 'ERROR');
|
||||
throw $e;
|
||||
|
||||
} catch (CircuitBreakerOpen $e) {
|
||||
$results['failed']++;
|
||||
$results['repositories'][$repoName] = 'failed';
|
||||
$this->log(" ✗ {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR');
|
||||
|
||||
} catch (RateLimitExceeded $e) {
|
||||
// Rate limit hit — abort immediately so we don't burn retries on 403s
|
||||
$results['failed']++;
|
||||
@@ -444,7 +444,6 @@ class BulkSync extends CLIApp
|
||||
$this->log(" ✗ {$repoName} rate-limited: " . $e->getMessage(), 'ERROR');
|
||||
$this->saveInterruptCheckpoint($results, $repoName, 'rate_limited');
|
||||
break;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Also catch rate limits surfaced as generic exceptions by ApiClient retries
|
||||
if ($this->isRateLimitError($e)) {
|
||||
@@ -513,7 +512,7 @@ class BulkSync extends CLIApp
|
||||
$this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Display synchronization results
|
||||
*/
|
||||
@@ -522,22 +521,22 @@ class BulkSync extends CLIApp
|
||||
$this->log("\n" . str_repeat('=', 60), 'INFO');
|
||||
$this->log("📊 Synchronization Complete", 'INFO');
|
||||
$this->log(str_repeat('=', 60), 'INFO');
|
||||
|
||||
|
||||
$total = $results['total'];
|
||||
$success = $results['success'];
|
||||
$skipped = $results['skipped'];
|
||||
$failed = $results['failed'];
|
||||
$duration = $results['duration'];
|
||||
|
||||
|
||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
$this->log(sprintf("Total: %d repositories", $total), 'INFO');
|
||||
$this->log(sprintf("Success: %d (✓)", $success), 'INFO');
|
||||
$this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO');
|
||||
$this->log(sprintf("Failed: %d (✗)", $failed), 'INFO');
|
||||
$this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO');
|
||||
$this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO');
|
||||
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->log("\n⚠️ Failed Repositories:", 'WARN');
|
||||
foreach ($results['repositories'] as $repo => $status) {
|
||||
@@ -546,11 +545,11 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($this->verbose) {
|
||||
$this->log("\n📋 Repository Details:", 'INFO');
|
||||
foreach ($results['repositories'] as $repo => $status) {
|
||||
$icon = match($status) {
|
||||
$icon = match ($status) {
|
||||
'success' => '✓',
|
||||
'skipped' => '⊘',
|
||||
'failed' => '✗',
|
||||
@@ -559,12 +558,12 @@ class BulkSync extends CLIApp
|
||||
$this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->log(str_repeat('=', 60), 'INFO');
|
||||
|
||||
|
||||
$this->writeStepSummary($results);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Write synchronization results to the GitHub Actions step summary.
|
||||
*
|
||||
@@ -587,7 +586,7 @@ class BulkSync extends CLIApp
|
||||
if (empty($summaryFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate that the path is an absolute filesystem path and not a
|
||||
// special device file, to guard against environment variable injection.
|
||||
$realDir = realpath(dirname($summaryFile));
|
||||
@@ -595,14 +594,14 @@ class BulkSync extends CLIApp
|
||||
$this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$total = $results['total'];
|
||||
$success = $results['success'];
|
||||
$skipped = $results['skipped'];
|
||||
$failed = $results['failed'];
|
||||
$duration = $results['duration'];
|
||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '';
|
||||
$lines[] = '### 📊 Synchronization Summary';
|
||||
@@ -619,7 +618,7 @@ class BulkSync extends CLIApp
|
||||
$duration
|
||||
);
|
||||
$lines[] = '';
|
||||
|
||||
|
||||
if (!empty($results['repositories'])) {
|
||||
$lines[] = '### 📋 Repositories Processed';
|
||||
$lines[] = '';
|
||||
@@ -636,7 +635,7 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
|
||||
$written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
if ($written === false) {
|
||||
$this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN');
|
||||
@@ -736,8 +735,10 @@ class BulkSync extends CLIApp
|
||||
if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) {
|
||||
$hasVersion = true;
|
||||
}
|
||||
if ((str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
||||
|| $this->refsContain($refs, 'dev')) {
|
||||
if (
|
||||
(str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
||||
|| $this->refsContain($refs, 'dev')
|
||||
) {
|
||||
$hasDev = true;
|
||||
}
|
||||
if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) {
|
||||
@@ -745,10 +746,18 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasMain) { $score += 5; }
|
||||
if ($hasVersion) { $score += 5; }
|
||||
if ($hasDev) { $score += 5; }
|
||||
if ($hasRc) { $score += 5; }
|
||||
if ($hasMain) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasVersion) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasDev) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasRc) {
|
||||
$score += 5;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -756,7 +765,9 @@ class BulkSync extends CLIApp
|
||||
// 2. Check branch protection on main (10 pts)
|
||||
$max += 10;
|
||||
$hasMainProtection = $this->checkBranchProtected($org, $name);
|
||||
if ($hasMainProtection) { $score += 10; }
|
||||
if ($hasMainProtection) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
// Calculate level
|
||||
$pct = $max > 0 ? ($score / $max * 100) : 0;
|
||||
@@ -782,7 +793,10 @@ class BulkSync extends CLIApp
|
||||
$poor = count(array_filter($health, fn($h) => $h['level'] === 'poor'));
|
||||
$this->log(sprintf(
|
||||
"🩺 Health: %d excellent, %d good, %d fair, %d poor",
|
||||
$excellent, $good, $fair, $poor
|
||||
$excellent,
|
||||
$good,
|
||||
$fair,
|
||||
$poor
|
||||
), 'INFO');
|
||||
|
||||
return $health;
|
||||
@@ -1017,7 +1031,9 @@ class BulkSync extends CLIApp
|
||||
try {
|
||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
||||
} catch (\Exception $e) { /* fallback to main */ }
|
||||
} catch (\Exception $e) {
|
||||
/* fallback to main */
|
||||
}
|
||||
|
||||
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
|
||||
'state' => 'open',
|
||||
@@ -1047,7 +1063,7 @@ class BulkSync extends CLIApp
|
||||
if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) {
|
||||
$this->log(" ⚠️ Merge conflict: {$defaultBranch} → {$branch} (PR #{$prNum})", 'WARN');
|
||||
} elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) {
|
||||
// Already up to date — silently skip
|
||||
$this->log(" ✓ Already up to date: {$branch}", 'DEBUG');
|
||||
} else {
|
||||
$this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN');
|
||||
}
|
||||
@@ -1146,6 +1162,7 @@ class BulkSync extends CLIApp
|
||||
'sort' => 'created',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
@@ -1157,7 +1174,9 @@ class BulkSync extends CLIApp
|
||||
// Re-apply labels in case any were removed
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||
@@ -1181,7 +1200,9 @@ class BulkSync extends CLIApp
|
||||
'body' => $closeRef . "\n\n" . $currentBody,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
return is_int($num) ? $num : null;
|
||||
@@ -1285,11 +1306,12 @@ class BulkSync extends CLIApp
|
||||
'state' => 'all',
|
||||
'per_page' => 1,
|
||||
'sort' => 'created',
|
||||
'direction'=> 'desc',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
||||
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$issueNumber = $existing[0]['number'];
|
||||
@@ -1300,7 +1322,9 @@ class BulkSync extends CLIApp
|
||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||
@@ -1371,6 +1395,7 @@ class BulkSync extends CLIApp
|
||||
'sort' => 'created',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
@@ -1398,10 +1423,6 @@ class BulkSync extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new BulkSync(
|
||||
'bulk-sync',
|
||||
'Enterprise-grade bulk repository synchronization',
|
||||
BulkSync::VERSION
|
||||
);
|
||||
$app = new BulkSync();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -37,53 +38,82 @@ $dryRun = in_array('--dry-run', $argv, true);
|
||||
$repoFilter = null;
|
||||
$skipRepos = [];
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repoFilter = $argv[$i + 1];
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) $skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoFilter = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
||||
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
||||
|
||||
function safeExec(string $command, string $cwd = '.'): array {
|
||||
function safeExec(string $command, string $cwd = '.'): array
|
||||
{
|
||||
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
|
||||
if (!is_resource($proc)) return [1, "proc_open failed"];
|
||||
if (!is_resource($proc)) {
|
||||
return [1, "proc_open failed"];
|
||||
}
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]); fclose($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
|
||||
}
|
||||
|
||||
function rmTree(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
if ($file->isDir()) @rmdir($file->getPathname());
|
||||
else { @chmod($file->getPathname(), 0777); @unlink($file->getPathname()); }
|
||||
if ($file->isDir()) {
|
||||
@rmdir($file->getPathname());
|
||||
} else {
|
||||
@chmod($file->getPathname(), 0777);
|
||||
@unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
function gitCmd(string $workDir, string ...$args): array {
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) $cmd .= ' ' . escapeshellarg($a);
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
}
|
||||
return safeExec($cmd, $workDir);
|
||||
}
|
||||
|
||||
function fetchRepos(string $url, string $org, string $token): array {
|
||||
$repos = []; $page = 1;
|
||||
function fetchRepos(string $url, string $org, string $token): array
|
||||
{
|
||||
$repos = [];
|
||||
$page = 1;
|
||||
do {
|
||||
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
|
||||
$body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||||
if ($code !== 200) break;
|
||||
$batch = json_decode($body, true); if (empty($batch)) break;
|
||||
$repos = array_merge($repos, $batch); $page++;
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code !== 200) {
|
||||
break;
|
||||
}
|
||||
$batch = json_decode($body, true);
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
}
|
||||
$repos = array_merge($repos, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 50);
|
||||
return $repos;
|
||||
}
|
||||
|
||||
function inspectRepo(string $workDir, string $platform): array {
|
||||
function inspectRepo(string $workDir, string $platform): array
|
||||
{
|
||||
$enrichment = [];
|
||||
$build = [];
|
||||
|
||||
@@ -92,11 +122,13 @@ function inspectRepo(string $workDir, string $platform): array {
|
||||
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
|
||||
$c = file_get_contents($xf);
|
||||
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
|
||||
$build['entry_point'] = 'src/' . basename($xf); break;
|
||||
$build['entry_point'] = 'src/' . basename($xf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
|
||||
$build['entry_point'] = str_replace("{$workDir}/", '', $mf); break;
|
||||
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,24 +136,39 @@ function inspectRepo(string $workDir, string $platform): array {
|
||||
if (file_exists("{$workDir}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
|
||||
$phpReq = $composer['require']['php'] ?? null;
|
||||
if ($phpReq) $build['runtime'] = "php:{$phpReq}";
|
||||
if ($phpReq) {
|
||||
$build['runtime'] = "php:{$phpReq}";
|
||||
}
|
||||
|
||||
$deps = [];
|
||||
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
|
||||
if (isset($composer['require'][$pd])) $deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
|
||||
if (isset($composer['require'][$pd])) {
|
||||
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
|
||||
}
|
||||
}
|
||||
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
|
||||
$deps[] = [
|
||||
'name' => 'mokoconsulting-tech/enterprise',
|
||||
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
|
||||
'type' => 'composer',
|
||||
];
|
||||
}
|
||||
if (!empty($deps)) {
|
||||
$build['dependencies'] = $deps;
|
||||
}
|
||||
if (isset($composer['require']['mokoconsulting-tech/enterprise']))
|
||||
$deps[] = ['name' => 'mokoconsulting-tech/enterprise', 'version' => $composer['require']['mokoconsulting-tech/enterprise'], 'type' => 'composer'];
|
||||
if (!empty($deps)) $build['dependencies'] = $deps;
|
||||
}
|
||||
|
||||
// Artifact from Makefile
|
||||
if (file_exists("{$workDir}/Makefile")) {
|
||||
$mk = file_get_contents("{$workDir}/Makefile");
|
||||
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
|
||||
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
|
||||
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($build)) $enrichment['build'] = $build;
|
||||
if (!empty($build)) {
|
||||
$enrichment['build'] = $build;
|
||||
}
|
||||
|
||||
// Deploy targets from workflows
|
||||
$targets = [];
|
||||
@@ -129,55 +176,94 @@ function inspectRepo(string $workDir, string $platform): array {
|
||||
if (is_dir($wfDir)) {
|
||||
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
|
||||
$wf = "{$wfDir}/{$dn}.yml";
|
||||
if (!file_exists($wf)) continue;
|
||||
if (!file_exists($wf)) {
|
||||
continue;
|
||||
}
|
||||
$wc = file_get_contents($wf);
|
||||
$t = ['name' => str_replace('deploy-', '', $dn)];
|
||||
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) $t['method'] = 'sftp';
|
||||
elseif (str_contains($wc, 'rsync')) $t['method'] = 'rsync';
|
||||
if (str_contains($wc, 'src/')) $t['src_dir'] = 'src/';
|
||||
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) $t['branch'] = $m[1];
|
||||
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
|
||||
$t['method'] = 'sftp';
|
||||
} elseif (str_contains($wc, 'rsync')) {
|
||||
$t['method'] = 'rsync';
|
||||
}
|
||||
if (str_contains($wc, 'src/')) {
|
||||
$t['src_dir'] = 'src/';
|
||||
}
|
||||
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
|
||||
$t['branch'] = $m[1];
|
||||
}
|
||||
$targets[] = $t;
|
||||
}
|
||||
}
|
||||
if (!empty($targets)) $enrichment['deploy'] = $targets;
|
||||
if (!empty($targets)) {
|
||||
$enrichment['deploy'] = $targets;
|
||||
}
|
||||
|
||||
// Scripts from Makefile + composer
|
||||
$scripts = [];
|
||||
if (file_exists("{$workDir}/Makefile")) {
|
||||
$mk = file_get_contents("{$workDir}/Makefile");
|
||||
$known = ['build'=>'build','test'=>'test','lint'=>'lint','clean'=>'build','package'=>'build','validate'=>'validate','release'=>'release'];
|
||||
$known = [
|
||||
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
|
||||
'clean' => 'build', 'package' => 'build',
|
||||
'validate' => 'validate', 'release' => 'release',
|
||||
];
|
||||
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
|
||||
foreach ($matches[1] as $tgt) {
|
||||
$tl = strtolower($tgt);
|
||||
if (isset($known[$tl])) $scripts[] = ['name'=>$tl, 'phase'=>$known[$tl], 'command'=>"make {$tgt}", 'desc'=>ucfirst($tl).' via make', 'runner'=>'make'];
|
||||
if (isset($known[$tl])) {
|
||||
$scripts[] = [
|
||||
'name' => $tl, 'phase' => $known[$tl],
|
||||
'command' => "make {$tgt}",
|
||||
'desc' => ucfirst($tl) . ' via make',
|
||||
'runner' => 'make',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file_exists("{$workDir}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
|
||||
$km = ['test'=>'test','lint'=>'lint','cs'=>'lint','phpcs'=>'lint','phpstan'=>'lint','validate'=>'validate'];
|
||||
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
|
||||
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
|
||||
$sl = strtolower($sn);
|
||||
foreach ($km as $match => $phase) {
|
||||
if (str_contains($sl, $match)) {
|
||||
$exists = false;
|
||||
foreach ($scripts as $s) { if ($s['name'] === $sl) { $exists = true; break; } }
|
||||
if (!$exists) $scripts[] = ['name'=>$sn, 'phase'=>$phase, 'command'=>"composer run {$sn}", 'desc'=>is_string($cmd)?$cmd:"Run {$sn}", 'runner'=>'composer'];
|
||||
foreach ($scripts as $s) {
|
||||
if ($s['name'] === $sl) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$scripts[] = [
|
||||
'name' => $sn, 'phase' => $phase,
|
||||
'command' => "composer run {$sn}",
|
||||
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
|
||||
'runner' => 'composer',
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($scripts)) $enrichment['scripts'] = $scripts;
|
||||
if (!empty($scripts)) {
|
||||
$enrichment['scripts'] = $scripts;
|
||||
}
|
||||
|
||||
return $enrichment;
|
||||
}
|
||||
|
||||
function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
function enrichManifestXml(string $xml, array $enrichment): string
|
||||
{
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
$dom->preserveWhiteSpace = false;
|
||||
$dom->formatOutput = true;
|
||||
if (!$dom->loadXML($xml)) return $xml;
|
||||
if (!$dom->loadXML($xml)) {
|
||||
return $xml;
|
||||
}
|
||||
|
||||
$ns = MokoStandardsParser::NAMESPACE_URI;
|
||||
$root = $dom->documentElement;
|
||||
@@ -185,19 +271,35 @@ function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
foreach (['build', 'deploy', 'scripts'] as $tag) {
|
||||
$toRemove = [];
|
||||
$existing = $root->getElementsByTagNameNS($ns, $tag);
|
||||
for ($i = 0; $i < $existing->length; $i++) $toRemove[] = $existing->item($i);
|
||||
foreach ($toRemove as $node) $root->removeChild($node);
|
||||
for ($i = 0; $i < $existing->length; $i++) {
|
||||
$toRemove[] = $existing->item($i);
|
||||
}
|
||||
foreach ($toRemove as $node) {
|
||||
$root->removeChild($node);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($enrichment['build'])) {
|
||||
$build = $dom->createElementNS($ns, 'build');
|
||||
$b = $enrichment['build'];
|
||||
foreach (['language', 'runtime'] as $f) { if (isset($b[$f])) $build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); }
|
||||
if (isset($b['package_type'])) $build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
|
||||
if (isset($b['entry_point'])) $build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
|
||||
foreach (['language', 'runtime'] as $f) {
|
||||
if (isset($b[$f])) {
|
||||
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
|
||||
}
|
||||
}
|
||||
if (isset($b['package_type'])) {
|
||||
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
|
||||
}
|
||||
if (isset($b['entry_point'])) {
|
||||
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
|
||||
}
|
||||
if (isset($b['artifact'])) {
|
||||
$art = $dom->createElementNS($ns, 'artifact');
|
||||
foreach (['format','path','filename'] as $af) { if (isset($b['artifact'][$af])) $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); }
|
||||
foreach (['format','path','filename'] as $af) {
|
||||
if (isset($b['artifact'][$af])) {
|
||||
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
|
||||
}
|
||||
}
|
||||
$build->appendChild($art);
|
||||
}
|
||||
if (isset($b['dependencies'])) {
|
||||
@@ -205,8 +307,12 @@ function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
foreach ($b['dependencies'] as $d) {
|
||||
$req = $dom->createElementNS($ns, 'requires', '');
|
||||
$req->setAttribute('name', $d['name']);
|
||||
if (isset($d['version'])) $req->setAttribute('version', $d['version']);
|
||||
if (isset($d['type'])) $req->setAttribute('type', $d['type']);
|
||||
if (isset($d['version'])) {
|
||||
$req->setAttribute('version', $d['version']);
|
||||
}
|
||||
if (isset($d['type'])) {
|
||||
$req->setAttribute('type', $d['type']);
|
||||
}
|
||||
$deps->appendChild($req);
|
||||
}
|
||||
$build->appendChild($deps);
|
||||
@@ -221,9 +327,15 @@ function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
$target->setAttribute('name', $t['name']);
|
||||
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
|
||||
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
|
||||
if (isset($t['method'])) $target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
|
||||
if (isset($t['branch'])) $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
|
||||
if (isset($t['src_dir'])) $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
|
||||
if (isset($t['method'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
|
||||
}
|
||||
if (isset($t['branch'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
|
||||
}
|
||||
if (isset($t['src_dir'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
|
||||
}
|
||||
$deploy->appendChild($target);
|
||||
}
|
||||
$root->appendChild($deploy);
|
||||
@@ -234,10 +346,16 @@ function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
foreach ($enrichment['scripts'] as $s) {
|
||||
$script = $dom->createElementNS($ns, 'script');
|
||||
$script->setAttribute('name', $s['name']);
|
||||
if (isset($s['phase'])) $script->setAttribute('phase', $s['phase']);
|
||||
if (isset($s['phase'])) {
|
||||
$script->setAttribute('phase', $s['phase']);
|
||||
}
|
||||
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
|
||||
if (isset($s['desc'])) $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
|
||||
if (isset($s['runner'])) $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
|
||||
if (isset($s['desc'])) {
|
||||
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
|
||||
}
|
||||
if (isset($s['runner'])) {
|
||||
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
|
||||
}
|
||||
$scriptsEl->appendChild($script);
|
||||
}
|
||||
$root->appendChild($scriptsEl);
|
||||
@@ -249,10 +367,15 @@ function enrichManifestXml(string $xml, array $enrichment): string {
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
echo "=== MokoStandards XML Manifest Enrichment ===\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if (!empty($skipRepos)) echo "Skipping: " . implode(', ', $skipRepos) . "\n";
|
||||
if (!empty($skipRepos)) {
|
||||
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($token)) { fprintf(STDERR, "ERROR: GA_TOKEN required\n"); exit(1); }
|
||||
if (empty($token)) {
|
||||
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
@@ -261,9 +384,18 @@ $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if ($repoFilter && $name !== $repoFilter) continue;
|
||||
if (in_array($name, $skipRepos, true)) { echo " {$name} ... SKIP (excluded)\n"; $stats['skipped']++; continue; }
|
||||
if ($repo['archived'] ?? false) { $stats['skipped']++; continue; }
|
||||
if ($repoFilter && $name !== $repoFilter) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, $skipRepos, true)) {
|
||||
echo " {$name} ... SKIP (excluded)\n";
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
if ($repo['archived'] ?? false) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultBranch = $repo['default_branch'] ?? 'main';
|
||||
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
|
||||
@@ -273,19 +405,31 @@ foreach ($repos as $repo) {
|
||||
|
||||
$workDir = "{$tmpBase}/{$name}";
|
||||
@mkdir($workDir, 0755, true);
|
||||
[$ret] = safeExec('git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir));
|
||||
if ($ret !== 0) { echo "FAIL (clone)\n"; $stats['failed']++; continue; }
|
||||
[$ret] = safeExec(
|
||||
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
|
||||
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
|
||||
);
|
||||
if ($ret !== 0) {
|
||||
echo "FAIL (clone)\n";
|
||||
$stats['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
|
||||
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
|
||||
echo "SKIP (no XML manifest)\n"; $stats['skipped']++; rmTree($workDir); continue;
|
||||
echo "SKIP (no XML manifest)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingXml = file_get_contents($manifestPath);
|
||||
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
|
||||
$enrichment = inspectRepo($workDir, $platform);
|
||||
|
||||
if (!isset($enrichment['build'])) $enrichment['build'] = [];
|
||||
if (!isset($enrichment['build'])) {
|
||||
$enrichment['build'] = [];
|
||||
}
|
||||
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
||||
|
||||
@@ -294,7 +438,12 @@ foreach ($repos as $repo) {
|
||||
$sc = count($enrichment['scripts'] ?? []);
|
||||
$details = "deploy={$dc} scripts={$sc}";
|
||||
|
||||
if ($dryRun) { echo "WOULD ENRICH [{$details}]\n"; $stats['enriched']++; rmTree($workDir); continue; }
|
||||
if ($dryRun) {
|
||||
echo "WOULD ENRICH [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($manifestPath, $enrichedXml);
|
||||
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
||||
@@ -302,11 +451,21 @@ foreach ($repos as $repo) {
|
||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
||||
|
||||
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
|
||||
if ($cr !== 0) { echo "SKIP (no diff)\n"; $stats['skipped']++; rmTree($workDir); continue; }
|
||||
if ($cr !== 0) {
|
||||
echo "SKIP (no diff)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
||||
if ($pr !== 0) { echo "FAIL (push)\n"; $stats['failed']++; }
|
||||
else { echo "ENRICHED [{$details}]\n"; $stats['enriched']++; }
|
||||
if ($pr !== 0) {
|
||||
echo "FAIL (push)\n";
|
||||
$stats['failed']++;
|
||||
} else {
|
||||
echo "ENRICHED [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
}
|
||||
|
||||
rmTree($workDir);
|
||||
}
|
||||
|
||||
+216
-211
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -41,254 +42,258 @@ use MokoEnterprise\MokoGiteaAdapter;
|
||||
*/
|
||||
class MigrateToGitea extends CliFramework
|
||||
{
|
||||
private ?GitHubAdapter $github = null;
|
||||
private ?MokoGiteaAdapter $gitea = null;
|
||||
private ?CheckpointManager $checkpoints = null;
|
||||
private ?GitHubAdapter $github = null;
|
||||
private ?MokoGiteaAdapter $gitea = null;
|
||||
private ?CheckpointManager $checkpoints = null;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
||||
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
||||
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
||||
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
||||
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
||||
$this->addArgument('--github-token', 'GitHub token override', '');
|
||||
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
||||
}
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
||||
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
||||
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
||||
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
||||
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
||||
$this->addArgument('--github-token', 'GitHub token override', '');
|
||||
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
||||
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
||||
$resume = (bool) $this->getArgument('--resume');
|
||||
protected function run(): int
|
||||
{
|
||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
||||
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
||||
$resume = (bool) $this->getArgument('--resume');
|
||||
|
||||
$config = Config::load();
|
||||
$config = Config::load();
|
||||
|
||||
// Override tokens if provided
|
||||
$ghToken = (string) $this->getArgument('--github-token');
|
||||
$giteaToken = (string) $this->getArgument('--gitea-token');
|
||||
if ($ghToken !== '') { $config->set('github.token', $ghToken); }
|
||||
if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); }
|
||||
// Override tokens if provided
|
||||
$ghToken = (string) $this->getArgument('--github-token');
|
||||
$giteaToken = (string) $this->getArgument('--gitea-token');
|
||||
if ($ghToken !== '') {
|
||||
$config->set('github.token', $ghToken);
|
||||
}
|
||||
if ($giteaToken !== '') {
|
||||
$config->set('gitea.token', $giteaToken);
|
||||
}
|
||||
|
||||
// Create both adapters
|
||||
try {
|
||||
$adapters = PlatformAdapterFactory::createBoth($config);
|
||||
$this->github = $adapters['github'];
|
||||
$this->gitea = $adapters['gitea'];
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->log('ERROR', $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
// Create both adapters
|
||||
try {
|
||||
$adapters = PlatformAdapterFactory::createBoth($config);
|
||||
$this->github = $adapters['github'];
|
||||
$this->gitea = $adapters['gitea'];
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->log('ERROR', $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
||||
$org = $config->getString('github.organization', 'MokoConsulting');
|
||||
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
||||
$org = $config->getString('github.organization', 'MokoConsulting');
|
||||
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
||||
|
||||
echo "=== Gitea Migration Tool ===\n";
|
||||
echo "Source: GitHub ({$org})\n";
|
||||
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
||||
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
||||
echo "=== Gitea Migration Tool ===\n";
|
||||
echo "Source: GitHub ({$org})\n";
|
||||
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
||||
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
||||
|
||||
// ── Phase 1: Discovery ──────────────────────────────────────────
|
||||
$this->section('Phase 1: Discovery');
|
||||
// ── Phase 1: Discovery ──────────────────────────────────────────
|
||||
$this->section('Phase 1: Discovery');
|
||||
|
||||
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
||||
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
||||
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
||||
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
||||
|
||||
// Filter repos
|
||||
if (!empty($specificRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
||||
}
|
||||
if (!empty($excludeRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
||||
}
|
||||
// Filter repos
|
||||
if (!empty($specificRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
||||
}
|
||||
if (!empty($excludeRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
||||
}
|
||||
|
||||
// Check which already exist on Gitea
|
||||
$giteaRepos = [];
|
||||
try {
|
||||
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
||||
foreach ($existing as $r) {
|
||||
$giteaRepos[$r['name']] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
||||
}
|
||||
// Check which already exist on Gitea
|
||||
$giteaRepos = [];
|
||||
try {
|
||||
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
||||
foreach ($existing as $r) {
|
||||
$giteaRepos[$r['name']] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
$toMigrate = [];
|
||||
$toSkip = [];
|
||||
foreach ($ghRepos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if (isset($giteaRepos[$name])) {
|
||||
$toSkip[] = $name;
|
||||
} else {
|
||||
$toMigrate[] = $repo;
|
||||
}
|
||||
}
|
||||
$toMigrate = [];
|
||||
$toSkip = [];
|
||||
foreach ($ghRepos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if (isset($giteaRepos[$name])) {
|
||||
$toSkip[] = $name;
|
||||
} else {
|
||||
$toMigrate[] = $repo;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nMigration plan:\n";
|
||||
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
||||
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
||||
if (!empty($toSkip)) {
|
||||
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
echo "\nMigration plan:\n";
|
||||
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
||||
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
||||
if (!empty($toSkip)) {
|
||||
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($toMigrate)) {
|
||||
echo "Nothing to migrate.\n";
|
||||
return 0;
|
||||
}
|
||||
if (empty($toMigrate)) {
|
||||
echo "Nothing to migrate.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
echo "Repositories to migrate:\n";
|
||||
foreach ($toMigrate as $repo) {
|
||||
$vis = $repo['private'] ? 'private' : 'public';
|
||||
echo " - {$repo['name']} ({$vis})\n";
|
||||
}
|
||||
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
||||
return 0;
|
||||
}
|
||||
if ($dryRun) {
|
||||
echo "Repositories to migrate:\n";
|
||||
foreach ($toMigrate as $repo) {
|
||||
$vis = $repo['private'] ? 'private' : 'public';
|
||||
echo " - {$repo['name']} ({$vis})\n";
|
||||
}
|
||||
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Phase 2: Migrate ────────────────────────────────────────────
|
||||
$this->section('Phase 2: Migration');
|
||||
// ── Phase 2: Migrate ────────────────────────────────────────────
|
||||
$this->section('Phase 2: Migration');
|
||||
|
||||
$ghToken = $config->getString('github.token');
|
||||
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
||||
$ghToken = $config->getString('github.token');
|
||||
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
||||
|
||||
// Resume support
|
||||
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
||||
$startFrom = $checkpoint['last_completed'] ?? '';
|
||||
$skipUntil = !empty($startFrom);
|
||||
// Resume support
|
||||
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
||||
$startFrom = $checkpoint['last_completed'] ?? '';
|
||||
$skipUntil = !empty($startFrom);
|
||||
|
||||
foreach ($toMigrate as $index => $repo) {
|
||||
$name = $repo['name'];
|
||||
foreach ($toMigrate as $index => $repo) {
|
||||
$name = $repo['name'];
|
||||
|
||||
if ($skipUntil) {
|
||||
if ($name === $startFrom) {
|
||||
$skipUntil = false;
|
||||
}
|
||||
echo " Skipping {$name} (already migrated)\n";
|
||||
continue;
|
||||
}
|
||||
if ($skipUntil) {
|
||||
if ($name === $startFrom) {
|
||||
$skipUntil = false;
|
||||
}
|
||||
echo " Skipping {$name} (already migrated)\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
||||
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
||||
|
||||
try {
|
||||
// Shallow migration — copy current branch state only, no past
|
||||
// commit history. This gives every repo a clean start on Gitea.
|
||||
$this->gitea->migrateRepository([
|
||||
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
||||
'repo_name' => $name,
|
||||
'repo_owner' => $giteaOrg,
|
||||
'service' => 'github',
|
||||
'auth_token' => $ghToken,
|
||||
'mirror' => false,
|
||||
'private' => $repo['private'],
|
||||
'issues' => false,
|
||||
'labels' => true,
|
||||
'milestones' => false,
|
||||
'releases' => false,
|
||||
'pull_requests' => false,
|
||||
'wiki' => false,
|
||||
]);
|
||||
try {
|
||||
// Shallow migration — copy current branch state only, no past
|
||||
// commit history. This gives every repo a clean start on Gitea.
|
||||
$this->gitea->migrateRepository([
|
||||
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
||||
'repo_name' => $name,
|
||||
'repo_owner' => $giteaOrg,
|
||||
'service' => 'github',
|
||||
'auth_token' => $ghToken,
|
||||
'mirror' => false,
|
||||
'private' => $repo['private'],
|
||||
'issues' => false,
|
||||
'labels' => true,
|
||||
'milestones' => false,
|
||||
'releases' => false,
|
||||
'pull_requests' => false,
|
||||
'wiki' => false,
|
||||
]);
|
||||
|
||||
echo " Migrated successfully\n";
|
||||
$results['migrated'][] = $name;
|
||||
echo " Migrated successfully\n";
|
||||
$results['migrated'][] = $name;
|
||||
|
||||
// Save checkpoint after each successful migration
|
||||
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
||||
'last_completed' => $name,
|
||||
'migrated' => $results['migrated'],
|
||||
'failed' => $results['failed'],
|
||||
]);
|
||||
// Save checkpoint after each successful migration
|
||||
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
||||
'last_completed' => $name,
|
||||
'migrated' => $results['migrated'],
|
||||
'failed' => $results['failed'],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
echo " FAILED: " . $e->getMessage() . "\n";
|
||||
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo " FAILED: " . $e->getMessage() . "\n";
|
||||
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
// ── Phase 3: Post-migration ─────────────────────────────────────
|
||||
$this->section('Phase 3: Post-migration');
|
||||
|
||||
// ── Phase 3: Post-migration ─────────────────────────────────────
|
||||
$this->section('Phase 3: Post-migration');
|
||||
foreach ($results['migrated'] as $name) {
|
||||
echo " Post-processing {$name}...\n";
|
||||
|
||||
foreach ($results['migrated'] as $name) {
|
||||
echo " Post-processing {$name}...\n";
|
||||
try {
|
||||
// Apply topics from GitHub
|
||||
$ghTopics = $this->github->getRepoTopics($org, $name);
|
||||
if (!empty($ghTopics)) {
|
||||
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
|
||||
echo " Topics applied\n";
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply topics from GitHub
|
||||
$ghTopics = $this->github->getRepoTopics($org, $name);
|
||||
if (!empty($ghTopics)) {
|
||||
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
|
||||
echo " Topics applied\n";
|
||||
}
|
||||
// Apply branch protection
|
||||
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'block_on_rejected' => true,
|
||||
]);
|
||||
echo " Branch protection applied\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply branch protection
|
||||
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'block_on_rejected' => true,
|
||||
]);
|
||||
echo " Branch protection applied\n";
|
||||
// ── Phase 4: Verification ───────────────────────────────────────
|
||||
$this->section('Phase 4: Verification');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
$report = "## Migration Report\n\n";
|
||||
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
||||
$report .= "**Source:** GitHub ({$org})\n";
|
||||
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
||||
|
||||
// ── Phase 4: Verification ───────────────────────────────────────
|
||||
$this->section('Phase 4: Verification');
|
||||
$report .= "### Results\n\n";
|
||||
$report .= "| Status | Count |\n|--------|-------|\n";
|
||||
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
|
||||
$report .= "| Failed | " . count($results['failed']) . " |\n";
|
||||
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
|
||||
|
||||
$report = "## Migration Report\n\n";
|
||||
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
||||
$report .= "**Source:** GitHub ({$org})\n";
|
||||
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
||||
if (!empty($results['migrated'])) {
|
||||
$report .= "### Migrated Repositories\n\n";
|
||||
foreach ($results['migrated'] as $name) {
|
||||
$report .= "- {$name}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
|
||||
$report .= "### Results\n\n";
|
||||
$report .= "| Status | Count |\n|--------|-------|\n";
|
||||
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
|
||||
$report .= "| Failed | " . count($results['failed']) . " |\n";
|
||||
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
|
||||
if (!empty($results['failed'])) {
|
||||
$report .= "### Failed Repositories\n\n";
|
||||
foreach ($results['failed'] as $fail) {
|
||||
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
|
||||
if (!empty($results['migrated'])) {
|
||||
$report .= "### Migrated Repositories\n\n";
|
||||
foreach ($results['migrated'] as $name) {
|
||||
$report .= "- {$name}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
echo $report;
|
||||
|
||||
if (!empty($results['failed'])) {
|
||||
$report .= "### Failed Repositories\n\n";
|
||||
foreach ($results['failed'] as $fail) {
|
||||
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
// Create summary issue on Gitea
|
||||
try {
|
||||
$this->gitea->createIssue(
|
||||
$giteaOrg,
|
||||
'MokoStandards',
|
||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||
$report,
|
||||
['labels' => ['automation', 'type: chore']]
|
||||
);
|
||||
echo "Migration report issue created on Gitea.\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "Could not create report issue: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo $report;
|
||||
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
|
||||
. count($results['failed']) . " failed, "
|
||||
. count($results['skipped']) . " skipped\n";
|
||||
|
||||
// Create summary issue on Gitea
|
||||
try {
|
||||
$this->gitea->createIssue($giteaOrg, 'MokoStandards',
|
||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||
$report,
|
||||
['labels' => ['automation', 'type: chore']]
|
||||
);
|
||||
echo "Migration report issue created on Gitea.\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "Could not create report issue: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
|
||||
. count($results['failed']) . " failed, "
|
||||
. count($results['skipped']) . " skipped\n";
|
||||
|
||||
return count($results['failed']) > 0 ? 1 : 0;
|
||||
}
|
||||
return count($results['failed']) > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
|
||||
|
||||
+52
-39
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -23,7 +24,7 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
use MokoEnterprise\{
|
||||
ApiClient,
|
||||
AuditLogger,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
DefinitionParser,
|
||||
GitPlatformAdapter,
|
||||
@@ -50,32 +51,31 @@ use MokoEnterprise\{
|
||||
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
|
||||
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
|
||||
*/
|
||||
class PushFiles extends CLIApp
|
||||
class PushFiles extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.00';
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private DefinitionParser $defParser;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private DefinitionParser $defParser;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')',
|
||||
'repos:' => 'Target repositories — comma or space-separated (required)',
|
||||
'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)',
|
||||
'message:' => 'Custom commit message (optional)',
|
||||
'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set',
|
||||
'direct' => 'Push directly to target branch instead of creating a PR',
|
||||
'yes' => 'Auto-confirm without prompting',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
];
|
||||
$this->setDescription('Push files to remote repositories');
|
||||
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
||||
$this->addArgument('--files', 'Files to push (comma-separated)', '');
|
||||
$this->addArgument('--message', 'Custom commit message', '');
|
||||
$this->addArgument('--branch', 'Target branch for direct pushes', '');
|
||||
$this->addArgument('--direct', 'Push directly instead of PR', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
|
||||
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +89,11 @@ class PushFiles extends CLIApp
|
||||
return 1;
|
||||
}
|
||||
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getOption('repos', '');
|
||||
$filesArg = $this->getOption('files', '');
|
||||
$direct = $this->hasOption('direct');
|
||||
$autoYes = $this->hasOption('yes');
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getArgument('--repos', '');
|
||||
$filesArg = $this->getArgument('--files', '');
|
||||
$direct = $this->getArgument('--direct', false);
|
||||
$autoYes = $this->getArgument('--yes', false);
|
||||
|
||||
// Validate required arguments
|
||||
if (empty($reposArg)) {
|
||||
@@ -126,7 +126,7 @@ class PushFiles extends CLIApp
|
||||
}
|
||||
|
||||
// Confirm before proceeding
|
||||
if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) {
|
||||
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
|
||||
$this->log('❌ Cancelled.', 'INFO');
|
||||
return 0;
|
||||
}
|
||||
@@ -264,7 +264,8 @@ class PushFiles extends CLIApp
|
||||
// Fall back to live detection
|
||||
try {
|
||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
||||
return $this->typeDetector->detect($repoData, $org, $repo);
|
||||
$result = $this->typeDetector->detect('.');
|
||||
return $result['type'] ?? 'default';
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
|
||||
return 'default';
|
||||
@@ -276,7 +277,7 @@ class PushFiles extends CLIApp
|
||||
*
|
||||
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
|
||||
*/
|
||||
private function confirm(array $repoFileMaps, bool $direct): bool
|
||||
private function confirmPush(array $repoFileMaps, bool $direct): bool
|
||||
{
|
||||
if ($this->quiet) {
|
||||
return true;
|
||||
@@ -321,8 +322,8 @@ class PushFiles extends CLIApp
|
||||
'repos' => [],
|
||||
];
|
||||
|
||||
$customMessage = $this->getOption('message', '');
|
||||
$targetBranch = $this->getOption('branch', '');
|
||||
$customMessage = $this->getArgument('--message', '');
|
||||
$targetBranch = $this->getArgument('--branch', '');
|
||||
|
||||
foreach ($repoFileMaps as $repo => $entries) {
|
||||
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
|
||||
@@ -356,7 +357,12 @@ class PushFiles extends CLIApp
|
||||
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
|
||||
$prBody = $this->buildPRBody($entries);
|
||||
$pr = $this->adapter->createPullRequest(
|
||||
$org, $repo, $prTitle, $branch, $defaultBranch, $prBody,
|
||||
$org,
|
||||
$repo,
|
||||
$prTitle,
|
||||
$branch,
|
||||
$defaultBranch,
|
||||
$prBody,
|
||||
['assignees' => ['jmiller']]
|
||||
);
|
||||
$prNumber = $pr['number'] ?? null;
|
||||
@@ -371,7 +377,6 @@ class PushFiles extends CLIApp
|
||||
}
|
||||
|
||||
$results['success']++;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ✗ {$repo}: " . $e->getMessage(), 'ERROR');
|
||||
$results['failed']++;
|
||||
@@ -440,7 +445,13 @@ class PushFiles extends CLIApp
|
||||
|
||||
try {
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $destPath, $content, $message, $existingSha, $branch
|
||||
$org,
|
||||
$repo,
|
||||
$destPath,
|
||||
$content,
|
||||
$message,
|
||||
$existingSha,
|
||||
$branch
|
||||
);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -509,6 +520,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -518,7 +530,9 @@ class PushFiles extends CLIApp
|
||||
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||
@@ -543,7 +557,9 @@ class PushFiles extends CLIApp
|
||||
'body' => $ref . "\n\n" . $currentBody,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
|
||||
@@ -566,7 +582,7 @@ class PushFiles extends CLIApp
|
||||
));
|
||||
|
||||
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
|
||||
$fileArgs = $this->getOption('files', '');
|
||||
$fileArgs = $this->getArgument('--files', '');
|
||||
|
||||
$title = "fix: push_files failed for {$failed} repo(s) — action required";
|
||||
|
||||
@@ -607,6 +623,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -678,10 +695,6 @@ class PushFiles extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new PushFiles(
|
||||
'push-files',
|
||||
'Push one or more specific files to one or more remote repositories',
|
||||
PushFiles::VERSION
|
||||
);
|
||||
$app = new PushFiles();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -52,28 +53,53 @@ $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
|
||||
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
|
||||
|
||||
function detectPlatform(array $repo): string {
|
||||
function detectPlatform(array $repo): string
|
||||
{
|
||||
global $CRM_PLATFORM_REPOS;
|
||||
$name = $repo['name'] ?? '';
|
||||
$nameLower = strtolower($name);
|
||||
$description = strtolower($repo['description'] ?? '');
|
||||
$topics = $repo['topics'] ?? [];
|
||||
|
||||
if (in_array($name, $CRM_PLATFORM_REPOS, true)) return 'crm-platform';
|
||||
if (in_array('dolibarr-platform', $topics)) return 'crm-platform';
|
||||
if (in_array('joomla-template', $topics)) return 'joomla-template';
|
||||
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) return 'waas-component';
|
||||
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) return 'crm-module';
|
||||
if (in_array($name, $CRM_PLATFORM_REPOS, true)) {
|
||||
return 'crm-platform';
|
||||
}
|
||||
if (in_array('dolibarr-platform', $topics)) {
|
||||
return 'crm-platform';
|
||||
}
|
||||
if (in_array('joomla-template', $topics)) {
|
||||
return 'joomla-template';
|
||||
}
|
||||
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
|
||||
return 'waas-component';
|
||||
}
|
||||
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
|
||||
return 'crm-module';
|
||||
}
|
||||
|
||||
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) return 'joomla-template';
|
||||
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) return 'waas-component';
|
||||
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) return 'crm-module';
|
||||
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
|
||||
return 'joomla-template';
|
||||
}
|
||||
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
|
||||
return 'waas-component';
|
||||
}
|
||||
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
|
||||
return 'crm-module';
|
||||
}
|
||||
|
||||
if (str_contains($description, 'joomla template')) return 'joomla-template';
|
||||
if (str_contains($description, 'joomla') || str_contains($description, 'component')) return 'waas-component';
|
||||
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) return 'crm-module';
|
||||
if (str_contains($description, 'joomla template')) {
|
||||
return 'joomla-template';
|
||||
}
|
||||
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
|
||||
return 'waas-component';
|
||||
}
|
||||
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
|
||||
return 'crm-module';
|
||||
}
|
||||
|
||||
if (str_contains($nameLower, 'standard')) return 'standards-repository';
|
||||
if (str_contains($nameLower, 'standard')) {
|
||||
return 'standards-repository';
|
||||
}
|
||||
return 'default-repository';
|
||||
}
|
||||
|
||||
@@ -81,7 +107,8 @@ function detectPlatform(array $repo): string {
|
||||
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
function safeExec(string $command, string $cwd = '.'): array {
|
||||
function safeExec(string $command, string $cwd = '.'): array
|
||||
{
|
||||
$proc = proc_open(
|
||||
$command,
|
||||
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
@@ -100,8 +127,11 @@ function safeExec(string $command, string $cwd = '.'): array {
|
||||
}
|
||||
|
||||
/** Recursively remove a directory (cross-platform). */
|
||||
function rmTree(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
@@ -120,7 +150,8 @@ function rmTree(string $dir): void {
|
||||
* Run a git command safely in a given working directory.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
function gitCmd(string $workDir, string ...$args): array {
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
@@ -129,7 +160,8 @@ function gitCmd(string $workDir, string ...$args): array {
|
||||
}
|
||||
|
||||
// ── Fetch all repos via API ──────────────────────────────────────────────
|
||||
function fetchRepos(string $url, string $org, string $token): array {
|
||||
function fetchRepos(string $url, string $org, string $token): array
|
||||
{
|
||||
$repos = [];
|
||||
$page = 1;
|
||||
do {
|
||||
@@ -149,7 +181,9 @@ function fetchRepos(string $url, string $org, string $token): array {
|
||||
}
|
||||
|
||||
$batch = json_decode($body, true);
|
||||
if (empty($batch)) break;
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
}
|
||||
$repos = array_merge($repos, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 50);
|
||||
@@ -161,7 +195,9 @@ function fetchRepos(string $url, string $org, string $token): array {
|
||||
echo "=== MokoStandards XML Manifest Push ===\n";
|
||||
echo "Org: {$giteaOrg}\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if ($repoFilter) echo "Filter: {$repoFilter}\n";
|
||||
if ($repoFilter) {
|
||||
echo "Filter: {$repoFilter}\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($token)) {
|
||||
@@ -176,7 +212,9 @@ $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if ($repoFilter && $name !== $repoFilter) continue;
|
||||
if ($repoFilter && $name !== $repoFilter) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, $skipRepos, true)) {
|
||||
echo " SKIP {$name} (excluded)\n";
|
||||
$stats['skipped']++;
|
||||
|
||||
+115
-98
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,7 +21,7 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
|
||||
/**
|
||||
* Enterprise Repository Cleanup
|
||||
@@ -35,7 +36,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter,
|
||||
* 7. Verify and provision standard labels
|
||||
* 8. Version drift detection
|
||||
*/
|
||||
class RepoCleanup extends CLIApp
|
||||
class RepoCleanup extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
||||
@@ -55,44 +56,36 @@ class RepoCleanup extends CLIApp
|
||||
'deploy-rs.yml',
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private bool $dryRun = false;
|
||||
private float $startTime;
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
protected bool $dryRun = false;
|
||||
private float $startTime;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('repo-cleanup');
|
||||
$this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs');
|
||||
$this->setVersion(self::VERSION);
|
||||
|
||||
$this->addOption('org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addOption('repos', 'Specific repositories (space-separated)', '');
|
||||
$this->addOption('skip-archived', 'Skip archived repositories', false);
|
||||
$this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false);
|
||||
$this->addOption('lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false);
|
||||
$this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false);
|
||||
$this->addOption('log-days', 'Days to keep logs (default: 30)', '30');
|
||||
$this->addOption('delete-retired', 'Delete retired workflow files from repos', false);
|
||||
$this->addOption('check-labels', 'Verify mokostandards label exists', false);
|
||||
$this->addOption('check-drift', 'Check for version drift against README.md', false);
|
||||
$this->addOption('all', 'Run all cleanup operations', false);
|
||||
$this->addOption('yes', 'Auto-confirm prompts', false);
|
||||
$this->addOption('dry-run', 'Preview changes without making them', false);
|
||||
$this->addOption('verbose', 'Show detailed output', false);
|
||||
$this->addOption('quiet', 'Suppress non-error output', false);
|
||||
$this->addOption('json', 'Output results as JSON', false);
|
||||
$this->setDescription('Enterprise repository cleanup');
|
||||
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
|
||||
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
|
||||
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
|
||||
$this->addArgument('--log-days', 'Days to keep logs', '30');
|
||||
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
|
||||
$this->addArgument('--check-labels', 'Verify labels exist', false);
|
||||
$this->addArgument('--check-drift', 'Check version drift', false);
|
||||
$this->addArgument('--all', 'Run all operations', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function execute(): int
|
||||
protected function run(): int
|
||||
{
|
||||
$this->startTime = microtime(true);
|
||||
$org = $this->getOption('org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getOption('dry-run', false);
|
||||
$runAll = (bool) $this->getOption('all', false);
|
||||
$org = $this->getArgument('--org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
|
||||
$runAll = (bool) $this->getArgument('--all', false);
|
||||
|
||||
$config = Config::load();
|
||||
|
||||
@@ -100,24 +93,22 @@ class RepoCleanup extends CLIApp
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('repo_cleanup');
|
||||
$this->metrics = new MetricsCollector('repo_cleanup');
|
||||
|
||||
$this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->log("Organization: {$org}");
|
||||
$this->log("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->logMsg("Organization: {$org}");
|
||||
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
if ($this->dryRun) {
|
||||
$this->log("⚠️ DRY RUN — no changes will be made");
|
||||
$this->logMsg("⚠️ DRY RUN — no changes will be made");
|
||||
}
|
||||
$this->log('');
|
||||
$this->logMsg('');
|
||||
|
||||
$repos = $this->fetchRepositories($org);
|
||||
$this->log("Found " . count($repos) . " repositories");
|
||||
$this->log('');
|
||||
$this->logMsg("Found " . count($repos) . " repositories");
|
||||
$this->logMsg('');
|
||||
|
||||
$results = [
|
||||
'repos_processed' => 0,
|
||||
@@ -139,7 +130,7 @@ class RepoCleanup extends CLIApp
|
||||
$name = $repo['name'];
|
||||
$num = $i + 1;
|
||||
$total = count($repos);
|
||||
$this->log("[{$num}/{$total}] {$name}");
|
||||
$this->logMsg("[{$num}/{$total}] {$name}");
|
||||
$results['repos_processed']++;
|
||||
|
||||
try {
|
||||
@@ -150,37 +141,37 @@ class RepoCleanup extends CLIApp
|
||||
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
||||
|
||||
// Optional: close resolved issues
|
||||
if ($runAll || $this->getOption('close-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--close-issues', false)) {
|
||||
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: lock old closed issues
|
||||
if ($runAll || $this->getOption('lock-old-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
|
||||
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: delete retired workflow files
|
||||
if ($runAll || $this->getOption('delete-retired', false)) {
|
||||
if ($runAll || $this->getArgument('--delete-retired', false)) {
|
||||
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean workflow runs
|
||||
if ($runAll || $this->getOption('clean-workflows', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-workflows', false)) {
|
||||
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean old logs
|
||||
if ($runAll || $this->getOption('clean-logs', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-logs', false)) {
|
||||
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: check labels
|
||||
if ($runAll || $this->getOption('check-labels', false)) {
|
||||
if ($runAll || $this->getArgument('--check-labels', false)) {
|
||||
$this->checkLabels($org, $name, $results);
|
||||
}
|
||||
|
||||
// Optional: check version drift
|
||||
if ($runAll || $this->getOption('check-drift', false)) {
|
||||
if ($runAll || $this->getArgument('--check-drift', false)) {
|
||||
$this->checkVersionDrift($org, $name, $results);
|
||||
}
|
||||
|
||||
@@ -188,32 +179,32 @@ class RepoCleanup extends CLIApp
|
||||
$results['repos_cleaned']++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ {$name}: " . $e->getMessage());
|
||||
$this->errorMsg(" ✗ {$name}: " . $e->getMessage());
|
||||
$results['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $this->startTime, 1);
|
||||
|
||||
$this->log('');
|
||||
$this->log('============================================================');
|
||||
$this->log("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->log('============================================================');
|
||||
$this->log("Repos processed: {$results['repos_processed']}");
|
||||
$this->log("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->log("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->log("PRs closed: {$results['prs_closed']}");
|
||||
$this->log("Issues closed: {$results['issues_closed']}");
|
||||
$this->log("Issues locked: {$results['issues_locked']}");
|
||||
$this->log("Retired files: {$results['retired_files']}");
|
||||
$this->log("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->log("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->log("Labels missing: {$results['labels_missing']}");
|
||||
$this->log("Version drift: {$results['version_drift']}");
|
||||
$this->log("Errors: {$results['errors']}");
|
||||
$this->log('============================================================');
|
||||
$this->logMsg('');
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("Repos processed: {$results['repos_processed']}");
|
||||
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->logMsg("PRs closed: {$results['prs_closed']}");
|
||||
$this->logMsg("Issues closed: {$results['issues_closed']}");
|
||||
$this->logMsg("Issues locked: {$results['issues_locked']}");
|
||||
$this->logMsg("Retired files: {$results['retired_files']}");
|
||||
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->logMsg("Labels missing: {$results['labels_missing']}");
|
||||
$this->logMsg("Version drift: {$results['version_drift']}");
|
||||
$this->logMsg("Errors: {$results['errors']}");
|
||||
$this->logMsg('============================================================');
|
||||
|
||||
if ($this->getOption('json', false)) {
|
||||
if ($this->getArgument('--json', false)) {
|
||||
$results['duration_seconds'] = $duration;
|
||||
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
||||
}
|
||||
@@ -225,8 +216,8 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
private function fetchRepositories(string $org): array
|
||||
{
|
||||
$specificRepos = trim((string) $this->getOption('repos', ''));
|
||||
$skipArchived = (bool) $this->getOption('skip-archived', false);
|
||||
$specificRepos = trim((string) $this->getArgument('--repos', ''));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
|
||||
|
||||
if (!empty($specificRepos)) {
|
||||
$names = preg_split('/[\s,]+/', $specificRepos);
|
||||
@@ -263,18 +254,22 @@ class RepoCleanup extends CLIApp
|
||||
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
||||
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
|
||||
}
|
||||
$this->log(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$results['prs_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->log(" 🗑️ Deleted branch: {$name}");
|
||||
$this->logMsg(" 🗑️ Deleted branch: {$name}");
|
||||
$results['branches_deleted']++;
|
||||
$changed = true;
|
||||
}
|
||||
@@ -290,7 +285,9 @@ class RepoCleanup extends CLIApp
|
||||
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||
'labels' => $label, 'state' => 'open', 'per_page' => 10,
|
||||
]);
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$num = $issue['number'] ?? 0;
|
||||
@@ -305,11 +302,13 @@ class RepoCleanup extends CLIApp
|
||||
'state' => 'closed', 'state_reason' => 'completed',
|
||||
]);
|
||||
}
|
||||
$this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$results['issues_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,28 +324,34 @@ class RepoCleanup extends CLIApp
|
||||
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
|
||||
]);
|
||||
} catch (\Exception $e) { return false; }
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$closedAt = $issue['closed_at'] ?? '';
|
||||
$locked = $issue['locked'] ?? false;
|
||||
$num = $issue['number'] ?? 0;
|
||||
|
||||
if ($locked || $closedAt > $cutoff || $num === 0) continue;
|
||||
if ($locked || $closedAt > $cutoff || $num === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
|
||||
'lock_reason' => 'resolved',
|
||||
]);
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$results['issues_locked']++;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($results['issues_locked'] > 0) {
|
||||
$this->log(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -358,7 +363,9 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
||||
} catch (\Exception $e) { /* fallback to main */ }
|
||||
} catch (\Exception $e) {
|
||||
/* fallback to main */
|
||||
}
|
||||
|
||||
// Check both workflow directories for retired workflows (supports dual-platform repos)
|
||||
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
|
||||
@@ -368,7 +375,9 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
$sha = $file['sha'] ?? '';
|
||||
if (empty($sha)) continue;
|
||||
if (empty($sha)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
|
||||
@@ -377,7 +386,7 @@ class RepoCleanup extends CLIApp
|
||||
'branch' => $defaultBranch,
|
||||
]);
|
||||
}
|
||||
$this->log(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$results['retired_files']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -404,13 +413,17 @@ class RepoCleanup extends CLIApp
|
||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
|
||||
$results['runs_deleted']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
if ($results['runs_deleted'] > 0) {
|
||||
$this->log(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -418,7 +431,7 @@ class RepoCleanup extends CLIApp
|
||||
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
||||
{
|
||||
$changed = false;
|
||||
$days = (int) $this->getOption('log-days', '30');
|
||||
$days = (int) $this->getArgument('--log-days', '30');
|
||||
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
|
||||
|
||||
try {
|
||||
@@ -432,13 +445,17 @@ class RepoCleanup extends CLIApp
|
||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
|
||||
$results['logs_deleted']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
if ($results['logs_deleted'] > 0) {
|
||||
$this->log(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -448,7 +465,7 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Missing 'mokostandards' label");
|
||||
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
||||
$results['labels_missing']++;
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -468,7 +485,7 @@ class RepoCleanup extends CLIApp
|
||||
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
||||
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
||||
if ($vm[1] !== self::VERSION) {
|
||||
$this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$results['version_drift']++;
|
||||
}
|
||||
}
|
||||
@@ -483,14 +500,14 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private function log(string $message): void
|
||||
private function logMsg(string $message): void
|
||||
{
|
||||
if (!$this->getOption('quiet', false)) {
|
||||
if (!$this->quiet) {
|
||||
echo $message . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function error(string $message): void
|
||||
private function errorMsg(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . "\n");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -88,45 +89,83 @@ require_once $autoloader;
|
||||
*/
|
||||
const COMMAND_MAP = [
|
||||
// Automation
|
||||
'sync' => 'api/automation/bulk_sync.php',
|
||||
'sync' => 'automation/bulk_sync.php',
|
||||
|
||||
// Maintenance
|
||||
'inventory' => 'api/maintenance/update_repo_inventory.php',
|
||||
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||
|
||||
// Validation — general
|
||||
'health' => 'api/validate/check_repo_health.php',
|
||||
'check:syntax' => 'api/validate/check_php_syntax.php',
|
||||
'check:version' => 'api/validate/check_version_consistency.php',
|
||||
'check:changelog' => 'api/validate/check_changelog.php',
|
||||
'check:structure' => 'api/validate/check_structure.php',
|
||||
'check:headers' => 'api/validate/check_license_headers.php',
|
||||
'check:secrets' => 'api/validate/check_no_secrets.php',
|
||||
'check:tabs' => 'api/validate/check_tabs.php',
|
||||
'check:paths' => 'api/validate/check_paths.php',
|
||||
'check:xml' => 'api/validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'api/validate/check_enterprise_readiness.php',
|
||||
'health' => 'validate/check_repo_health.php',
|
||||
'check:syntax' => 'validate/check_php_syntax.php',
|
||||
'check:version' => 'validate/check_version_consistency.php',
|
||||
'check:changelog' => 'validate/check_changelog.php',
|
||||
'check:structure' => 'validate/check_structure.php',
|
||||
'check:headers' => 'validate/check_license_headers.php',
|
||||
'check:secrets' => 'validate/check_no_secrets.php',
|
||||
'check:tabs' => 'validate/check_tabs.php',
|
||||
'check:paths' => 'validate/check_paths.php',
|
||||
'check:xml' => 'validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||
|
||||
// Validation — platform-specific
|
||||
'check:dolibarr' => 'api/validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'api/validate/check_joomla_manifest.php',
|
||||
'check:language' => 'api/validate/check_language_structure.php',
|
||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||
'check:language' => 'validate/check_language_structure.php',
|
||||
'check:client' => 'validate/check_client_theme.php',
|
||||
'check:wiki' => 'validate/check_wiki_health.php',
|
||||
|
||||
// Detection
|
||||
'detect' => 'api/validate/auto_detect_platform.php',
|
||||
'detect' => 'validate/auto_detect_platform.php',
|
||||
|
||||
// Org-wide
|
||||
'drift' => 'api/validate/scan_drift.php',
|
||||
'drift' => 'validate/scan_drift.php',
|
||||
|
||||
// Release
|
||||
'release' => 'api/cli/release.php',
|
||||
'release' => 'cli/release.php',
|
||||
'release:notes' => 'cli/release_notes.php',
|
||||
'release:validate' => 'cli/release_validate.php',
|
||||
'manifest:element' => 'cli/manifest_element.php',
|
||||
'release:cascade' => 'cli/release_cascade.php',
|
||||
'release:promote' => 'cli/release_promote.php',
|
||||
'release:create' => 'cli/release_create.php',
|
||||
'release:manage' => 'cli/release_manage.php',
|
||||
'release:mirror' => 'cli/release_mirror.php',
|
||||
'release:package' => 'cli/release_package.php',
|
||||
|
||||
// CLI utilities (used by workflows — centralized logic)
|
||||
'version:read' => 'api/cli/version_read.php',
|
||||
'version:bump' => 'api/cli/version_bump.php',
|
||||
'version:propagate' => 'api/maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'api/cli/version_set_platform.php',
|
||||
'platform:detect' => 'api/cli/platform_detect.php',
|
||||
'release:notes' => 'api/cli/release_notes.php',
|
||||
// Version management
|
||||
'version:read' => 'cli/version_read.php',
|
||||
'version:bump' => 'cli/version_bump.php',
|
||||
'version:check' => 'cli/version_check.php',
|
||||
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'cli/version_set_platform.php',
|
||||
'version:reset-dev' => 'cli/version_reset_dev.php',
|
||||
|
||||
// Build & package
|
||||
'build:package' => 'cli/package_build.php',
|
||||
'build:joomla' => 'cli/joomla_build.php',
|
||||
'build:updates-xml' => 'cli/updates_xml_build.php',
|
||||
|
||||
// Platform detection
|
||||
'platform:detect' => 'cli/platform_detect.php',
|
||||
'manifest:read' => 'cli/manifest_read.php',
|
||||
|
||||
// Repository management
|
||||
'repo:create' => 'cli/create_repo.php',
|
||||
'repo:archive' => 'cli/archive_repo.php',
|
||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||
'repo:provision' => 'cli/client_provision.php',
|
||||
|
||||
// Bulk operations
|
||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
|
||||
|
||||
// Monitoring & dashboards
|
||||
'dashboard' => 'cli/client_dashboard.php',
|
||||
'grafana' => 'cli/grafana_dashboard.php',
|
||||
'client:inventory' => 'cli/client_inventory.php',
|
||||
|
||||
// Module validation
|
||||
'validate:module' => 'bin/validate-module',
|
||||
];
|
||||
|
||||
@@ -210,24 +249,112 @@ function printCommandList(): void
|
||||
{
|
||||
echo "Available commands:\n\n";
|
||||
|
||||
$groups = [
|
||||
'Automation' => ['sync'],
|
||||
'Maintenance' => ['inventory'],
|
||||
'Validation (general)' => ['health', 'check:syntax', 'check:version', 'check:changelog',
|
||||
'check:structure', 'check:headers', 'check:secrets',
|
||||
'check:tabs', 'check:paths', 'check:xml', 'check:enterprise'],
|
||||
'Validation (platform)' => ['check:dolibarr', 'check:joomla', 'check:language', 'detect'],
|
||||
'Organisation-wide' => ['drift'],
|
||||
];
|
||||
// Auto-group by command prefix or comment-based sections
|
||||
$groups = [];
|
||||
foreach (COMMAND_MAP as $cmd => $path) {
|
||||
if (str_contains($cmd, ':')) {
|
||||
$prefix = explode(':', $cmd)[0];
|
||||
$groupName = match ($prefix) {
|
||||
'check' => 'Validation',
|
||||
'version' => 'Version',
|
||||
'release' => 'Release',
|
||||
'build' => 'Build',
|
||||
'platform', 'manifest' => 'Platform',
|
||||
'repo' => 'Repository',
|
||||
'bulk' => 'Bulk Operations',
|
||||
'client' => 'Client Management',
|
||||
'validate' => 'Module Validation',
|
||||
default => ucfirst($prefix),
|
||||
};
|
||||
} else {
|
||||
$groupName = match ($cmd) {
|
||||
'sync' => 'Automation',
|
||||
'inventory' => 'Maintenance',
|
||||
'health' => 'Validation',
|
||||
'detect', 'drift' => 'Validation',
|
||||
'dashboard', 'grafana' => 'Monitoring',
|
||||
default => 'Other',
|
||||
};
|
||||
}
|
||||
$groups[$groupName][$cmd] = $path;
|
||||
}
|
||||
|
||||
// Load plugin commands
|
||||
$pluginCommands = loadPluginCommands();
|
||||
if (!empty($pluginCommands)) {
|
||||
foreach ($pluginCommands as $cmd => $info) {
|
||||
$type = $info['plugin'] ?? 'Plugin';
|
||||
$groups["Plugin: {$type}"][$cmd] = $info['description'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
foreach ($groups as $group => $commands) {
|
||||
echo " {$group}:\n";
|
||||
foreach ($commands as $cmd) {
|
||||
printf(" %-22s %s\n", $cmd, COMMAND_MAP[$cmd]);
|
||||
echo " \033[1m{$group}\033[0m\n";
|
||||
ksort($commands);
|
||||
foreach ($commands as $cmd => $path) {
|
||||
printf(" \033[36m%-26s\033[0m %s\n", $cmd, basename($path));
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "Run: php bin/moko <command> --help for command-specific options.\n";
|
||||
echo "All platforms: php bin/moko <command>\n";
|
||||
$total = count(COMMAND_MAP) + count($pluginCommands);
|
||||
echo "{$total} command(s) available.\n";
|
||||
echo "Run: php bin/moko <command> --help\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load commands from registered plugins.
|
||||
*
|
||||
* @return array<string, array{plugin: string, description: string, script: string}>
|
||||
*/
|
||||
function loadPluginCommands(): array
|
||||
{
|
||||
$pluginDir = dirname(__DIR__) . '/lib/Enterprise/Plugins';
|
||||
if (!is_dir($pluginDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$commands = [];
|
||||
|
||||
foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
|
||||
$className = 'MokoEnterprise\\Plugins\\'
|
||||
. pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
if (!class_exists($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ref = new \ReflectionClass($className);
|
||||
if ($ref->isAbstract()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin = $ref->newInstanceWithoutConstructor();
|
||||
$pluginCmds = $plugin->getCommands();
|
||||
|
||||
foreach ($pluginCmds as $cmd) {
|
||||
$name = $cmd['name'] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = method_exists($plugin, 'getProjectType')
|
||||
? $plugin->getProjectType() : 'unknown';
|
||||
|
||||
$commands[$name] = [
|
||||
'plugin' => $type,
|
||||
'description' => $cmd['description'] ?? '',
|
||||
'script' => $cmd['script'] ?? '',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Skip plugins that can't be instantiated
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* 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/bulk_workflow_push.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BulkWorkflowPush
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = '';
|
||||
private string $workflowFile = '';
|
||||
private string $destPath = '';
|
||||
private string $branch = 'main';
|
||||
private bool $dryRun = false;
|
||||
|
||||
private int $updated = 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 ($this->workflowFile === '') {
|
||||
$this->log('ERROR: --file is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->workflowFile)) {
|
||||
$this->log("ERROR: File not found: {$this->workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->org === '') {
|
||||
$this->log('ERROR: --org is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->destPath === '') {
|
||||
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
|
||||
}
|
||||
|
||||
$localContent = file_get_contents($this->workflowFile);
|
||||
|
||||
if ($localContent === false) {
|
||||
$this->log("ERROR: Could not read file: {$this->workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Pushing: {$this->workflowFile}");
|
||||
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
|
||||
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('[DRY RUN] No changes will be made.');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
|
||||
$repos = $this->fetchOrgRepos();
|
||||
|
||||
if ($repos === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
|
||||
$this->log('');
|
||||
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
|
||||
$this->log(str_repeat('-', 70));
|
||||
|
||||
$encodedContent = base64_encode($localContent);
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$this->pushToRepo($repo, $encodedContent, $localContent);
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log("Done: {$this->created} created, {$this->updated} updated, "
|
||||
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
||||
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function pushToRepo(
|
||||
string $repoFullName,
|
||||
string $encodedContent,
|
||||
string $localContent
|
||||
): void {
|
||||
[$owner, $repoName] = explode('/', $repoFullName, 2);
|
||||
|
||||
$existing = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. "{$this->destPath}?ref={$this->branch}"
|
||||
);
|
||||
|
||||
if ($existing['code'] === 200) {
|
||||
$data = json_decode($existing['body'], true);
|
||||
$remoteSha = $data['sha'] ?? '';
|
||||
$remoteContent = base64_decode($data['content'] ?? '');
|
||||
|
||||
if ($remoteContent === $localContent) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'IDENTICAL (skipped)'
|
||||
));
|
||||
$this->skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD UPDATE'
|
||||
));
|
||||
$this->updated++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'sha' => $remoteSha,
|
||||
'message' => "chore: sync {$this->destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'PUT',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'UPDATED'
|
||||
));
|
||||
$this->updated++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
} elseif ($existing['code'] === 404) {
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD CREATE'
|
||||
));
|
||||
$this->created++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'message' => "chore: add {$this->destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 201) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'CREATED'
|
||||
));
|
||||
$this->created++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$existing['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchOrgRepos(): ?array
|
||||
{
|
||||
$this->log("Fetching repos from org: {$this->org}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
while (true) {
|
||||
$response = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/v1/orgs/{$this->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;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->workflowFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dest':
|
||||
$this->destPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--branch':
|
||||
$this->branch = $args[++$i] ?? 'main';
|
||||
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: bulk_workflow_push.php '
|
||||
. '--token <token> --file <path> --org <org> [options]'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log(
|
||||
'Push a workflow file from moko-platform '
|
||||
. 'to all governed repos.'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL '
|
||||
. '(default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --org <org> Target organization');
|
||||
$this->log(' --file <path> Local workflow file to push');
|
||||
$this->log(' --dest <path> Destination path in repos '
|
||||
. '(default: .mokogitea/workflows/<filename>)');
|
||||
$this->log(' --branch <branch> Target branch (default: main)');
|
||||
$this->log(' --dry-run Show what would be done');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $this->giteaUrl . $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 {$this->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];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new BulkWorkflowPush();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* 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/client_dashboard.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Generate unified client dashboard HTML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientDashboard
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = 'MokoConsulting';
|
||||
private string $outputFile = '';
|
||||
private bool $checkSsl = true;
|
||||
private bool $checkUptime = true;
|
||||
private int $sslWarnDays = 30;
|
||||
private int $httpTimeout = 10;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('GA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token or GA_TOKEN required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Gathering client data...');
|
||||
$clients = $this->discoverClients();
|
||||
|
||||
if ($clients === null) {
|
||||
$this->log('ERROR: Could not fetch client repos.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($clients) . ' client(s).');
|
||||
|
||||
foreach ($clients as &$client) {
|
||||
$this->enrichClient($client);
|
||||
}
|
||||
|
||||
unset($client);
|
||||
|
||||
$html = $this->renderDashboard($clients);
|
||||
|
||||
if ($this->outputFile !== '') {
|
||||
file_put_contents($this->outputFile, $html);
|
||||
$this->log("Dashboard: {$this->outputFile}");
|
||||
} else {
|
||||
fwrite(STDOUT, $html);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>>|null */
|
||||
private function discoverClients(): ?array
|
||||
{
|
||||
$clients = [];
|
||||
$orgs = $this->fetchAllOrgs();
|
||||
|
||||
if (!in_array($this->org, $orgs, true)) {
|
||||
array_unshift($orgs, $this->org);
|
||||
}
|
||||
|
||||
foreach ($orgs as $orgName) {
|
||||
$page = 1;
|
||||
|
||||
while (true) {
|
||||
$resp = $this->api(
|
||||
'GET',
|
||||
"/api/v1/orgs/{$orgName}/repos"
|
||||
. "?limit=50&page={$page}"
|
||||
);
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
break;
|
||||
}
|
||||
|
||||
$repos = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($repos) || empty($repos)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'] ?? '';
|
||||
|
||||
if (
|
||||
!str_starts_with($name, 'client-waas-')
|
||||
|| !empty($repo['archived'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clients[] = [
|
||||
'repo' => $repo['full_name'] ?? '',
|
||||
'name' => str_replace('client-waas-', '', $name),
|
||||
'description' => $repo['description'] ?? '',
|
||||
'updated' => $repo['updated_at'] ?? '',
|
||||
'url' => $repo['html_url'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
usort($clients, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function fetchAllOrgs(): array
|
||||
{
|
||||
$resp = $this->api('GET', '/api/v1/user/orgs?limit=50');
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
return [$this->org];
|
||||
}
|
||||
|
||||
$orgs = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($orgs)) {
|
||||
return [$this->org];
|
||||
}
|
||||
|
||||
return array_map(fn($o) => $o['username'] ?? '', $orgs);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $client */
|
||||
private function enrichClient(array &$client): void
|
||||
{
|
||||
$repo = $client['repo'];
|
||||
$this->log(" Checking {$client['name']}...");
|
||||
|
||||
// Fetch variables
|
||||
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
|
||||
$vars = [];
|
||||
|
||||
if ($resp['code'] === 200) {
|
||||
$varList = json_decode($resp['body'], true);
|
||||
|
||||
if (is_array($varList)) {
|
||||
foreach ($varList as $v) {
|
||||
$vars[$v['name'] ?? ''] = $v['data'] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$client['vars'] = $vars;
|
||||
$client['dev_url'] = $vars['DEV_SITE_URL'] ?? '';
|
||||
$client['live_url'] = $vars['LIVE_SITE_URL'] ?? '';
|
||||
$client['has_dev'] = isset($vars['DEV_SYNC_HOST']);
|
||||
$client['has_live'] = isset($vars['LIVE_SSH_HOST']);
|
||||
$client['dev_status'] = 'unknown';
|
||||
$client['live_status'] = 'unknown';
|
||||
|
||||
if ($this->checkUptime) {
|
||||
if ($client['dev_url'] !== '') {
|
||||
$client['dev_status'] = $this->checkHttp($client['dev_url']);
|
||||
}
|
||||
|
||||
if ($client['live_url'] !== '') {
|
||||
$client['live_status'] = $this->checkHttp($client['live_url']);
|
||||
}
|
||||
}
|
||||
|
||||
// SSL
|
||||
$client['ssl_expiry'] = null;
|
||||
$client['ssl_days'] = null;
|
||||
$client['ssl_status'] = 'unknown';
|
||||
$domain = $vars['MONITORED_DOMAINS'] ?? '';
|
||||
|
||||
if ($domain === '' && $client['live_url'] !== '') {
|
||||
$parsed = parse_url($client['live_url']);
|
||||
$domain = $parsed['host'] ?? '';
|
||||
}
|
||||
|
||||
if ($this->checkSsl && $domain !== '') {
|
||||
$domain = trim(explode("\n", $domain)[0]);
|
||||
$ssl = $this->checkSslCert($domain);
|
||||
$client['ssl_domain'] = $domain;
|
||||
$client['ssl_expiry'] = $ssl['expiry'];
|
||||
$client['ssl_days'] = $ssl['days'];
|
||||
|
||||
if ($ssl['days'] === null) {
|
||||
$client['ssl_status'] = 'error';
|
||||
} elseif ($ssl['days'] < $this->sslWarnDays) {
|
||||
$client['ssl_status'] = 'warning';
|
||||
} else {
|
||||
$client['ssl_status'] = 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
// Last release
|
||||
$client['last_release'] = '';
|
||||
$client['last_release_date'] = '';
|
||||
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
|
||||
|
||||
if ($relResp['code'] === 200) {
|
||||
$rels = json_decode($relResp['body'], true);
|
||||
|
||||
if (is_array($rels) && !empty($rels)) {
|
||||
$client['last_release'] = $rels[0]['name'] ?? '';
|
||||
$client['last_release_date'] = substr($rels[0]['created_at'] ?? '', 0, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHttp(string $url): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_NOBODY, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->httpTimeout);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 0) {
|
||||
return 'down';
|
||||
}
|
||||
|
||||
return ($code >= 200 && $code < 400) ? 'up' : "http-{$code}";
|
||||
}
|
||||
|
||||
/** @return array{expiry: ?string, days: ?int} */
|
||||
private function checkSslCert(string $domain): array
|
||||
{
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'capture_peer_cert' => true,
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$client = @stream_socket_client(
|
||||
"ssl://{$domain}:443",
|
||||
$errno,
|
||||
$errstr,
|
||||
$this->httpTimeout,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$ctx
|
||||
);
|
||||
|
||||
if (!$client) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($client);
|
||||
fclose($client);
|
||||
|
||||
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
|
||||
|
||||
if ($cert === null) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$info = openssl_x509_parse($cert);
|
||||
$validTo = $info['validTo_time_t'] ?? 0;
|
||||
|
||||
if ($validTo === 0) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$expiry = date('Y-m-d', $validTo);
|
||||
$days = (int) round(($validTo - time()) / 86400);
|
||||
|
||||
return ['expiry' => $expiry, 'days' => $days];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $clients */
|
||||
private function renderDashboard(array $clients): string
|
||||
{
|
||||
$generated = date('Y-m-d H:i:s T');
|
||||
$total = count($clients);
|
||||
$up = 0;
|
||||
$sslWarn = 0;
|
||||
|
||||
foreach ($clients as $c) {
|
||||
if ($c['live_status'] === 'up' || $c['dev_status'] === 'up') {
|
||||
$up++;
|
||||
}
|
||||
|
||||
if ($c['ssl_status'] === 'warning') {
|
||||
$sslWarn++;
|
||||
}
|
||||
}
|
||||
|
||||
$cards = '';
|
||||
|
||||
foreach ($clients as $c) {
|
||||
$cards .= $this->renderCard($c);
|
||||
}
|
||||
|
||||
$warnCls = $sslWarn > 0 ? 'stat-warn' : 'stat-ok';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Moko Client Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:4px}
|
||||
.sub{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
|
||||
.stats{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}
|
||||
.st{background:#1e293b;border-radius:8px;padding:16px 20px;min-width:140px}
|
||||
.sv{font-size:1.5rem;font-weight:700}
|
||||
.sl{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
|
||||
.stat-ok .sv{color:#4ade80}
|
||||
.stat-warn .sv{color:#fbbf24}
|
||||
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.c{background:#1e293b;border-radius:8px;padding:20px;border:1px solid #334155;transition:border-color .2s}
|
||||
.c:hover{border-color:#475569}
|
||||
.ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.cn{font-size:1.1rem;font-weight:600;text-transform:capitalize}
|
||||
.cn a{color:#e2e8f0;text-decoration:none}
|
||||
.cn a:hover{color:#60a5fa}
|
||||
.b{font-size:.7rem;padding:2px 8px;border-radius:999px;font-weight:600;text-transform:uppercase}
|
||||
.b-up{background:#064e3b;color:#4ade80}
|
||||
.b-dn{background:#7f1d1d;color:#fca5a5}
|
||||
.b-un{background:#374151;color:#9ca3af}
|
||||
.rs{display:flex;flex-direction:column;gap:8px}
|
||||
.r{display:flex;justify-content:space-between;font-size:.85rem}
|
||||
.rl{color:#94a3b8}
|
||||
.rv{color:#e2e8f0;text-align:right;max-width:60%}
|
||||
.rv a{color:#60a5fa;text-decoration:none}
|
||||
.rv a:hover{text-decoration:underline}
|
||||
.ok{color:#4ade80}.wn{color:#fbbf24}.er{color:#f87171}
|
||||
.st2{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#64748b;
|
||||
margin-top:8px;margin-bottom:4px;padding-top:8px;border-top:1px solid #334155}
|
||||
footer{margin-top:32px;text-align:center;color:#64748b;font-size:.75rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Moko Client Dashboard</h1>
|
||||
<p class="sub">Generated {$generated}</p>
|
||||
<div class="stats">
|
||||
<div class="st"><div class="sv">{$total}</div><div class="sl">Clients</div></div>
|
||||
<div class="st stat-ok"><div class="sv">{$up}</div><div class="sl">Sites Up</div></div>
|
||||
<div class="st {$warnCls}"><div class="sv">{$sslWarn}</div><div class="sl">SSL Warnings</div></div>
|
||||
</div>
|
||||
<div class="g">{$cards}</div>
|
||||
<footer>Moko Consulting — client_dashboard.php</footer>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $c */
|
||||
private function renderCard(array $c): string
|
||||
{
|
||||
$name = htmlspecialchars($c['name']);
|
||||
$repoUrl = htmlspecialchars($c['url']);
|
||||
|
||||
$ls = $c['live_status'];
|
||||
|
||||
if ($ls === 'up') {
|
||||
$badge = '<span class="b b-up">UP</span>';
|
||||
} elseif ($ls === 'down') {
|
||||
$badge = '<span class="b b-dn">DOWN</span>';
|
||||
} else {
|
||||
$badge = '<span class="b b-un">' . htmlspecialchars($ls) . '</span>';
|
||||
}
|
||||
|
||||
$rows = '';
|
||||
|
||||
if ($c['live_url'] !== '') {
|
||||
$u = htmlspecialchars($c['live_url']);
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Live</span>"
|
||||
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a></span></div>";
|
||||
}
|
||||
|
||||
if ($c['dev_url'] !== '') {
|
||||
$u = htmlspecialchars($c['dev_url']);
|
||||
$ds = $c['dev_status'] === 'up' ? ' (up)' : '';
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Dev</span>"
|
||||
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a>{$ds}</span></div>";
|
||||
}
|
||||
|
||||
if ($c['ssl_days'] !== null) {
|
||||
$cls = match ($c['ssl_status']) {
|
||||
'ok' => 'ok', 'warning' => 'wn', default => 'er'
|
||||
};
|
||||
$stxt = htmlspecialchars("{$c['ssl_expiry']} ({$c['ssl_days']}d)");
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">SSL</span>"
|
||||
. "<span class=\"rv {$cls}\">{$stxt}</span></div>";
|
||||
}
|
||||
|
||||
if ($c['last_release'] !== '') {
|
||||
$rel = htmlspecialchars($c['last_release']);
|
||||
$rd = htmlspecialchars($c['last_release_date']);
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Release</span>"
|
||||
. "<span class=\"rv\">{$rel} ({$rd})</span></div>";
|
||||
}
|
||||
|
||||
$dc = $c['has_dev'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
|
||||
$lc = $c['has_live'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
|
||||
$upd = substr($c['updated'], 0, 10);
|
||||
|
||||
return <<<CARD
|
||||
<div class="c">
|
||||
<div class="ch"><span class="cn"><a href="{$repoUrl}" target="_blank">{$name}</a></span>{$badge}</div>
|
||||
<div class="rs">{$rows}
|
||||
<div class="st2">Infrastructure</div>
|
||||
<div class="r"><span class="rl">Dev Server</span><span class="rv">{$dc}</span></div>
|
||||
<div class="r"><span class="rl">Live Server</span><span class="rv">{$lc}</span></div>
|
||||
<div class="r"><span class="rl">Last Push</span><span class="rv">{$upd}</span></div>
|
||||
</div></div>
|
||||
CARD;
|
||||
}
|
||||
|
||||
/** @return array{code: int, body: string} */
|
||||
private function api(string $method, string $endpoint): array
|
||||
{
|
||||
$url = $this->giteaUrl . $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, [
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
curl_close($ch);
|
||||
return ['code' => 0, 'body' => ''];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
return ['code' => $code, 'body' => $body];
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--output':
|
||||
case '-o':
|
||||
$this->outputFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-ssl':
|
||||
$this->checkSsl = false;
|
||||
break;
|
||||
case '--no-uptime':
|
||||
$this->checkUptime = false;
|
||||
break;
|
||||
case '--ssl-warn-days':
|
||||
$this->sslWarnDays = (int) ($args[++$i] ?? 30);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_dashboard.php --token TOKEN [options]');
|
||||
$this->log('');
|
||||
$this->log('Generate unified client status dashboard (HTML).');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --token <token> Gitea token (or GA_TOKEN)');
|
||||
$this->log(' --gitea-url <url> Gitea URL');
|
||||
$this->log(' --org <org> Primary org (default: MokoConsulting)');
|
||||
$this->log(' -o, --output <file> Output HTML file (default: stdout)');
|
||||
$this->log(' --no-ssl Skip SSL checks');
|
||||
$this->log(' --no-uptime Skip HTTP uptime checks');
|
||||
$this->log(' --ssl-warn-days <n> SSL warning days (default: 30)');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientDashboard();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/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/client_health_check.php
|
||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
||||
*
|
||||
* Usage:
|
||||
* php client_health_check.php --update-url URL
|
||||
* php client_health_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads update server URL from manifest)
|
||||
* --update-url Update server XML URL (overrides manifest)
|
||||
* --site-url Live site URL for version checking via Joomla API (optional)
|
||||
* --api-token Joomla API token for site-url (optional)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$updateUrl = null;
|
||||
$siteUrl = null;
|
||||
$apiToken = null;
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
|
||||
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
|
||||
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$checks = [];
|
||||
|
||||
// ── Resolve update server URL from manifest ─────────────────────────────
|
||||
if ($updateUrl === null) {
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
||||
$updateUrl = trim($m[1]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updateUrl === null) {
|
||||
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Update server: {$updateUrl}\n\n";
|
||||
|
||||
// ── Check 1: Update server accessible ───────────────────────────────────
|
||||
echo "--- Update Server ---\n";
|
||||
$ch = curl_init($updateUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && !empty($response)) {
|
||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||
$checks['update_server'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$httpCode}\n";
|
||||
$checks['update_server'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 2: Parse updates.xml for stable version ───────────────────────
|
||||
$stableVersion = null;
|
||||
$downloadUrl = null;
|
||||
|
||||
if (!empty($response)) {
|
||||
$sections = preg_split('/<update>/', $response);
|
||||
foreach ($sections as $section) {
|
||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||
$downloadUrl = trim($m[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Stable Release ---\n";
|
||||
if ($stableVersion !== null) {
|
||||
echo " Version: {$stableVersion}\n";
|
||||
$checks['stable_version'] = $stableVersion;
|
||||
} else {
|
||||
echo " FAIL: Could not parse stable version\n";
|
||||
$checks['stable_version'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 3: Download URL accessible ────────────────────────────────────
|
||||
if ($downloadUrl !== null) {
|
||||
echo "\n--- Download URL ---\n";
|
||||
$ch = curl_init($downloadUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
|
||||
if ($dlCode === 200) {
|
||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||
$checks['download'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$dlCode}\n";
|
||||
$checks['download'] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Site version (optional) ────────────────────────────────────
|
||||
if ($siteUrl !== null && $apiToken !== null) {
|
||||
echo "\n--- Site Version ---\n";
|
||||
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"X-Joomla-Token: {$apiToken}",
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$siteResponse = curl_exec($ch);
|
||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($siteCode === 200) {
|
||||
echo " API accessible (HTTP {$siteCode})\n";
|
||||
$checks['site_api'] = 'pass';
|
||||
} else {
|
||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
||||
$checks['site_api'] = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Health Check Summary ===\n";
|
||||
$failed = 0;
|
||||
foreach ($checks as $name => $result) {
|
||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||
if ($result === 'fail') $failed++;
|
||||
echo " {$icon}: {$name} = {$result}\n";
|
||||
}
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* 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/client_provision.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Provision a new client environment end-to-end
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientProvision
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $giteaToken = '';
|
||||
private string $grafanaUrl = '';
|
||||
private string $grafanaToken = '';
|
||||
private string $configFile = '';
|
||||
private string $step = '';
|
||||
private bool $dryRun = false;
|
||||
/** @var array<string, mixed> */
|
||||
private array $config = [];
|
||||
private string $org = '';
|
||||
private string $repoName = '';
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->configFile === '') {
|
||||
$this->log('ERROR: --config is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->configFile)) {
|
||||
$this->log("ERROR: Not found: {$this->configFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->configFile);
|
||||
$this->config = json_decode($json, true);
|
||||
|
||||
if (!is_array($this->config)) {
|
||||
$this->log('ERROR: Invalid JSON in config file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->giteaToken = $this->config['gitea_token']
|
||||
?? getenv('GA_TOKEN') ?: '';
|
||||
$this->grafanaUrl = $this->config['grafana_url']
|
||||
?? getenv('GRAFANA_URL') ?: '';
|
||||
$this->grafanaToken = $this->config['grafana_token']
|
||||
?? getenv('GRAFANA_TOKEN') ?: '';
|
||||
$this->giteaUrl = $this->config['gitea_url']
|
||||
?? $this->giteaUrl;
|
||||
|
||||
if ($this->giteaToken === '') {
|
||||
$this->log('ERROR: gitea_token or GA_TOKEN required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->org = $this->config['org'] ?? '';
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
if ($this->org === '' || $clientName === '') {
|
||||
$this->log('ERROR: "org" and "name" required in config.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->repoName = 'client-waas-' . $clientName;
|
||||
|
||||
$this->log("=== Client Provisioning: {$clientName} ===");
|
||||
$this->log(" Org: {$this->org}");
|
||||
$this->log(" Repo: {$this->repoName}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(' Mode: DRY RUN');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
|
||||
$steps = [
|
||||
'repo' => 'createRepo',
|
||||
'variables' => 'setVariables',
|
||||
'secrets' => 'setSecrets',
|
||||
'monitoring' => 'setupMonitoring',
|
||||
'summary' => 'printSummary',
|
||||
];
|
||||
|
||||
$exitCode = 0;
|
||||
|
||||
foreach ($steps as $name => $method) {
|
||||
if ($this->step !== '' && $this->step !== $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->$method();
|
||||
|
||||
if ($result !== 0) {
|
||||
$exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
private function createRepo(): int
|
||||
{
|
||||
$this->log('[1/5] Creating repository...');
|
||||
|
||||
$check = $this->giteaApi(
|
||||
'GET',
|
||||
"/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
);
|
||||
|
||||
if ($check['code'] === 200) {
|
||||
$this->log(" SKIP: repo already exists");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(
|
||||
" WOULD CREATE: {$this->org}/{$this->repoName}"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'owner' => $this->org,
|
||||
'name' => $this->repoName,
|
||||
'description' => ($this->config['name'] ?? '') . ' WaaS site',
|
||||
'private' => true,
|
||||
'git_content' => true,
|
||||
'topics' => true,
|
||||
'labels' => true,
|
||||
]);
|
||||
|
||||
$resp = $this->giteaApi(
|
||||
'POST',
|
||||
'/api/v1/repos/MokoConsulting/'
|
||||
. 'Template-Client-WaaS/generate',
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($resp['code'] < 200 || $resp['code'] >= 300) {
|
||||
$this->log(" ERROR: HTTP {$resp['code']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log(' OK: Repo created');
|
||||
|
||||
$this->giteaApi(
|
||||
'POST',
|
||||
"/api/v1/repos/{$this->org}/{$this->repoName}/branches",
|
||||
json_encode([
|
||||
'new_branch_name' => 'dev',
|
||||
'old_branch_name' => 'main',
|
||||
])
|
||||
);
|
||||
|
||||
$this->log(' OK: dev branch created');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setVariables(): int
|
||||
{
|
||||
$this->log('[2/5] Setting repo variables...');
|
||||
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
|
||||
if (empty($vars)) {
|
||||
$this->log(' SKIP: No variables in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/variables";
|
||||
|
||||
foreach ($vars as $name => $value) {
|
||||
if ($this->dryRun) {
|
||||
$display = strlen($value) > 40
|
||||
? substr($value, 0, 37) . '...' : $value;
|
||||
$this->log(" WOULD SET: {$name} = {$display}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->setOrCreateVariable($api, $name, $value);
|
||||
|
||||
if ($ok) {
|
||||
$this->log(" OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function setSecrets(): int
|
||||
{
|
||||
$this->log('[3/5] Setting repo secrets...');
|
||||
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
|
||||
if (empty($secrets)) {
|
||||
$this->log(' SKIP: No secrets in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/secrets";
|
||||
|
||||
foreach ($secrets as $name => $value) {
|
||||
if (str_starts_with($value, '@')) {
|
||||
$keyPath = substr($value, 1);
|
||||
|
||||
if (!file_exists($keyPath)) {
|
||||
$this->log(" ERROR: {$name} file not found: {$keyPath}");
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = file_get_contents($keyPath);
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
|
||||
continue;
|
||||
}
|
||||
|
||||
$resp = $this->giteaApi(
|
||||
'PUT',
|
||||
"{$api}/{$name}",
|
||||
json_encode(['data' => $value])
|
||||
);
|
||||
|
||||
if ($resp['code'] >= 200 && $resp['code'] < 300) {
|
||||
$this->log(" OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function setupMonitoring(): int
|
||||
{
|
||||
$this->log('[4/5] Setting up monitoring...');
|
||||
|
||||
$mon = $this->config['monitoring'] ?? [];
|
||||
|
||||
if (empty($mon)) {
|
||||
$this->log(' SKIP: No monitoring config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dashFile = $mon['grafana_dashboard'] ?? '';
|
||||
|
||||
if (
|
||||
$dashFile !== '' && $this->grafanaUrl !== ''
|
||||
&& $this->grafanaToken !== ''
|
||||
) {
|
||||
$this->pushGrafanaDashboard(
|
||||
$dashFile,
|
||||
$mon['grafana_folder'] ?? 'Clients'
|
||||
);
|
||||
}
|
||||
|
||||
$urls = $mon['urls'] ?? [];
|
||||
$domains = $mon['domains'] ?? [];
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/variables";
|
||||
|
||||
if (!empty($urls)) {
|
||||
$urlStr = implode("\n", $urls);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_URLS");
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
|
||||
$this->log(' OK: MONITORED_URLS');
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($domains)) {
|
||||
$domainStr = implode("\n", $domains);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_DOMAINS");
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
|
||||
$this->log(' OK: MONITORED_DOMAINS');
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function pushGrafanaDashboard(string $file, string $folder): void
|
||||
{
|
||||
if (!file_exists($file)) {
|
||||
$this->log(" WARN: Dashboard not found: {$file}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
|
||||
return;
|
||||
}
|
||||
|
||||
$dashboard = json_decode(file_get_contents($file), true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log(' ERROR: Invalid dashboard JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
$folderId = $this->resolveGrafanaFolder($folder);
|
||||
$dashboard['id'] = null;
|
||||
|
||||
$resp = $this->grafanaApi(
|
||||
'POST',
|
||||
'/api/dashboards/db',
|
||||
json_encode([
|
||||
'dashboard' => $dashboard,
|
||||
'folderId' => $folderId,
|
||||
'overwrite' => true,
|
||||
])
|
||||
);
|
||||
|
||||
if ($resp['code'] === 200) {
|
||||
$data = json_decode($resp['body'], true);
|
||||
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
||||
} else {
|
||||
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})");
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveGrafanaFolder(string $title): int
|
||||
{
|
||||
$resp = $this->grafanaApi('GET', '/api/folders');
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$folders = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($folders)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($folders as $f) {
|
||||
if (strcasecmp($f['title'] ?? '', $title) === 0) {
|
||||
return (int) ($f['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function printSummary(): int
|
||||
{
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
$this->log('');
|
||||
$this->log('[5/5] Provisioning summary');
|
||||
$this->log(str_repeat('=', 60));
|
||||
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
|
||||
$this->log(' Variables: ' . count($vars) . ' set');
|
||||
$this->log(' Secrets: ' . count($secrets) . ' set');
|
||||
$this->log('');
|
||||
$this->log('Next steps:');
|
||||
$this->log(' 1. Clone and customize the Joomla template');
|
||||
$this->log(' 2. Push to dev to trigger dev deployment');
|
||||
$this->log(' 3. Merge dev -> main for production release');
|
||||
$this->log(str_repeat('=', 60));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setOrCreateVariable(
|
||||
string $api,
|
||||
string $name,
|
||||
string $value
|
||||
): bool {
|
||||
$resp = $this->giteaApi(
|
||||
'PUT',
|
||||
"{$api}/{$name}",
|
||||
json_encode(['value' => $value])
|
||||
);
|
||||
|
||||
if ($resp['code'] === 404) {
|
||||
$resp = $this->giteaApi(
|
||||
'POST',
|
||||
$api,
|
||||
json_encode(['name' => $name, 'value' => $value])
|
||||
);
|
||||
}
|
||||
|
||||
return $resp['code'] >= 200 && $resp['code'] < 300;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--config':
|
||||
$this->configFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--step':
|
||||
$this->step = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_provision.php --config <file.json> [options]');
|
||||
$this->log('');
|
||||
$this->log('Provision a new client environment end-to-end.');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --config <file> Client config JSON');
|
||||
$this->log(' --step <name> Run one step: repo, variables, secrets, monitoring, summary');
|
||||
$this->log(' --dry-run Preview without changes');
|
||||
$this->log(' --help, -h Show this help');
|
||||
$this->log('');
|
||||
$this->log('Environment variables:');
|
||||
$this->log(' GA_TOKEN Gitea API token');
|
||||
$this->log(' GRAFANA_URL Grafana instance URL');
|
||||
$this->log(' GRAFANA_TOKEN Grafana API token');
|
||||
}
|
||||
|
||||
private function giteaApi(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
return $this->httpRequest(
|
||||
$this->giteaUrl . $endpoint,
|
||||
$method,
|
||||
"token {$this->giteaToken}",
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
private function grafanaApi(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
return $this->httpRequest(
|
||||
$this->grafanaUrl . $endpoint,
|
||||
$method,
|
||||
"Bearer {$this->grafanaToken}",
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
private function httpRequest(
|
||||
string $url,
|
||||
string $method,
|
||||
string $auth,
|
||||
?string $body = null
|
||||
): array {
|
||||
$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: {$auth}",
|
||||
]);
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientProvision();
|
||||
exit($app->run());
|
||||
@@ -159,7 +159,7 @@ function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiCli
|
||||
/**
|
||||
* Detect platform type from .mokostandards file in the repo.
|
||||
*/
|
||||
function detectPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
{
|
||||
// Try platform metadata dir first, then root
|
||||
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
|
||||
@@ -447,7 +447,7 @@ foreach ($repos as $repo) {
|
||||
// Detect project type
|
||||
$type = $typeOverride;
|
||||
if (!$type) {
|
||||
$platform = detectPlatform($org, $repo, $token);
|
||||
$platform = detectRepoPlatform($org, $repo, $token);
|
||||
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
||||
echo " Platform: {$platform} → type: {$type}\n";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* 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/grafana_dashboard.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Manage Grafana dashboards via API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GrafanaDashboard
|
||||
{
|
||||
private string $grafanaUrl = '';
|
||||
private string $token = '';
|
||||
private string $command = '';
|
||||
private string $uid = '';
|
||||
private string $file = '';
|
||||
private int $folderId = 0;
|
||||
private string $folderTitle = '';
|
||||
private bool $overwrite = true;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->grafanaUrl === '') {
|
||||
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('GRAFANA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->grafanaUrl === '' || $this->token === '') {
|
||||
$this->log(
|
||||
'ERROR: --url and --token are required '
|
||||
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
|
||||
);
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
return match ($this->command) {
|
||||
'push' => $this->pushDashboard(),
|
||||
'delete' => $this->deleteDashboard(),
|
||||
'list' => $this->listDashboards(),
|
||||
'export' => $this->exportDashboard(),
|
||||
default => $this->noCommand(),
|
||||
};
|
||||
}
|
||||
|
||||
private function pushDashboard(): int
|
||||
{
|
||||
if ($this->file === '') {
|
||||
$this->log('ERROR: --file is required for push.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->file)) {
|
||||
$this->log("ERROR: File not found: {$this->file}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->file);
|
||||
$dashboard = json_decode($json, true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log('ERROR: Invalid JSON in dashboard file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||
$this->folderId = $this->resolveFolderId(
|
||||
$this->folderTitle
|
||||
);
|
||||
|
||||
if ($this->folderId < 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$dashboard['id'] = null;
|
||||
|
||||
$payload = json_encode([
|
||||
'dashboard' => $dashboard,
|
||||
'folderId' => $this->folderId,
|
||||
'overwrite' => $this->overwrite,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
'/api/dashboards/db',
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$data = json_decode($response['body'], true);
|
||||
$uid = $data['uid'] ?? '?';
|
||||
$url = $data['url'] ?? '';
|
||||
$status = $data['status'] ?? 'success';
|
||||
$this->log("OK: {$status} (uid: {$uid})");
|
||||
|
||||
if ($url !== '') {
|
||||
$this->log("URL: {$this->grafanaUrl}{$url}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Push failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function deleteDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for delete.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'DELETE',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log("OK: Deleted dashboard {$this->uid}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($response['code'] === 404) {
|
||||
$this->log(
|
||||
"WARN: Dashboard {$this->uid} not found."
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Delete failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function listDashboards(): int
|
||||
{
|
||||
$query = '/api/search?type=dash-db';
|
||||
|
||||
if ($this->folderId > 0) {
|
||||
$query .= "&folderIds={$this->folderId}";
|
||||
}
|
||||
|
||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||
$fid = $this->resolveFolderId($this->folderTitle);
|
||||
|
||||
if ($fid > 0) {
|
||||
$query .= "&folderIds={$fid}";
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->apiRequest('GET', $query);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: List failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dashboards = json_decode($response['body'], true);
|
||||
|
||||
if (
|
||||
!is_array($dashboards)
|
||||
|| count($dashboards) === 0
|
||||
) {
|
||||
$this->log('No dashboards found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
'Title',
|
||||
'UID',
|
||||
'Folder'
|
||||
));
|
||||
$this->log(str_repeat('-', 75));
|
||||
|
||||
foreach ($dashboards as $d) {
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
substr($d['title'] ?? '', 0, 30),
|
||||
$d['uid'] ?? '',
|
||||
$d['folderTitle'] ?? 'General'
|
||||
));
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log(count($dashboards) . ' dashboard(s).');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function exportDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for export.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Export failed "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
$dashboard = $data['dashboard'] ?? null;
|
||||
|
||||
if ($dashboard === null) {
|
||||
$this->log(
|
||||
'ERROR: No dashboard data in response.'
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output = json_encode(
|
||||
$dashboard,
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||
) . "\n";
|
||||
|
||||
if ($this->file !== '') {
|
||||
file_put_contents($this->file, $output);
|
||||
$this->log(
|
||||
"Exported {$this->uid} to {$this->file}"
|
||||
);
|
||||
} else {
|
||||
fwrite(STDOUT, $output);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function resolveFolderId(string $title): int
|
||||
{
|
||||
$response = $this->apiRequest('GET', '/api/folders');
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Could not fetch folders "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
$folders = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($folders)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
foreach ($folders as $f) {
|
||||
if (
|
||||
strcasecmp(
|
||||
$f['title'] ?? '',
|
||||
$title
|
||||
) === 0
|
||||
) {
|
||||
return (int) ($f['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"WARN: Folder \"{$title}\" not found, "
|
||||
. "using General."
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function noCommand(): int
|
||||
{
|
||||
$this->log('ERROR: No command specified.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case 'push':
|
||||
case 'delete':
|
||||
case 'list':
|
||||
case 'export':
|
||||
$this->command = $args[$i];
|
||||
break;
|
||||
case '--url':
|
||||
$this->grafanaUrl = rtrim(
|
||||
$args[++$i] ?? '',
|
||||
'/'
|
||||
);
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--uid':
|
||||
$this->uid = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->file = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--folder-id':
|
||||
$this->folderId = (int) (
|
||||
$args[++$i] ?? 0
|
||||
);
|
||||
break;
|
||||
case '--folder':
|
||||
$this->folderTitle = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-overwrite':
|
||||
$this->overwrite = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log(
|
||||
"WARNING: Unknown arg: {$args[$i]}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$u = 'Usage: grafana_dashboard.php <command> '
|
||||
. '--url <url> --token <token> [options]';
|
||||
$this->log($u);
|
||||
$this->log('');
|
||||
$this->log('Commands:');
|
||||
$this->log(' push Create/update dashboard from JSON');
|
||||
$this->log(' delete Delete a dashboard by UID');
|
||||
$this->log(' list List dashboards (optionally by folder)');
|
||||
$this->log(' export Export dashboard JSON by UID');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --url <url> Grafana URL (or GRAFANA_URL)');
|
||||
$this->log(' --token <token> API token (or GRAFANA_TOKEN)');
|
||||
$this->log(' --uid <uid> Dashboard UID (delete/export)');
|
||||
$this->log(' --file <path> JSON file (push/export)');
|
||||
$this->log(' --folder <name> Folder name (push/list)');
|
||||
$this->log(' --folder-id <id> Folder ID (push/list)');
|
||||
$this->log(' --no-overwrite Fail if dashboard exists');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $this->grafanaUrl . $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: Bearer {$this->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];
|
||||
}
|
||||
|
||||
private function logApiError(string $body): void
|
||||
{
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data) && isset($data['message'])) {
|
||||
$this->log(" Grafana: {$data['message']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new GrafanaDashboard();
|
||||
exit($app->run());
|
||||
@@ -155,6 +155,14 @@ function parseManifest(string $file): array
|
||||
$element = (string) ($xml->element ?? '');
|
||||
$group = (string) ($xml->attributes()->group ?? '');
|
||||
|
||||
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
|
||||
if ($type === 'package' && $element === '') {
|
||||
$packageName = (string) ($xml->packagename ?? '');
|
||||
if ($packageName !== '') {
|
||||
$element = $packageName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback element detection
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
|
||||
@@ -164,6 +172,10 @@ function parseManifest(string $file): array
|
||||
$element = strtolower(basename(dirname($file)));
|
||||
}
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
||||
|
||||
if ($name === '') { $name = $element; }
|
||||
|
||||
return compact('name', 'type', 'element', 'group');
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/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/joomla_compat_check.php
|
||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
||||
*
|
||||
* Usage:
|
||||
* php joomla_compat_check.php --path /repo
|
||||
* php joomla_compat_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Find manifest and extract targetplatform ────────────────────────────
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No manifest with targetplatform found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$xml = file_get_contents($manifest);
|
||||
$relManifest = str_replace($root . '/', '', $manifest);
|
||||
|
||||
// Extract targetplatform version regex
|
||||
$targetRegex = '';
|
||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||
$targetRegex = $m[1];
|
||||
}
|
||||
|
||||
if (empty($targetRegex)) {
|
||||
echo "No targetplatform version found in {$relManifest}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Manifest: {$relManifest}\n";
|
||||
echo "Target regex: {$targetRegex}\n";
|
||||
|
||||
// ── Fetch latest Joomla version ─────────────────────────────────────────
|
||||
$joomlaVersions = [];
|
||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
|
||||
if ($updateXml === false) {
|
||||
// Fallback: try the LTS feed
|
||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
}
|
||||
|
||||
if ($updateXml !== false) {
|
||||
// Parse all version entries
|
||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||
$joomlaVersions = $matches[1] ?? [];
|
||||
}
|
||||
|
||||
if (empty($joomlaVersions)) {
|
||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||
echo "Tested URL: {$updateUrl}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Sort and get latest
|
||||
usort($joomlaVersions, 'version_compare');
|
||||
$latestJoomla = end($joomlaVersions);
|
||||
|
||||
echo "Latest Joomla: {$latestJoomla}\n";
|
||||
|
||||
// ── Test compatibility ──────────────────────────────────────────────────
|
||||
// The targetplatform regex uses Joomla's regex format
|
||||
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
|
||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||
|
||||
if ($compatible === false) {
|
||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||
$result = 'error';
|
||||
} elseif ($compatible === 1) {
|
||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||
$result = 'pass';
|
||||
} else {
|
||||
// Check which major versions are supported
|
||||
$supported = [];
|
||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
||||
$supported[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
|
||||
echo "Supported versions: " . implode(', ', $supported) . "\n";
|
||||
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
|
||||
$result = 'warn';
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($result === 'error' ? 1 : 0);
|
||||
+13
-6
@@ -24,9 +24,17 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||
|
||||
class JoomlaRelease extends CLIApp
|
||||
/**
|
||||
* Joomla Release Manager
|
||||
*
|
||||
* Creates and manages Joomla extension releases on Gitea, including
|
||||
* package building, asset upload, and update stream management.
|
||||
*
|
||||
* @since 04.06.00
|
||||
*/
|
||||
class JoomlaRelease extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const ORG = 'mokoconsulting-tech';
|
||||
@@ -48,7 +56,7 @@ class JoomlaRelease extends CLIApp
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private AuditLogger $logger;
|
||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
@@ -75,7 +83,6 @@ class JoomlaRelease extends CLIApp
|
||||
$config = Config::load();
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
$this->logger = new AuditLogger('joomla_release');
|
||||
|
||||
if ($repo !== '') {
|
||||
$path = $this->cloneRepo($repo);
|
||||
@@ -498,5 +505,5 @@ class JoomlaRelease extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
|
||||
exit($script->execute());
|
||||
$app = new JoomlaRelease();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/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/manifest_element.php
|
||||
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
|
||||
*
|
||||
* Usage:
|
||||
* php manifest_element.php --path .
|
||||
* php manifest_element.php --path . --version 09.01.00 --stability dev --github-output
|
||||
*
|
||||
* Detects platform (joomla, dolibarr, generic) and resolves:
|
||||
* ext_element — canonical element name (e.g. mokojgdpc)
|
||||
* ext_type — extension type (plugin, module, component, package, etc.)
|
||||
* ext_folder — group/folder for plugins (e.g. system)
|
||||
* ext_name — human-readable name (e.g. "Moko JGDPC")
|
||||
* type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.)
|
||||
* zip_name — computed ZIP filename
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$stability = 'stable';
|
||||
$githubOutput = false;
|
||||
$repoName = '';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) {
|
||||
$stability = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Detect platform from manifest.xml ────────────────────────────────────────
|
||||
$platform = 'generic';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find extension manifest (Joomla XML) ─────────────────────────────────────
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find Dolibarr module file ────────────────────────────────────────────────
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract metadata ─────────────────────────────────────────────────────────
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
|
||||
switch (true) {
|
||||
// Joomla platforms
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
|
||||
// Extension type and folder
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||
$extElement = $pm[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable name
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
// Dolibarr platforms
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
||||
|
||||
$modContent = file_get_contents($modFile);
|
||||
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
|
||||
$extName = $nm[1];
|
||||
}
|
||||
break;
|
||||
|
||||
// Generic / fallback
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Strip existing type prefix from element to prevent duplication ────────────
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
// ── Compute type prefix ──────────────────────────────────────────────────────
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Compute ZIP name ─────────────────────────────────────────────────────────
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
$suffix = $suffixMap[$stability] ?? '';
|
||||
$zipName = '';
|
||||
if ($version !== null) {
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
|
||||
}
|
||||
|
||||
// Fallback name
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName ?: basename($root);
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────────
|
||||
$outputs = [
|
||||
'platform' => $platform,
|
||||
'ext_element' => $extElement,
|
||||
'ext_type' => $extType,
|
||||
'ext_folder' => $extFolder,
|
||||
'ext_name' => $extName,
|
||||
'type_prefix' => $typePrefix,
|
||||
'zip_name' => $zipName,
|
||||
];
|
||||
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [];
|
||||
foreach ($outputs as $key => $value) {
|
||||
$lines[] = "{$key}={$value}";
|
||||
}
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
} else {
|
||||
// Fallback: echo ::set-output (legacy)
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "::set-output name={$key}::{$value}\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "{$key}={$value}\n";
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -103,6 +103,7 @@ if ($xml === false) {
|
||||
'language' => (string)($xml->build->language ?? ''),
|
||||
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
|
||||
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
|
||||
'version' => (string)($xml->identity->version ?? ''),
|
||||
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||
'excludes' => (string)($xml->deploy->excludes ?? ''),
|
||||
|
||||
+230
-163
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -37,33 +38,50 @@ $elementOverride = null;
|
||||
$githubOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--output-dir' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
|
||||
if ($arg === '--type-prefix' && isset($argv[$i + 1])) $typePrefixOverride = $argv[$i + 1];
|
||||
if ($arg === '--element' && isset($argv[$i + 1])) $elementOverride = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $githubOutput = true;
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output-dir' && isset($argv[$i + 1])) {
|
||||
$outputDir = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--type-prefix' && isset($argv[$i + 1])) {
|
||||
$typePrefixOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--element' && isset($argv[$i + 1])) {
|
||||
$elementOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
// -- Determine source directory -----------------------------------------------
|
||||
$sourceDir = null;
|
||||
foreach (['src', 'htdocs'] as $candidate) {
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
$sourceDir = "{$root}/{$candidate}";
|
||||
break;
|
||||
}
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
$sourceDir = "{$root}/{$candidate}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Determine element and type prefix from manifest --------------------------
|
||||
@@ -73,54 +91,80 @@ $extType = '';
|
||||
$isPackage = false;
|
||||
|
||||
if ($extElement === null || $typePrefixOverride === null) {
|
||||
// Find manifest
|
||||
$manifest = null;
|
||||
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find manifest
|
||||
$manifest = null;
|
||||
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
if ($extElement === null) {
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
|
||||
elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
elseif (preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
else $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
}
|
||||
if ($extElement === null) {
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} else {
|
||||
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
|
||||
if ($typePrefixOverride === null) {
|
||||
switch ($extType) {
|
||||
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break;
|
||||
case 'module': $typePrefix = 'mod_'; break;
|
||||
case 'component': $typePrefix = 'com_'; break;
|
||||
case 'template': $typePrefix = 'tpl_'; break;
|
||||
case 'library': $typePrefix = 'lib_'; break;
|
||||
case 'package': $typePrefix = 'pkg_'; break;
|
||||
}
|
||||
}
|
||||
if ($typePrefixOverride === null) {
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
}
|
||||
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
}
|
||||
}
|
||||
|
||||
if ($extElement === null) {
|
||||
$extElement = strtolower(basename($root));
|
||||
$extElement = strtolower(basename($root));
|
||||
}
|
||||
|
||||
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
||||
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
||||
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
||||
}
|
||||
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
|
||||
@@ -130,87 +174,106 @@ $tarPath = "{$outputDir}/{$tarName}";
|
||||
|
||||
// -- Exclude patterns ---------------------------------------------------------
|
||||
$excludePatterns = [
|
||||
'.ftpignore',
|
||||
'sftp-config*',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
'.ftpignore',
|
||||
'sftp-config*',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
];
|
||||
|
||||
// -- Build packages -----------------------------------------------------------
|
||||
if ($isPackage) {
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
|
||||
|
||||
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
|
||||
mkdir($stagingDir, 0755, true);
|
||||
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
|
||||
$packagesDir = "{$stagingDir}/packages";
|
||||
mkdir($packagesDir, 0755, true);
|
||||
|
||||
// ZIP each sub-extension
|
||||
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
|
||||
$subName = basename($extDir);
|
||||
echo " Packaging sub-extension: {$subName}\n";
|
||||
// ZIP each sub-extension into packages/
|
||||
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
|
||||
$subName = basename($extDir);
|
||||
echo " Packaging sub-extension: {$subName}\n";
|
||||
|
||||
$subZip = new ZipArchive();
|
||||
$subZipPath = "{$stagingDir}/{$subName}.zip";
|
||||
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
|
||||
continue;
|
||||
}
|
||||
$subZip = new ZipArchive();
|
||||
$subZipPath = "{$packagesDir}/{$subName}.zip";
|
||||
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
}
|
||||
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
|
||||
}
|
||||
|
||||
// Copy package-level files
|
||||
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||
copy($f, "{$stagingDir}/" . basename($f));
|
||||
}
|
||||
// Copy package-level files (manifest, script.php, etc.)
|
||||
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||
copy($f, "{$stagingDir}/" . basename($f));
|
||||
}
|
||||
|
||||
// Create ZIP from staging
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $stagingDir, '', []);
|
||||
$zip->close();
|
||||
// Copy language directory if present
|
||||
if (is_dir("{$sourceDir}/language")) {
|
||||
$langDest = "{$stagingDir}/language";
|
||||
mkdir($langDest, 0755, true);
|
||||
$langIterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($langIterator as $item) {
|
||||
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
|
||||
if ($item->isDir()) {
|
||||
mkdir($target, 0755, true);
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($stagingDir)
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
// Create ZIP from staging
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $stagingDir, '', []);
|
||||
$zip->close();
|
||||
|
||||
// Cleanup staging
|
||||
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||
passthru($cleanCmd);
|
||||
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($stagingDir)
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
|
||||
// Cleanup staging
|
||||
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||
passthru($cleanCmd);
|
||||
} else {
|
||||
echo "=== Building standard extension package ===\n";
|
||||
echo "=== Building standard extension package ===\n";
|
||||
|
||||
// ZIP
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
// ZIP
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
|
||||
// tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$excludeArgs = '';
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s%s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($sourceDir),
|
||||
$excludeArgs
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
// tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$excludeArgs = '';
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s%s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($sourceDir),
|
||||
$excludeArgs
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
}
|
||||
|
||||
// -- Calculate SHA-256 --------------------------------------------------------
|
||||
@@ -224,29 +287,31 @@ echo "\n";
|
||||
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Zip}\n";
|
||||
if ($tarSize > 0) {
|
||||
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Tar}\n";
|
||||
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Tar}\n";
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT --------------------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"zip_name={$zipName}",
|
||||
"tar_name={$tarName}",
|
||||
"zip_path={$zipPath}",
|
||||
"tar_path={$tarPath}",
|
||||
"sha256_zip={$sha256Zip}",
|
||||
"sha256_tar={$sha256Tar}",
|
||||
"type_prefix={$typePrefix}",
|
||||
"ext_element={$extElement}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) echo "{$line}\n";
|
||||
}
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"zip_name={$zipName}",
|
||||
"tar_name={$tarName}",
|
||||
"zip_path={$zipPath}",
|
||||
"tar_path={$tarPath}",
|
||||
"sha256_zip={$sha256Zip}",
|
||||
"sha256_tar={$sha256Tar}",
|
||||
"type_prefix={$typePrefix}",
|
||||
"ext_element={$extElement}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -256,33 +321,35 @@ exit(0);
|
||||
// =============================================================================
|
||||
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
|
||||
|
||||
// Check excludes
|
||||
$basename = basename($filePath);
|
||||
$skip = false;
|
||||
foreach ($excludes as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) continue;
|
||||
// Check excludes
|
||||
$basename = basename($filePath);
|
||||
$skip = false;
|
||||
foreach ($excludes as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for ZIP
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
// Normalize path separators for ZIP
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
} else {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
} else {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-55
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -14,12 +15,16 @@
|
||||
* Usage:
|
||||
* php release_cascade.php --stability stable --token TOKEN --api-base URL
|
||||
* php release_cascade.php --stability rc --token TOKEN --api-base URL
|
||||
* php release_cascade.php --stability stable --version 09.01.00 --token TOKEN --api-base URL
|
||||
*
|
||||
* Cascade rules:
|
||||
* stable -> deletes development, alpha, beta, release-candidate
|
||||
* rc -> deletes development, alpha, beta
|
||||
* beta -> deletes development, alpha
|
||||
* alpha -> deletes development
|
||||
*
|
||||
* When --version is given, also deletes releases on any channel whose version
|
||||
* is lower than the specified version (prevents stale pre-releases lingering).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -27,89 +32,176 @@ declare(strict_types=1);
|
||||
$stability = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$version = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) {
|
||||
$stability = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
|
||||
if ($stability === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
|
||||
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
|
||||
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Define cascade hierarchy
|
||||
$cascadeMap = [
|
||||
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
|
||||
'rc' => ['development', 'alpha', 'beta'],
|
||||
'beta' => ['development', 'alpha'],
|
||||
'alpha' => ['development'],
|
||||
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
|
||||
'release-candidate' => ['development', 'alpha', 'beta'],
|
||||
'rc' => ['development', 'alpha', 'beta'],
|
||||
'beta' => ['development', 'alpha'],
|
||||
'alpha' => ['development'],
|
||||
];
|
||||
|
||||
if (!isset($cascadeMap[$stability])) {
|
||||
fwrite(STDERR, "Unknown stability level: {$stability}\n");
|
||||
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "Unknown stability level: {$stability}\n");
|
||||
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$tagsToDelete = $cascadeMap[$stability];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($tagsToDelete as $tag) {
|
||||
// Get release by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
// Get release by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
continue;
|
||||
}
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$releaseId = $data['id'] ?? null;
|
||||
$data = json_decode($response, true);
|
||||
$releaseId = $data['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
continue;
|
||||
}
|
||||
if ($releaseId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete release
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Delete release
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Delete tag
|
||||
$ch = curl_init("{$apiBase}/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Delete tag
|
||||
$ch = curl_init("{$apiBase}/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
echo "Deleted: {$tag} (release id: {$releaseId})\n";
|
||||
$deleted++;
|
||||
echo "Deleted: {$tag} (release id: {$releaseId})\n";
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
// ── Version-aware cleanup: delete releases with lesser version numbers ───────
|
||||
if ($version !== null) {
|
||||
// Normalize version for comparison (strip any suffix)
|
||||
$baseVersion = preg_replace('/-[a-z]+$/', '', $version);
|
||||
|
||||
// Check all channels (including ones not in the cascade map for this stability)
|
||||
$allChannels = ['development', 'alpha', 'beta', 'release-candidate', 'stable'];
|
||||
foreach ($allChannels as $tag) {
|
||||
// Skip the current stability channel
|
||||
if ($tag === $stability) {
|
||||
continue;
|
||||
}
|
||||
// Skip channels already deleted by cascade above
|
||||
if (in_array($tag, $tagsToDelete, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$releaseId = $data['id'] ?? null;
|
||||
$releaseName = $data['name'] ?? '';
|
||||
if ($releaseId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract version from release name (e.g. "element 09.00.01 (development)")
|
||||
$releaseVersion = null;
|
||||
if (preg_match('/(\d{2}\.\d{2}\.\d{2})/', $releaseName, $vm)) {
|
||||
$releaseVersion = $vm[1];
|
||||
}
|
||||
|
||||
if ($releaseVersion === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete if release version is less than the promoted version
|
||||
if (version_compare($releaseVersion, $baseVersion, '<')) {
|
||||
$delCh = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($delCh, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($delCh);
|
||||
curl_close($delCh);
|
||||
|
||||
$tagCh = curl_init("{$apiBase}/tags/{$tag}");
|
||||
curl_setopt_array($tagCh, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($tagCh);
|
||||
curl_close($tagCh);
|
||||
|
||||
echo "Deleted: {$tag} — version {$releaseVersion} < {$baseVersion}\n";
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "Cleaned up {$deleted} pre-release channel(s)\n";
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/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/release_create.php
|
||||
* BRIEF: Create or overwrite a Gitea release with proper naming
|
||||
*
|
||||
* Usage:
|
||||
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL
|
||||
* php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease
|
||||
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo
|
||||
*
|
||||
* Replaces the inline bash in auto-release.yml Step 7b.
|
||||
* Detects extension metadata from manifest, builds a proper release name,
|
||||
* generates release notes, and creates (or overwrites) a Gitea release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────────
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$tag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'main';
|
||||
$repoName = '';
|
||||
$prerelease = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||
$tag = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--prerelease') {
|
||||
$prerelease = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$envToken = getenv('GA_TOKEN');
|
||||
if ($envToken === false || $envToken === '') {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
}
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null || $tag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n");
|
||||
fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n");
|
||||
fwrite(STDERR, " --branch main Target commitish (default: main)\n");
|
||||
fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n");
|
||||
fwrite(STDERR, " --prerelease Mark release as prerelease\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Helper: Gitea API request ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a request to the Gitea API.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Authorization token
|
||||
* @param string $method HTTP method (GET, POST, DELETE, etc.)
|
||||
* @param string|null $body JSON request body
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
// ── Detect element metadata ─────────────────────────────────────────────────
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
$typePrefix = '';
|
||||
|
||||
// Detect platform from manifest.xml
|
||||
$platform = 'generic';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if ($content !== false && preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Find extension manifest (Joomla XML)
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Dolibarr module file
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata based on platform
|
||||
switch (true) {
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
if ($xml === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||
$extElement = $pm2[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable name
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
|
||||
|
||||
$modContent = file_get_contents($modFile);
|
||||
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
|
||||
$extName = $nm2[1];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip existing type prefix from element to prevent duplication
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
|
||||
|
||||
// Compute type prefix
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback name
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName !== '' ? $repoName : basename($root);
|
||||
}
|
||||
|
||||
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
|
||||
|
||||
// ── Build release name ──────────────────────────────────────────────────────
|
||||
|
||||
$releaseName = "{$extName} {$version} ({$typePrefix}{$extElement}-{$version})";
|
||||
echo "Release name: {$releaseName}\n";
|
||||
|
||||
// ── Generate release notes ──────────────────────────────────────────────────
|
||||
|
||||
$releaseNotes = "Release {$version}";
|
||||
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
|
||||
if (file_exists($releaseNotesScript)) {
|
||||
$cmd = sprintf(
|
||||
'php %s --path %s --version %s',
|
||||
escapeshellarg($releaseNotesScript),
|
||||
escapeshellarg($root),
|
||||
escapeshellarg($version)
|
||||
);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode === 0 && count($output) > 0) {
|
||||
$notes = implode("\n", $output);
|
||||
if (trim($notes) !== '') {
|
||||
$releaseNotes = $notes;
|
||||
echo "Release notes: generated from CHANGELOG.md\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete existing release at tag (if present) ─────────────────────────────
|
||||
|
||||
$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if ($existing !== null && !empty($existing['id'])) {
|
||||
$existingId = $existing['id'];
|
||||
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
|
||||
|
||||
// Delete release
|
||||
giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
|
||||
|
||||
// Delete tag
|
||||
giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Create new release ──────────────────────────────────────────────────────
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseNotes,
|
||||
'prerelease' => $prerelease,
|
||||
]);
|
||||
|
||||
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
|
||||
if ($newRelease === null || empty($newRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create release at tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$releaseId = $newRelease['id'];
|
||||
echo "Created release: {$tag} (id: {$releaseId})\n";
|
||||
|
||||
// Output release_id to stdout for CI consumption
|
||||
echo "release_id={$releaseId}\n";
|
||||
exit(0);
|
||||
+11
-11
@@ -83,7 +83,7 @@ if ($action === null || $tag === null || $token === null || $apiBase === null) {
|
||||
/**
|
||||
* Make a Gitea API request using curl
|
||||
*/
|
||||
function giteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$headers = ["Authorization: token {$token}"];
|
||||
@@ -118,7 +118,7 @@ function giteaApi(string $url, string $method, string $token, ?string $jsonBody
|
||||
*/
|
||||
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||
{
|
||||
$result = giteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||
if ($result['code'] === 200 && isset($result['data']['id'])) {
|
||||
return $result['data'];
|
||||
}
|
||||
@@ -132,8 +132,8 @@ switch ($action) {
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
$existingId = $existing['id'];
|
||||
giteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ switch ($action) {
|
||||
'target_commitish' => $target,
|
||||
]);
|
||||
|
||||
$result = giteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||
$result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
$releaseId = $result['data']['id'] ?? 'unknown';
|
||||
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
|
||||
@@ -169,7 +169,7 @@ switch ($action) {
|
||||
$releaseId = $release['id'];
|
||||
|
||||
// Get existing assets to avoid duplicates
|
||||
$assetsResult = giteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
@@ -184,7 +184,7 @@ switch ($action) {
|
||||
// Delete existing asset with same name
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (($asset['name'] ?? '') === $fileName) {
|
||||
giteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||
echo "Deleted existing asset: {$fileName}\n";
|
||||
break;
|
||||
}
|
||||
@@ -192,7 +192,7 @@ switch ($action) {
|
||||
|
||||
// Upload
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
||||
$result = giteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Uploaded: {$fileName}\n";
|
||||
} else {
|
||||
@@ -210,7 +210,7 @@ switch ($action) {
|
||||
$releaseId = $release['id'];
|
||||
|
||||
$payload = json_encode(['body' => $body ?? '']);
|
||||
$result = giteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Release body updated for tag: {$tag}\n";
|
||||
} else {
|
||||
@@ -222,8 +222,8 @@ switch ($action) {
|
||||
case 'delete':
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
giteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted: {$tag} (id: {$existing['id']})\n";
|
||||
} else {
|
||||
echo "No release found for tag: {$tag}\n";
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/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/release_mirror.php
|
||||
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
|
||||
*
|
||||
* Usage:
|
||||
* php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \
|
||||
* --gh-token GH_TOKEN --gh-repo MokoConsulting/MokoWaaS
|
||||
*
|
||||
* Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release.
|
||||
* If the GitHub release already exists at the same tag, its title is updated via PATCH.
|
||||
* All assets from the Gitea release are downloaded and uploaded to the GitHub release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
$version = null;
|
||||
$tag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$ghToken = null;
|
||||
$ghRepo = null;
|
||||
$branch = 'main';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||
$tag = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gh-token' && isset($argv[$i + 1])) {
|
||||
$ghToken = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gh-repo' && isset($argv[$i + 1])) {
|
||||
$ghRepo = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow tokens from environment
|
||||
$token = $token ?: (getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
$ghToken = $ghToken ?: (getenv('GH_TOKEN') ?: null);
|
||||
|
||||
if (
|
||||
$version === null || $tag === null || $token === null || $apiBase === null
|
||||
|| $ghToken === null || $ghRepo === null
|
||||
) {
|
||||
fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
|
||||
"--api-base URL --gh-token GH_TOKEN --gh-repo org/repo [--branch main]\n");
|
||||
fwrite(STDERR, " --token: Gitea token (or GA_TOKEN / GITEA_TOKEN env)\n");
|
||||
fwrite(STDERR, " --gh-token: GitHub token (or GH_TOKEN env)\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a request to the Gitea API.
|
||||
*
|
||||
* @param string $url Full Gitea API URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||
* @param string|null $body JSON request body or null
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from Gitea to a local path.
|
||||
*
|
||||
* @param string $url Download URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $dest Local destination path
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the GitHub API.
|
||||
*
|
||||
* @param string $url Full GitHub API URL
|
||||
* @param string $token GitHub personal access token
|
||||
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||
* @param string|null $body JSON request body or null
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a binary asset to a GitHub release.
|
||||
*
|
||||
* @param string $uploadUrl GitHub upload URL (uploads.github.com)
|
||||
* @param string $token GitHub personal access token
|
||||
* @param string $filePath Local file path to upload
|
||||
* @param string $name Asset filename for GitHub
|
||||
*
|
||||
* @return int HTTP status code
|
||||
*/
|
||||
function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
|
||||
{
|
||||
$url = $uploadUrl . '?name=' . urlencode($name);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($filePath),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return $httpCode;
|
||||
}
|
||||
|
||||
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
|
||||
|
||||
echo "Fetching Gitea release: {$tag}\n";
|
||||
$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if (!$giteaRelease || empty($giteaRelease['id'])) {
|
||||
fwrite(STDERR, "No Gitea release found with tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$giteaId = $giteaRelease['id'];
|
||||
$releaseName = $giteaRelease['name'] ?? "{$version}";
|
||||
$releaseBody = $giteaRelease['body'] ?? '';
|
||||
$assets = $giteaRelease['assets'] ?? [];
|
||||
|
||||
echo " Name: {$releaseName}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Step 2: Check / create GitHub release ────────────────────────────────────
|
||||
|
||||
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
|
||||
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
|
||||
|
||||
echo "Checking GitHub release: {$tag}\n";
|
||||
$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
|
||||
|
||||
if ($ghRelease && !empty($ghRelease['id'])) {
|
||||
// Update existing release title
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
|
||||
$patchPayload = json_encode([
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
]);
|
||||
githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
|
||||
} else {
|
||||
// Create new release
|
||||
echo " Creating GitHub release\n";
|
||||
$createPayload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
'draft' => false,
|
||||
'prerelease' => ($tag !== 'stable'),
|
||||
]);
|
||||
$ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
|
||||
if (!$ghRelease || empty($ghRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create GitHub release\n");
|
||||
exit(1);
|
||||
}
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " Created GitHub release (id: {$ghReleaseId})\n";
|
||||
}
|
||||
|
||||
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'] ?? '';
|
||||
$downloadUrl = $asset['browser_download_url'] ?? '';
|
||||
if ($name === '' || $downloadUrl === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localPath = "{$tmpDir}/{$name}";
|
||||
echo " Downloading: {$name}\n";
|
||||
|
||||
if (!giteaDownload($downloadUrl, $token, $localPath)) {
|
||||
fwrite(STDERR, " Failed to download: {$name}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
|
||||
echo " Uploading: {$name}\n";
|
||||
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " {$status}\n";
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
|
||||
echo " Version: {$version}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
exit(0);
|
||||
@@ -0,0 +1,548 @@
|
||||
#!/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/release_package.php
|
||||
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
|
||||
*
|
||||
* Usage:
|
||||
* php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL
|
||||
* php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo
|
||||
*
|
||||
* Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums,
|
||||
* creates .sha256 sidecar files, and uploads all assets to an existing Gitea release.
|
||||
*
|
||||
* For Joomla packages (type=package with packages/ subdir):
|
||||
* - ZIPs each sub-extension directory
|
||||
* - Copies top-level XML/PHP to package root before archiving
|
||||
*
|
||||
* For standard extensions:
|
||||
* - Builds ZIP and tar.gz from source dir
|
||||
* - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$tag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$repoName = '';
|
||||
$outputDir = sys_get_temp_dir();
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||
$tag = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) {
|
||||
$outputDir = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null);
|
||||
}
|
||||
|
||||
if ($version === null || $tag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n");
|
||||
fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n");
|
||||
fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform a Gitea API request.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token API token
|
||||
* @param string $method HTTP method
|
||||
* @param string|null $body Request body (JSON)
|
||||
*
|
||||
* @return array{data: array<string, mixed>|null, code: int}
|
||||
*/
|
||||
function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return ['data' => null, 'code' => 0];
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||
return ['data' => null, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file as a release asset.
|
||||
*
|
||||
* @param string $url Upload endpoint URL
|
||||
* @param string $token API token
|
||||
* @param string $filePath Local file path
|
||||
*
|
||||
* @return int HTTP status code
|
||||
*/
|
||||
function giteaUploadAsset(string $url, string $token, string $filePath): int
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return 0;
|
||||
}
|
||||
$fileContent = file_get_contents($filePath);
|
||||
if ($fileContent === false) {
|
||||
return 0;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode;
|
||||
}
|
||||
|
||||
// ── Read platform from .mokogitea/manifest.xml ───────────────────────────────
|
||||
|
||||
$detectedPlatform = 'generic';
|
||||
$detectedEntryPoint = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||
if ($mokoXml !== false) {
|
||||
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||
if ($rawPlatform !== '') {
|
||||
$detectedPlatform = match ($rawPlatform) {
|
||||
'waas-component' => 'joomla',
|
||||
'crm-module' => 'dolibarr',
|
||||
default => $rawPlatform,
|
||||
};
|
||||
}
|
||||
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detect element metadata from manifest XML ────────────────────────────────
|
||||
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$typePrefix = '';
|
||||
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
$extManifest = null;
|
||||
foreach ($manifestFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if ($content !== false && strpos($content, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($extManifest !== null) {
|
||||
$xml = file_get_contents($extManifest);
|
||||
if ($xml === false) {
|
||||
$xml = '';
|
||||
}
|
||||
|
||||
// Extension type and folder
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||
$extElement = $pm[1];
|
||||
}
|
||||
// For packages: prefer <packagename> over filename
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if ($extElement === '') {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to repo name
|
||||
if ($extElement === '') {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication
|
||||
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
// Compute type prefix
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
echo "Element: {$typePrefix}{$extElement}\n";
|
||||
echo "Type: {$extType}\n";
|
||||
|
||||
// ── Compute filenames ────────────────────────────────────────────────────────
|
||||
|
||||
$baseName = "{$typePrefix}{$extElement}-{$version}";
|
||||
$zipFile = "{$outputDir}/{$baseName}.zip";
|
||||
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
|
||||
|
||||
echo "ZIP: {$baseName}.zip\n";
|
||||
echo "TAR: {$baseName}.tar.gz\n";
|
||||
|
||||
// ── Find source directory ────────────────────────────────────────────────────
|
||||
|
||||
$sourceDir = null;
|
||||
|
||||
// Use entry-point from manifest.xml if available
|
||||
if ($detectedEntryPoint !== '') {
|
||||
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
|
||||
if (is_dir("{$root}/{$entryDir}")) {
|
||||
$sourceDir = "{$root}/{$entryDir}";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to common directories
|
||||
if ($sourceDir === null && is_dir("{$root}/src")) {
|
||||
$sourceDir = "{$root}/src";
|
||||
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
|
||||
$sourceDir = "{$root}/htdocs";
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
echo "No src/ or htdocs/ directory found — skipping package build\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Source: {$sourceDir}\n";
|
||||
|
||||
// ── File exclusion patterns ──────────────────────────────────────────────────
|
||||
|
||||
/** @var array<int, string> */
|
||||
$excludePatterns = [
|
||||
'sftp-config*',
|
||||
'.ftpignore',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
'*.local',
|
||||
'.build-trigger',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a filename matches any exclusion pattern.
|
||||
*
|
||||
* @param string $filename Filename to check
|
||||
* @param array<int,string> $patterns Glob patterns to exclude
|
||||
*
|
||||
* @return bool True if the file should be excluded
|
||||
*/
|
||||
function isExcluded(string $filename, array $patterns): bool
|
||||
{
|
||||
$basename = basename($filename);
|
||||
foreach ($patterns as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add files from a directory to a ZipArchive.
|
||||
*
|
||||
* @param ZipArchive $zip ZipArchive instance
|
||||
* @param string $sourceDir Source directory path
|
||||
* @param string $prefix Path prefix inside the archive
|
||||
* @param array<int,string> $excludes Exclusion patterns
|
||||
*/
|
||||
function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file instanceof SplFileInfo || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$realPath = $file->getRealPath();
|
||||
if ($realPath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isExcluded($file->getFilename(), $excludes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($realPath, strlen($sourceDir) + 1);
|
||||
// Normalise to forward slashes for ZIP compatibility
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
|
||||
$zip->addFile($realPath, $archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build packages ───────────────────────────────────────────────────────────
|
||||
|
||||
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
|
||||
if ($isJoomlaPackage) {
|
||||
// ── Joomla package: ZIP each sub-extension, then combine ─────────────────
|
||||
echo "Building Joomla package (sub-extensions)...\n";
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ZIP each sub-extension directory
|
||||
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
|
||||
foreach ($packageDirs as $pkgDir) {
|
||||
$subName = basename($pkgDir);
|
||||
$subZipPath = "{$outputDir}/{$subName}.zip";
|
||||
|
||||
$subZip = new ZipArchive();
|
||||
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n");
|
||||
continue;
|
||||
}
|
||||
addDirToZip($subZip, $pkgDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
|
||||
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
||||
echo " Sub-package: {$subName}.zip\n";
|
||||
}
|
||||
|
||||
// Copy top-level XML and PHP files into the package root
|
||||
$topLevelFiles = array_merge(
|
||||
glob("{$sourceDir}/*.xml") ?: [],
|
||||
glob("{$sourceDir}/*.php") ?: []
|
||||
);
|
||||
foreach ($topLevelFiles as $tlFile) {
|
||||
if (!isExcluded(basename($tlFile), $excludePatterns)) {
|
||||
$zip->addFile($tlFile, basename($tlFile));
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
echo "ZIP created: {$zipFile}\n";
|
||||
} else {
|
||||
// ── Standard extension: ZIP from source dir ──────────────────────────────
|
||||
echo "Building standard extension ZIP...\n";
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
echo "ZIP created: {$zipFile}\n";
|
||||
}
|
||||
|
||||
// ── Build tar.gz ─────────────────────────────────────────────────────────────
|
||||
|
||||
$tarExcludeArgs = [];
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
|
||||
$tarCommand = sprintf(
|
||||
'tar -czf %s -C %s %s .',
|
||||
escapeshellarg($tarFile),
|
||||
escapeshellarg($sourceDir),
|
||||
implode(' ', $tarExcludeArgs)
|
||||
);
|
||||
|
||||
$tarReturnCode = 0;
|
||||
$tarOutputLines = [];
|
||||
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
|
||||
|
||||
if (!file_exists($tarFile)) {
|
||||
fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n");
|
||||
if ($tarOutputLines !== []) {
|
||||
fwrite(STDERR, implode("\n", $tarOutputLines) . "\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
echo "TAR created: {$tarFile}\n";
|
||||
|
||||
// ── Compute SHA-256 checksums ────────────────────────────────────────────────
|
||||
|
||||
$zipHash = hash_file('sha256', $zipFile);
|
||||
$tarHash = hash_file('sha256', $tarFile);
|
||||
|
||||
if ($zipHash === false || $tarHash === false) {
|
||||
fwrite(STDERR, "Failed to compute SHA-256 checksums\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$zipSha = "{$zipFile}.sha256";
|
||||
$tarSha = "{$tarFile}.sha256";
|
||||
|
||||
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
|
||||
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
|
||||
|
||||
echo "SHA-256 (ZIP): {$zipHash}\n";
|
||||
echo "SHA-256 (TAR): {$tarHash}\n";
|
||||
|
||||
// ── Get release ID from tag ──────────────────────────────────────────────────
|
||||
|
||||
$result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if ($result['data'] === null || !isset($result['data']['id'])) {
|
||||
fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$releaseId = (int) $result['data']['id'];
|
||||
echo "Release ID: {$releaseId} (tag: {$tag})\n";
|
||||
|
||||
// ── Delete existing assets with same names ───────────────────────────────────
|
||||
|
||||
$assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
|
||||
$uploadNames = [
|
||||
"{$baseName}.zip",
|
||||
"{$baseName}.tar.gz",
|
||||
"{$baseName}.zip.sha256",
|
||||
"{$baseName}.tar.gz.sha256",
|
||||
];
|
||||
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (!is_array($asset)) {
|
||||
continue;
|
||||
}
|
||||
$assetName = $asset['name'] ?? '';
|
||||
$assetId = $asset['id'] ?? 0;
|
||||
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
|
||||
giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
|
||||
echo "Deleted existing asset: {$assetName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload assets ────────────────────────────────────────────────────────────
|
||||
|
||||
$filesToUpload = [
|
||||
"{$baseName}.zip" => $zipFile,
|
||||
"{$baseName}.tar.gz" => $tarFile,
|
||||
"{$baseName}.zip.sha256" => $zipSha,
|
||||
"{$baseName}.tar.gz.sha256" => $tarSha,
|
||||
];
|
||||
|
||||
$uploaded = 0;
|
||||
foreach ($filesToUpload as $name => $localPath) {
|
||||
if (!file_exists($localPath)) {
|
||||
fwrite(STDERR, "File not found, skipping: {$localPath}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
|
||||
$httpCode = giteaUploadAsset($uploadUrl, $token, $localPath);
|
||||
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
|
||||
echo "Upload: {$name} — {$status}\n";
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "\n";
|
||||
echo "Package build complete\n";
|
||||
echo " Element: {$typePrefix}{$extElement}\n";
|
||||
echo " Version: {$version}\n";
|
||||
echo " Tag: {$tag}\n";
|
||||
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
|
||||
|
||||
exit($uploaded === count($filesToUpload) ? 0 : 1);
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/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/release_promote.php
|
||||
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
|
||||
*
|
||||
* Usage:
|
||||
* php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL
|
||||
* php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path .
|
||||
*
|
||||
* When promoting to stable, --path detects extension type prefix for asset renaming.
|
||||
* When --from is "auto", checks beta > alpha > development and uses the first found.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$from = null;
|
||||
$to = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$path = '.';
|
||||
$branch = 'main';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--from' && isset($argv[$i + 1])) {
|
||||
$from = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--to' && isset($argv[$i + 1])) {
|
||||
$to = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
$token = $token ?: (getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
|
||||
if ($to === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]\n");
|
||||
fwrite(STDERR, " --from auto: checks beta > alpha > development\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Suffix maps ──────────────────────────────────────────────────────────────
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
|
||||
// ── Channel hierarchy (highest first) ────────────────────────────────────────
|
||||
$channelOrder = ['beta', 'alpha', 'development'];
|
||||
|
||||
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||
/** @return array<string, mixed>|null */
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
// ── Resolve --from auto ──────────────────────────────────────────────────────
|
||||
if ($from === 'auto') {
|
||||
foreach ($channelOrder as $candidate) {
|
||||
$data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
|
||||
if ($data && !empty($data['id'])) {
|
||||
$from = $candidate;
|
||||
echo "Auto-detected source channel: {$from}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($from === 'auto') {
|
||||
echo "No pre-release found to promote\n";
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find source release ──────────────────────────────────────────────────────
|
||||
$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token);
|
||||
if (!$sourceRelease || empty($sourceRelease['id'])) {
|
||||
fwrite(STDERR, "No release found with tag: {$from}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$sourceId = $sourceRelease['id'];
|
||||
$sourceName = $sourceRelease['name'] ?? '';
|
||||
$sourceBody = $sourceRelease['body'] ?? '';
|
||||
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
|
||||
|
||||
// ── Get source assets ────────────────────────────────────────────────────────
|
||||
$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
|
||||
echo "Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Download assets to temp ──────────────────────────────────────────────────
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'];
|
||||
$downloadUrl = $asset['browser_download_url'];
|
||||
echo " Downloading: {$name}\n";
|
||||
giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
|
||||
}
|
||||
|
||||
// ── Detect type prefix for stable promotion ──────────────────────────────────
|
||||
$typePrefix = '';
|
||||
if ($to === 'stable') {
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
if ($typePrefix !== '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename assets ────────────────────────────────────────────────────────────
|
||||
$oldSuffix = $suffixMap[$from] ?? '';
|
||||
$newSuffix = $suffixMap[$to] ?? '';
|
||||
|
||||
$renamedAssets = [];
|
||||
foreach ($assets as $asset) {
|
||||
$oldName = $asset['name'];
|
||||
$newName = $oldName;
|
||||
|
||||
// Strip old suffix
|
||||
if ($oldSuffix !== '') {
|
||||
$newName = str_replace($oldSuffix, '', $newName);
|
||||
}
|
||||
|
||||
// Add type prefix for stable (if not already prefixed)
|
||||
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
|
||||
// Strip any existing type prefix to prevent duplication
|
||||
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
|
||||
$newName = $typePrefix . $newName;
|
||||
}
|
||||
|
||||
// Add new suffix (for non-stable targets)
|
||||
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
|
||||
// Insert before extension
|
||||
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
|
||||
}
|
||||
|
||||
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
|
||||
if ($oldName !== $newName) {
|
||||
echo " Rename: {$oldName} → {$newName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete source release + tag ──────────────────────────────────────────────
|
||||
giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
|
||||
giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
|
||||
echo "Deleted source: {$from} release + tag\n";
|
||||
|
||||
// ── Delete existing target release + tag (if any) ────────────────────────────
|
||||
$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token);
|
||||
if ($existingTarget && !empty($existingTarget['id'])) {
|
||||
giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
|
||||
giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
|
||||
echo "Deleted existing target: {$to} release + tag\n";
|
||||
}
|
||||
|
||||
// ── Create target release ────────────────────────────────────────────────────
|
||||
$isPrerelease = ($to !== 'stable');
|
||||
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
|
||||
if ($newName === $sourceName) {
|
||||
$newName = str_ireplace($from, $to, $sourceName);
|
||||
}
|
||||
|
||||
$newBody = str_ireplace($from, $to, $sourceBody);
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $to,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $newName,
|
||||
'body' => $newBody,
|
||||
'prerelease' => $isPrerelease,
|
||||
]);
|
||||
|
||||
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
|
||||
if (!$newRelease || empty($newRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create {$to} release\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$newId = $newRelease['id'];
|
||||
echo "Created: {$to} release (id: {$newId})\n";
|
||||
|
||||
// ── Upload renamed assets ────────────────────────────────────────────────────
|
||||
foreach ($renamedAssets as $entry) {
|
||||
$localFile = "{$tmpDir}/{$entry['old']}";
|
||||
if (!file_exists($localFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadName = urlencode($entry['new']);
|
||||
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($localFile),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " Upload: {$entry['new']} — {$status}\n";
|
||||
}
|
||||
|
||||
// ── Cleanup temp ─────────────────────────────────────────────────────────────
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
echo "Promoted: {$from} → {$to}\n";
|
||||
exit(0);
|
||||
+175
-96
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -26,153 +27,231 @@ declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$platform = 'joomla';
|
||||
$platform = null;
|
||||
$outputSummary = false;
|
||||
$githubOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) {
|
||||
$platform = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output-summary') {
|
||||
$outputSummary = true;
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Auto-detect platform from manifest.xml if not specified
|
||||
if ($platform === null) {
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$mContent = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
// Normalize platform aliases
|
||||
if (in_array($platform, ['waas-component'], true)) {
|
||||
$platform = 'joomla';
|
||||
}
|
||||
if (in_array($platform, ['crm-module'], true)) {
|
||||
$platform = 'dolibarr';
|
||||
}
|
||||
if ($platform === null) {
|
||||
$platform = 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$warn = 0;
|
||||
/** @var array<int, array{check: string, status: string, details: string}> */
|
||||
$results = [];
|
||||
|
||||
function addResult(string $check, string $status, string $details): void {
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') $pass++;
|
||||
elseif ($status === 'FAIL') $fail++;
|
||||
elseif ($status === 'WARN') $warn++;
|
||||
/**
|
||||
* Record a validation result.
|
||||
*
|
||||
* @param string $check Check name
|
||||
* @param string $status PASS, FAIL, or WARN
|
||||
* @param string $details Human-readable details
|
||||
*/
|
||||
function addResult(string $check, string $status, string $details): void
|
||||
{
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') {
|
||||
$pass++;
|
||||
} elseif ($status === 'FAIL') {
|
||||
$fail++;
|
||||
} elseif ($status === 'WARN') {
|
||||
$warn++;
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Source directory check
|
||||
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||
if ($hasSource) {
|
||||
addResult('Source directory', 'PASS', 'src/ or htdocs/ found');
|
||||
} else {
|
||||
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory');
|
||||
}
|
||||
|
||||
// 1. README.md exists and contains VERSION
|
||||
if (!file_exists("{$root}/README.md")) {
|
||||
addResult('README.md', 'FAIL', 'Not found');
|
||||
addResult('README.md', 'FAIL', 'Not found');
|
||||
} else {
|
||||
$readme = file_get_contents("{$root}/README.md");
|
||||
if (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
|
||||
strpos($readme, $version) !== false) {
|
||||
addResult('README.md version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
|
||||
}
|
||||
$readme = file_get_contents("{$root}/README.md");
|
||||
if (
|
||||
preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
|
||||
strpos($readme, $version) !== false
|
||||
) {
|
||||
addResult('README.md version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CHANGELOG.md exists with matching section
|
||||
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||
addResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||
addResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||
} else {
|
||||
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
|
||||
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
|
||||
} else {
|
||||
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
|
||||
}
|
||||
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
|
||||
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
|
||||
} else {
|
||||
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. LICENSE file exists
|
||||
$licenseFound = false;
|
||||
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||
if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; }
|
||||
if (file_exists("{$root}/{$lf}")) {
|
||||
$licenseFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||
|
||||
// 4. Platform-specific checks
|
||||
if ($platform === 'joomla') {
|
||||
// Find XML manifest
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
addResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||
} else {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$mVer = trim($m[1]);
|
||||
if ($mVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$mVer}` matches");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
|
||||
}
|
||||
}
|
||||
// Find XML manifest
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
addResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||
} else {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$mVer = trim($m[1]);
|
||||
if ($mVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$mVer}` matches");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
|
||||
}
|
||||
}
|
||||
|
||||
// updates.xml
|
||||
if (!file_exists("{$root}/updates.xml")) {
|
||||
addResult('updates.xml', 'WARN', 'Not found');
|
||||
} else {
|
||||
$ux = file_get_contents("{$root}/updates.xml");
|
||||
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) {
|
||||
addResult('updates.xml version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml");
|
||||
}
|
||||
}
|
||||
// updates.xml
|
||||
if (!file_exists("{$root}/updates.xml")) {
|
||||
addResult('updates.xml', 'WARN', 'Not found');
|
||||
} else {
|
||||
$ux = file_get_contents("{$root}/updates.xml");
|
||||
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) {
|
||||
addResult('updates.xml version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml");
|
||||
}
|
||||
}
|
||||
} elseif ($platform === 'dolibarr') {
|
||||
$modFile = null;
|
||||
foreach (['src', 'htdocs'] as $sd) {
|
||||
$pattern = "{$root}/{$sd}/mod*.class.php";
|
||||
$matches = glob($pattern);
|
||||
if (!empty($matches)) { $modFile = $matches[0]; break; }
|
||||
}
|
||||
if ($modFile === null) {
|
||||
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||
} else {
|
||||
$mc = file_get_contents($modFile);
|
||||
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
|
||||
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
|
||||
}
|
||||
}
|
||||
$modFile = null;
|
||||
foreach (['src', 'htdocs'] as $sd) {
|
||||
$pattern = "{$root}/{$sd}/mod*.class.php";
|
||||
$matches = glob($pattern);
|
||||
if (!empty($matches)) {
|
||||
$modFile = $matches[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($modFile === null) {
|
||||
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||
} else {
|
||||
$mc = file_get_contents($modFile);
|
||||
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
|
||||
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. composer.json version (if present)
|
||||
if (file_exists("{$root}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||
if (isset($composer['version'])) {
|
||||
if ($composer['version'] === $version) {
|
||||
addResult('composer.json version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`");
|
||||
}
|
||||
}
|
||||
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||
if (isset($composer['version'])) {
|
||||
if ($composer['version'] === $version) {
|
||||
addResult('composer.json version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "### Pre-Release Validation\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"validation_pass={$pass}",
|
||||
"validation_fail={$fail}",
|
||||
"validation_warn={$warn}",
|
||||
"validation_platform={$platform}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/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/theme_lint.php
|
||||
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
|
||||
*
|
||||
* Usage:
|
||||
* php theme_lint.php --path /repo
|
||||
* php theme_lint.php --path /repo --max-image-kb 500
|
||||
* php theme_lint.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --max-image-kb Maximum image file size in KB (default: 500)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
* --strict Exit 1 on any warning (default: only on errors)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$maxImageKb = 500;
|
||||
$ghOutput = false;
|
||||
$strict = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
if ($arg === '--strict') $strict = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
// ── Find source directory ───────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
// ── Check 1: CSS syntax validation ──────────────────────────────────────
|
||||
echo "--- CSS Syntax ---\n";
|
||||
$cssFiles = findFiles($srcDir, '*.css');
|
||||
$cssMinFiles = findFiles($srcDir, '*.min.css');
|
||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||
|
||||
if (empty($cssToCheck)) {
|
||||
echo " No CSS files to check\n";
|
||||
} else {
|
||||
foreach ($cssToCheck as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
// Check for unmatched braces
|
||||
$openBraces = substr_count($content, '{');
|
||||
$closeBraces = substr_count($content, '}');
|
||||
if ($openBraces !== $closeBraces) {
|
||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||
$errors++;
|
||||
}
|
||||
|
||||
// Check for empty rules
|
||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||
$count = count($m[0]);
|
||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||
$warnings++;
|
||||
}
|
||||
|
||||
// Check for !important abuse (more than 10 in one file)
|
||||
$importantCount = substr_count($content, '!important');
|
||||
if ($importantCount > 10) {
|
||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors === 0) {
|
||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Image file sizes ───────────────────────────────────────────
|
||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||
$images = [];
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles($srcDir, $ext));
|
||||
}
|
||||
// Also check root images/ directory
|
||||
if (is_dir("{$root}/images")) {
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles("{$root}/images", $ext));
|
||||
}
|
||||
}
|
||||
|
||||
$oversized = 0;
|
||||
$totalSize = 0;
|
||||
foreach ($images as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$sizeKb = round($size / 1024);
|
||||
|
||||
if ($sizeKb > $maxImageKb) {
|
||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||
$oversized++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||
if ($oversized > 0) {
|
||||
echo ", {$oversized} oversized";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
|
||||
echo "\n--- Hardcoded URLs ---\n";
|
||||
$codeFiles = array_merge(
|
||||
findFiles($srcDir, '*.css'),
|
||||
findFiles($srcDir, '*.js')
|
||||
);
|
||||
// Exclude minified files
|
||||
$codeFiles = array_filter($codeFiles, function($f) {
|
||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||
});
|
||||
|
||||
$urlPatterns = [
|
||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||
'/https?:\/\/localhost/' => 'localhost reference',
|
||||
];
|
||||
|
||||
$urlIssues = 0;
|
||||
foreach ($codeFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
foreach ($urlPatterns as $pattern => $desc) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
$count = count($matches[0]);
|
||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||
$urlIssues++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($urlIssues === 0) {
|
||||
echo " OK: No hardcoded URLs found\n";
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Errors: {$errors}\n";
|
||||
echo "Warnings: {$warnings}\n";
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
if ($strict && $warnings > 0) {
|
||||
exit(1);
|
||||
}
|
||||
exit(0);
|
||||
|
||||
// ── Helper: recursively find files matching a glob pattern ──────────────
|
||||
function findFiles(string $dir, string $pattern): array
|
||||
{
|
||||
$results = [];
|
||||
if (!is_dir($dir)) return $results;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (fnmatch($pattern, $file->getFilename())) {
|
||||
$results[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
+317
-181
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -42,274 +43,408 @@ $outputFile = null;
|
||||
$githubOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
if ($arg === '--sha' && isset($argv[$i + 1])) $sha = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) $outputFile = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $githubOutput = true;
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) {
|
||||
$stability = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--sha' && isset($argv[$i + 1])) {
|
||||
$sha = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) {
|
||||
$giteaUrl = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) {
|
||||
$org = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repo = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) {
|
||||
$outputFile = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
|
||||
exit(1);
|
||||
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- Read platform from .mokogitea/manifest.xml --------------------------------
|
||||
$detectedPlatform = 'joomla'; // default for backward compat
|
||||
$detectedName = $repo;
|
||||
$detectedPackageType = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||
if ($mokoXml !== false) {
|
||||
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||
if ($rawPlatform !== '') {
|
||||
$detectedPlatform = match ($rawPlatform) {
|
||||
'waas-component' => 'joomla',
|
||||
'crm-module' => 'dolibarr',
|
||||
default => $rawPlatform,
|
||||
};
|
||||
}
|
||||
$detectedName = (string)($mokoXml->identity->name ?? $repo);
|
||||
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
|
||||
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root
|
||||
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
$searchDirs = ["{$root}/src", "{$root}"];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$searchDirs = ["{$root}/src", "{$root}"];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||
exit(1);
|
||||
if ($manifest === null && $detectedPlatform === 'joomla') {
|
||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
// Extract fields via regex (more portable than SimpleXML for malformed manifests)
|
||||
$extName = '';
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
|
||||
|
||||
$extType = '';
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
|
||||
|
||||
$extElement = '';
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement)) {
|
||||
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||
} else {
|
||||
$extElement = $fname;
|
||||
}
|
||||
}
|
||||
|
||||
$extClient = '';
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
|
||||
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
|
||||
|
||||
$targetPlatform = '';
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />';
|
||||
}
|
||||
|
||||
$phpMinimum = '';
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
|
||||
|
||||
if ($manifest !== null) {
|
||||
// Joomla manifest found — parse extension metadata from it
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
|
||||
$extName = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||
} else {
|
||||
$extElement = $fname;
|
||||
}
|
||||
}
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
|
||||
$extClient = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
|
||||
$targetPlatform = $m[1];
|
||||
}
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||
}
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
||||
$phpMinimum = $m[1];
|
||||
}
|
||||
} else {
|
||||
// Non-Joomla platform — derive metadata from .mokogitea/manifest.xml
|
||||
$extName = $detectedName ?: ($repo ?: basename($root));
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
|
||||
$extType = $detectedPackageType ?: 'generic';
|
||||
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
|
||||
}
|
||||
|
||||
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
|
||||
if (preg_match('/^[A-Z_]+$/', $extName)) {
|
||||
$iniFiles = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
|
||||
$iniFiles[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
foreach ($iniFiles as $ini) {
|
||||
$content = file_get_contents($ini);
|
||||
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
|
||||
$extName = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$iniFiles = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
|
||||
$iniFiles[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
foreach ($iniFiles as $ini) {
|
||||
$content = file_get_contents($ini);
|
||||
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
|
||||
$extName = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if (empty($extName)) $extName = $repo ?: basename($root);
|
||||
if (empty($extType)) $extType = 'component';
|
||||
if (empty($extName)) {
|
||||
$extName = $repo ?: basename($root);
|
||||
}
|
||||
if (empty($extType)) {
|
||||
$extType = 'component';
|
||||
}
|
||||
|
||||
// -- Build type prefix --------------------------------------------------------
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break;
|
||||
case 'module': $typePrefix = 'mod_'; break;
|
||||
case 'component': $typePrefix = 'com_'; break;
|
||||
case 'template': $typePrefix = 'tpl_'; break;
|
||||
case 'library': $typePrefix = 'lib_'; break;
|
||||
case 'package': $typePrefix = 'pkg_'; break;
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"ext_element={$extElement}",
|
||||
"ext_name={$extName}",
|
||||
"ext_type={$extType}",
|
||||
"ext_folder={$extFolder}",
|
||||
"type_prefix={$typePrefix}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) echo "{$line}\n";
|
||||
}
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"ext_element={$extElement}",
|
||||
"ext_name={$extName}",
|
||||
"ext_type={$extType}",
|
||||
"ext_folder={$extFolder}",
|
||||
"type_prefix={$typePrefix}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
];
|
||||
|
||||
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
];
|
||||
|
||||
// Gitea release tag names (used in download/info URLs)
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
];
|
||||
|
||||
// -- Build update entries -----------------------------------------------------
|
||||
$releaseTag = $stabilityTagMap[$stability] ?? $stability;
|
||||
|
||||
// For the primary entry: apply suffix if not stable
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
|
||||
$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip";
|
||||
$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}";
|
||||
|
||||
// Build client tag
|
||||
// Build client tag — Joomla requires <client>site</client> to match updates
|
||||
// to installed extensions. Without it, extension_id=0 in #__updates.
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} elseif ($extType === 'module' || $extType === 'plugin') {
|
||||
$clientTag = ' <client>site</client>';
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} else {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
// Build folder tag
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
|
||||
// PHP minimum tag
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
|
||||
// SHA tag
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single <update> entry for a given stability tag
|
||||
*/
|
||||
function buildEntry(
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $extName,
|
||||
string $extElement,
|
||||
string $extType,
|
||||
string $clientTag,
|
||||
string $folderTag,
|
||||
string $infoUrl,
|
||||
string $targetPlatform,
|
||||
string $phpTag,
|
||||
string $shaTag
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $extName,
|
||||
string $extElement,
|
||||
string $extType,
|
||||
string $clientTag,
|
||||
string $folderTag,
|
||||
string $infoUrl,
|
||||
string $targetPlatform,
|
||||
string $phpTag,
|
||||
string $shaTag
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = ' <update>';
|
||||
$lines[] = " <name>{$extName}</name>";
|
||||
$lines[] = " <description>{$extName} update</description>";
|
||||
$lines[] = " <element>{$extElement}</element>";
|
||||
$lines[] = " <type>{$extType}</type>";
|
||||
$lines[] = " <version>{$entryVersion}</version>";
|
||||
if (!empty($clientTag)) $lines[] = $clientTag;
|
||||
if (!empty($folderTag)) $lines[] = $folderTag;
|
||||
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
|
||||
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
|
||||
$lines[] = ' </downloads>';
|
||||
if (!empty($shaTag)) $lines[] = $shaTag;
|
||||
$lines[] = " {$targetPlatform}";
|
||||
if (!empty($phpTag)) $lines[] = $phpTag;
|
||||
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
|
||||
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
|
||||
$lines[] = ' </update>';
|
||||
return implode("\n", $lines);
|
||||
$lines = [];
|
||||
$lines[] = ' <update>';
|
||||
$lines[] = " <name>{$extName}</name>";
|
||||
$lines[] = " <description>{$extName} update</description>";
|
||||
// Element in updates.xml must match what Joomla stores in #__extensions
|
||||
// For packages: pkg_elementname. For plugins: elementname (folder handles grouping).
|
||||
$dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement;
|
||||
$lines[] = " <element>{$dbElement}</element>";
|
||||
$lines[] = " <type>{$extType}</type>";
|
||||
$lines[] = " <version>{$entryVersion}</version>";
|
||||
if (!empty($clientTag)) {
|
||||
$lines[] = $clientTag;
|
||||
}
|
||||
if (!empty($folderTag)) {
|
||||
$lines[] = $folderTag;
|
||||
}
|
||||
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
|
||||
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
|
||||
$lines[] = ' </downloads>';
|
||||
if (!empty($shaTag)) {
|
||||
$lines[] = $shaTag;
|
||||
}
|
||||
$lines[] = " {$targetPlatform}";
|
||||
if (!empty($phpTag)) {
|
||||
$lines[] = $phpTag;
|
||||
}
|
||||
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
|
||||
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
|
||||
$lines[] = ' </update>';
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
// -- Determine which channels to write ----------------------------------------
|
||||
// Stable cascades to all channels; pre-releases only write their level and below
|
||||
// Each channel gets its own suffixed version:
|
||||
// development -> 04.01.00-dev
|
||||
// alpha -> 04.01.00-alpha
|
||||
// beta -> 04.01.00-beta
|
||||
// rc -> 04.01.00-rc
|
||||
// stable -> 04.01.00
|
||||
// Stable cascades to all channels; pre-releases cascade down to lower channels.
|
||||
// Each channel entry represents "latest release available at this stability or higher".
|
||||
// When stable releases, ALL channels point to stable (it's the newest for everyone).
|
||||
// When RC releases, rc/beta/alpha/dev point to RC; stable is preserved.
|
||||
// When dev releases, only dev is updated; everything else is preserved.
|
||||
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
|
||||
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
|
||||
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
|
||||
if ($stabilityIndex === false) {
|
||||
$stabilityIndex = 4; // default to stable
|
||||
}
|
||||
|
||||
// Write entries for this stability and all below it
|
||||
// Write entries for the current channel AND all lower channels (cascade down)
|
||||
// All cascaded entries point to the CURRENT release (the highest stability being built)
|
||||
$entries = [];
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$channelName = $allChannels[$i];
|
||||
$channelSuffix = $stabilitySuffixMap[$channelName] ?? '';
|
||||
$channelVersion = $version . $channelSuffix;
|
||||
$channelTag = $stabilityTagMap[$channelName] ?? $channelName;
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}";
|
||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$channelName,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag
|
||||
);
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$channelName = $allChannels[$i];
|
||||
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
|
||||
// Only attach SHA to the primary channel entry
|
||||
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$entrySha
|
||||
);
|
||||
}
|
||||
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
$preservedEntries = [];
|
||||
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
// Joomla tags we're writing — don't preserve these
|
||||
$writtenChannels = [];
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
|
||||
}
|
||||
// Also match legacy/alternate tag names (e.g. 'development' = 'dev')
|
||||
$writtenChannels[] = 'development'; // alias for 'dev'
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
$existingTag = '';
|
||||
if (isset($existingUpdate->tags->tag)) {
|
||||
$existingTag = (string) $existingUpdate->tags->tag;
|
||||
}
|
||||
// Keep entries for channels we're NOT overwriting
|
||||
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
|
||||
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write updates.xml --------------------------------------------------------
|
||||
@@ -323,7 +458,8 @@ $output = <<<XML
|
||||
|
||||
<updates>
|
||||
XML;
|
||||
$output .= "\n" . implode("\n", $entries) . "\n</updates>\n";
|
||||
$allEntries = array_merge($preservedEntries, $entries);
|
||||
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
||||
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
file_put_contents($dest, $output);
|
||||
|
||||
+150
-18
@@ -9,7 +9,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump.php
|
||||
* BRIEF: Auto-increment patch version in README.md — outputs old → new
|
||||
* BRIEF: Auto-increment version — manifest.xml is canonical, cascades to all XML and MD files
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -22,21 +22,76 @@ foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--major') $type = 'major';
|
||||
}
|
||||
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (!file_exists($readme)) {
|
||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- 1. Read version from .mokogitea/manifest.xml (canonical) --
|
||||
$mokoVersion = null;
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
$mokoContent = '';
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoContent = file_get_contents($mokoManifest);
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $mokoContent, $m)) {
|
||||
$mokoVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 2. Fallback: README.md --
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
$readmeContent = '';
|
||||
if (file_exists($readme)) {
|
||||
$readmeContent = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Use the highest version as base --
|
||||
$baseVersion = null;
|
||||
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
||||
foreach ($candidates as $v) {
|
||||
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
|
||||
$baseVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseVersion === null) {
|
||||
fwrite(STDERR, "No version found in manifest.xml, README.md, or Joomla XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$content = file_get_contents($readme);
|
||||
if (!preg_match('/^(\s*VERSION:\s*)(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
|
||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
||||
// -- Parse and bump --
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
|
||||
fwrite(STDERR, "Invalid version format: {$baseVersion}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$m[2];
|
||||
$minor = (int)$m[3];
|
||||
$patch = (int)$m[4];
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
switch ($type) {
|
||||
@@ -50,13 +105,90 @@ switch ($type) {
|
||||
}
|
||||
|
||||
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
$updated = preg_replace(
|
||||
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $new,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
file_put_contents($readme, $updated);
|
||||
echo "{$old} → {$new}\n";
|
||||
// -- Update .mokogitea/manifest.xml (canonical target) --
|
||||
if (file_exists($mokoManifest) && !empty($mokoContent)) {
|
||||
$updated = preg_replace(
|
||||
'|<version>\d{2}\.\d{2}\.\d{2}</version>|',
|
||||
"<version>{$new}</version>",
|
||||
$mokoContent,
|
||||
1
|
||||
);
|
||||
file_put_contents($mokoManifest, $updated);
|
||||
}
|
||||
|
||||
// -- Update README.md --
|
||||
if (file_exists($readme) && !empty($readmeContent)) {
|
||||
$updated = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $new,
|
||||
$readmeContent,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $updated);
|
||||
}
|
||||
|
||||
// -- Cascade to ALL Joomla extension XML manifests --
|
||||
$xmlPatterns = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
$updatedFiles = [];
|
||||
foreach ($xmlPatterns as $pattern) {
|
||||
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
// Only update files that have an <extension> tag (Joomla manifests)
|
||||
if (strpos($content, '<extension') === false) {
|
||||
continue;
|
||||
}
|
||||
$newContent = preg_replace(
|
||||
'|<version>\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?</version>|',
|
||||
"<version>{$new}</version>",
|
||||
$content
|
||||
);
|
||||
if ($newContent !== $content) {
|
||||
file_put_contents($xmlFile, $newContent);
|
||||
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updatedFiles)) {
|
||||
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
|
||||
}
|
||||
|
||||
// -- Update package.json (Node.js / MCP) --
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgContent = file_get_contents($packageJsonFile);
|
||||
$updatedPkg = preg_replace(
|
||||
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(")/m',
|
||||
'${1}' . $new . '${2}',
|
||||
$pkgContent
|
||||
);
|
||||
if ($updatedPkg !== $pkgContent) {
|
||||
file_put_contents($packageJsonFile, $updatedPkg);
|
||||
fwrite(STDERR, "Updated package.json\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Update pyproject.toml (Python) --
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
$updatedPy = preg_replace(
|
||||
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(")/m',
|
||||
'${1}' . $new . '${2}',
|
||||
$pyContent
|
||||
);
|
||||
if ($updatedPy !== $pyContent) {
|
||||
file_put_contents($pyprojectFile, $updatedPy);
|
||||
fwrite(STDERR, "Updated pyproject.toml\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo "{$old} -> {$new}\n";
|
||||
exit(0);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/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/version_bump_remote.php
|
||||
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump patch --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --no-changelog --token TOKEN --api-base URL
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads current version from local manifest)
|
||||
* --branch Target branch to bump (required, e.g. dev)
|
||||
* --bump Bump type: patch | minor | major (default: minor)
|
||||
* --token Gitea API token (or GA_TOKEN env var)
|
||||
* --api-base Gitea API base URL for the repo
|
||||
* --no-changelog Skip CHANGELOG.md bump
|
||||
* --repo Repository path (owner/repo) for API base construction
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$branch = null;
|
||||
$bumpType = 'minor';
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$noChangelog = false;
|
||||
$repo = null;
|
||||
$giteaUrl = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--no-changelog') $noChangelog = true;
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
|
||||
if ($apiBase === null && $repo !== null) {
|
||||
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
|
||||
}
|
||||
|
||||
if ($branch === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]\n");
|
||||
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read current version from local manifest ────────────────────────────
|
||||
$version = null;
|
||||
$manifestFile = null;
|
||||
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||
$version = $m[1];
|
||||
$manifestFile = basename($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compute next version ────────────────────────────────────────────────
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
||||
fwrite(STDERR, "Invalid version format: {$version}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
|
||||
switch ($bumpType) {
|
||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor': $minor++; $patch = 0; break;
|
||||
default: $patch++; break;
|
||||
}
|
||||
|
||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||
|
||||
// ── Helper: Gitea API request ───────────────────────────────────────────
|
||||
function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400 || $response === false) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
// ── Helper: Update a file on a remote branch ────────────────────────────
|
||||
function updateRemoteFile(
|
||||
string $apiBase,
|
||||
string $token,
|
||||
string $filePath,
|
||||
string $branch,
|
||||
callable $transform,
|
||||
string $commitMessage
|
||||
): bool {
|
||||
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}";
|
||||
$file = giteaApi('GET', $url, $token);
|
||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = base64_decode($file['content']);
|
||||
$newContent = $transform($content);
|
||||
|
||||
if ($newContent === $content) {
|
||||
fwrite(STDERR, " {$filePath}: no changes needed\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => base64_encode($newContent),
|
||||
'sha' => $file['sha'],
|
||||
'message' => $commitMessage,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||
if ($result === null) {
|
||||
fwrite(STDERR, " {$filePath}: failed to update\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
echo " {$filePath}: updated on {$branch}\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Update manifest XML on the remote branch ────────────────────────────
|
||||
$manifestPaths = [];
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "src/{$manifestFile}";
|
||||
}
|
||||
$manifestPaths = array_merge($manifestPaths, [
|
||||
'src/templateDetails.xml',
|
||||
'src/manifest.xml',
|
||||
]);
|
||||
|
||||
$manifestUpdated = false;
|
||||
foreach ($manifestPaths as $mPath) {
|
||||
$result = updateRemoteFile(
|
||||
$apiBase, $token, $mPath, $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
return str_replace(
|
||||
"<version>{$version}</version>",
|
||||
"<version>{$nextVersion}</version>",
|
||||
$content
|
||||
);
|
||||
},
|
||||
"chore(version): bump {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
if ($result) {
|
||||
$manifestUpdated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$manifestUpdated) {
|
||||
fwrite(STDERR, "WARNING: could not update manifest on {$branch}\n");
|
||||
}
|
||||
|
||||
// ── Update CHANGELOG.md on the remote branch ────────────────────────────
|
||||
if (!$noChangelog) {
|
||||
updateRemoteFile(
|
||||
$apiBase, $token, 'CHANGELOG.md', $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||
|
||||
if (strpos($content, '[Unreleased]') === false
|
||||
&& strpos($content, "## [{$nextVersion}]") === false
|
||||
) {
|
||||
$marker = "## [{$version}]";
|
||||
if (strpos($content, $marker) !== false) {
|
||||
$unreleased = "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n";
|
||||
$content = str_replace($marker, $unreleased . $marker, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
},
|
||||
"chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/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/version_check.php
|
||||
* VERSION: 05.00.00
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*
|
||||
* Usage:
|
||||
* php version_check.php --path /repo
|
||||
* php version_check.php --path /repo --strict # exit 1 on mismatch
|
||||
* php version_check.php --path /repo --fix # fix mismatches to highest version
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$strict = false;
|
||||
$fix = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--strict') $strict = true;
|
||||
if ($arg === '--fix') $fix = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$versions = [];
|
||||
|
||||
// ── Read README.md version ───────────────────────────────────────────────────
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['README.md'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read manifest XML versions ───────────────────────────────────────────────
|
||||
$xmlGlobs = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
foreach ($xmlGlobs as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
// Skip updates.xml
|
||||
if (basename($file) === 'updates.xml') continue;
|
||||
|
||||
$xmlContent = file_get_contents($file);
|
||||
if (strpos($xmlContent, '<extension') === false) continue;
|
||||
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
$versions[$relPath] = $xm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
fwrite(STDERR, "No version sources found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compare versions ─────────────────────────────────────────────────────────
|
||||
$uniqueVersions = array_unique(array_values($versions));
|
||||
$highestVersion = '00.00.00';
|
||||
foreach ($versions as $v) {
|
||||
if (version_compare($v, $highestVersion, '>')) {
|
||||
$highestVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== Version Consistency Check ===\n";
|
||||
foreach ($versions as $source => $ver) {
|
||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||
if ($status === 'MISMATCH') $errors++;
|
||||
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||
}
|
||||
|
||||
if (count($uniqueVersions) === 1) {
|
||||
echo "\nAll {$ver} — consistent.\n";
|
||||
} else {
|
||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||
|
||||
if ($fix) {
|
||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||
|
||||
// Fix README.md
|
||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||
$content = file_get_contents($readme);
|
||||
$content = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $highestVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $content);
|
||||
echo " Fixed: README.md -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix XML manifests
|
||||
foreach ($versions as $source => $ver) {
|
||||
if ($source === 'README.md') continue;
|
||||
if ($ver === $highestVersion) continue;
|
||||
|
||||
$file = "{$root}/{$source}";
|
||||
if (!file_exists($file)) continue;
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$content = preg_replace(
|
||||
'|<version>[^<]*</version>|',
|
||||
"<version>{$highestVersion}</version>",
|
||||
$content
|
||||
);
|
||||
file_put_contents($file, $content);
|
||||
echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($strict && $errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
+115
-10
@@ -9,7 +9,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_read.php
|
||||
* BRIEF: Read VERSION from README.md — outputs just the version string
|
||||
* BRIEF: Read version — manifest.xml is canonical, falls back to README.md and Joomla XML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -21,17 +21,122 @@ foreach ($argv as $i => $arg) {
|
||||
}
|
||||
}
|
||||
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (!file_exists($readme)) {
|
||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
||||
exit(1);
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
|
||||
$mokoVersion = null;
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$v = (string)($xml->identity->version ?? '');
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $v)) {
|
||||
$mokoVersion = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
echo $m[1] . "\n";
|
||||
// If manifest.xml has a version, that is authoritative
|
||||
if ($mokoVersion !== null) {
|
||||
echo $mokoVersion . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
||||
exit(1);
|
||||
// -- 2. Fallback: README.md --
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4. Fallback: package.json (Node.js / MCP) --
|
||||
$packageJsonVersion = null;
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgData = json_decode(file_get_contents($packageJsonFile), true);
|
||||
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
|
||||
$packageJsonVersion = $pkgData['version'];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 5. Fallback: pyproject.toml (Python) --
|
||||
$pyprojectVersion = null;
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
|
||||
$pyprojectVersion = $pm[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- Output the higher version --
|
||||
$candidates = array_filter([
|
||||
$readmeVersion,
|
||||
$manifestVersion,
|
||||
$packageJsonVersion,
|
||||
$pyprojectVersion,
|
||||
]);
|
||||
|
||||
$version = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($version === null || version_compare($candidate, $version, '>')) {
|
||||
$version = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Backfill: if manifest.xml exists but lacks <version>, insert it --
|
||||
if (file_exists($mokoManifest)) {
|
||||
$content = file_get_contents($mokoManifest);
|
||||
if (!preg_match('|<version>\d{2}\.\d{2}\.\d{2}</version>|', $content)) {
|
||||
if (strpos($content, '<license') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(\s*<license)|',
|
||||
"\n <version>{$version}</version>\$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
} elseif (strpos($content, '</identity>') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(</identity>)|',
|
||||
" <version>{$version}</version>\n \$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
}
|
||||
file_put_contents($mokoManifest, $content);
|
||||
fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo $version . "\n";
|
||||
exit(0);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/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/version_reset_dev.php
|
||||
* BRIEF: Reset platform version to 'development' on a branch via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --branch dev
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --platform dolibarr
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --path /repo/root
|
||||
*
|
||||
* This replaces the inline curl+python3+sed block previously used in
|
||||
* auto-release.yml to reset Dolibarr's $this->version on the dev branch
|
||||
* after a stable release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'dev';
|
||||
$platform = null;
|
||||
$path = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = rtrim($argv[$i + 1], '/');
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) {
|
||||
$platform = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--help' || $arg === '-h') {
|
||||
printUsage();
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$envToken = getenv('GA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
if ($token === null) {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
if ($token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Error: --token and --api-base are required.\n\n");
|
||||
printUsage();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
if ($platform === null && $path !== null) {
|
||||
$platform = detectPlatform($path);
|
||||
if ($platform !== null) {
|
||||
echo "Detected platform: {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($platform === null) {
|
||||
fwrite(STDERR, "Error: could not determine platform. Use --platform or --path.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Dispatch by platform ─────────────────────────────────────────────────────
|
||||
|
||||
$changed = 0;
|
||||
|
||||
if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
|
||||
$changed = resetDolibarrVersion($apiBase, $token, $branch);
|
||||
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
|
||||
echo "Joomla version reset is not yet implemented — skipping.\n";
|
||||
} else {
|
||||
echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
|
||||
}
|
||||
|
||||
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
|
||||
exit(0);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Helper functions
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Print usage information to stdout.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function printUsage(): void
|
||||
{
|
||||
echo <<<'USAGE'
|
||||
Reset platform version to 'development' on a branch via Gitea API.
|
||||
|
||||
Usage:
|
||||
php version_reset_dev.php --token TOKEN --api-base URL [options]
|
||||
|
||||
Required:
|
||||
--token TOKEN Gitea API token (also reads GA_TOKEN / GITEA_TOKEN env)
|
||||
--api-base URL Gitea API base URL for the repo
|
||||
e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo
|
||||
|
||||
Options:
|
||||
--branch BRANCH Target branch (default: dev)
|
||||
--platform TYPE Platform type: dolibarr, crm-module, joomla, waas-component
|
||||
--path DIR Repo root for auto-detecting platform from manifest.xml
|
||||
--help Show this help
|
||||
|
||||
USAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the platform type from a repo's .mokogitea/manifest.xml file.
|
||||
*
|
||||
* @param string $repoPath Path to the repository root
|
||||
* @return string|null The detected platform, or null if detection fails
|
||||
*/
|
||||
function detectPlatform(string $repoPath): ?string
|
||||
{
|
||||
$root = realpath($repoPath) ?: $repoPath;
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestXml)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($manifestXml);
|
||||
if ($xml === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($xml->governance->platform)) {
|
||||
$platform = (string) $xml->governance->platform;
|
||||
if ($platform !== '') {
|
||||
return $platform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a Gitea API call and return the decoded JSON response.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $method HTTP method (GET, PUT, POST, DELETE)
|
||||
* @param string|null $body JSON request body, or null for bodiless requests
|
||||
* @return array<string, mixed>|null Decoded JSON response, or null on failure
|
||||
*/
|
||||
function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
fwrite(STDERR, "Error: curl_init() failed for {$url}\n");
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/json',
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Dolibarr module version to 'development' on the target branch.
|
||||
*
|
||||
* Searches the repository tree for mod*.class.php files that contain
|
||||
* `extends DolibarrModules`, then replaces `$this->version = '...'`
|
||||
* with `$this->version = 'development'` via the Gitea file contents API.
|
||||
*
|
||||
* @param string $apiBase Gitea API base URL for the repo
|
||||
* @param string $token Gitea API token
|
||||
* @param string $branch Target branch name
|
||||
* @return int Number of files modified
|
||||
*/
|
||||
function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
|
||||
{
|
||||
// Search the repo tree for mod*.class.php files
|
||||
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
|
||||
$tree = giteaApiCall($treeUrl, $token);
|
||||
|
||||
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
|
||||
fwrite(STDERR, "Error: could not read repository tree for branch '{$branch}'.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find candidate files: mod*.class.php anywhere in the tree
|
||||
$candidates = [];
|
||||
foreach ($tree['tree'] as $entry) {
|
||||
if (!isset($entry['path']) || !is_string($entry['path'])) {
|
||||
continue;
|
||||
}
|
||||
$basename = basename($entry['path']);
|
||||
if (preg_match('/^mod[A-Za-z0-9_]+\.class\.php$/', $basename)) {
|
||||
$candidates[] = $entry['path'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
echo "No mod*.class.php files found on branch '{$branch}'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($candidates as $filePath) {
|
||||
// GET file contents via API
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
|
||||
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
|
||||
$fileData = giteaApiCall($fileUrl, $token);
|
||||
|
||||
if ($fileData === null || !isset($fileData['content'])) {
|
||||
echo "Skipping {$filePath}: could not fetch contents.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
$rawContent = is_string($fileData['content']) ? $fileData['content'] : '';
|
||||
$content = base64_decode($rawContent, true);
|
||||
if ($content === false) {
|
||||
echo "Skipping {$filePath}: could not decode content.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify this file extends DolibarrModules
|
||||
if (!str_contains($content, 'extends DolibarrModules')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace $this->version = '...' with $this->version = 'development'
|
||||
$updated = preg_replace(
|
||||
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||
"\${1}'development'",
|
||||
$content
|
||||
);
|
||||
|
||||
if ($updated === null || $updated === $content) {
|
||||
echo "Skipping {$filePath}: no version change needed.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// PUT updated content back via API
|
||||
$sha = $fileData['sha'] ?? '';
|
||||
$putBody = json_encode([
|
||||
'content' => base64_encode($updated),
|
||||
'message' => 'chore(version): reset dev version [skip ci]',
|
||||
'branch' => $branch,
|
||||
'sha' => $sha,
|
||||
]);
|
||||
|
||||
$putUrl = "{$apiBase}/contents/{$encodedPath}";
|
||||
$result = giteaApiCall($putUrl, $token, 'PUT', $putBody);
|
||||
|
||||
if ($result !== null) {
|
||||
echo "Reset: {$filePath} -> \$this->version = 'development'\n";
|
||||
$changed++;
|
||||
} else {
|
||||
fwrite(STDERR, "Error: failed to update {$filePath} on branch '{$branch}'.\n");
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
@@ -131,9 +131,17 @@ if ($platform === 'crm-module') {
|
||||
}
|
||||
}
|
||||
|
||||
// Joomla: <version> in XML manifests
|
||||
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
||||
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||
foreach (glob("{$root}/src/*.xml") ?: glob("{$root}/*.xml") ?: [] as $file) {
|
||||
$xmlFiles = array_merge(
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
if (empty($xmlFiles)) {
|
||||
$xmlFiles = glob("{$root}/*.xml") ?: [];
|
||||
}
|
||||
foreach ($xmlFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if (!str_contains($content, '<extension')) continue;
|
||||
$updated = preg_replace(
|
||||
@@ -143,7 +151,8 @@ if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||
);
|
||||
if ($updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
echo "Joomla: " . basename($file) . " → {$version}\n";
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
echo "Joomla: {$relPath} → {$version}\n";
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "mokoconsulting-tech/enterprise",
|
||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
||||
"type": "library",
|
||||
"version": "05.00.01",
|
||||
"version": "09.01.00",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?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 v09.01.00
|
||||
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"
|
||||
version="09.01.00">
|
||||
|
||||
<!-- 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>
|
||||
+569
-564
File diff suppressed because it is too large
Load Diff
+246
-245
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -24,275 +25,275 @@ declare(strict_types=1);
|
||||
*/
|
||||
class Common
|
||||
{
|
||||
/**
|
||||
* Fallback version used when README.md cannot be parsed.
|
||||
* NOTE: Kept in sync with _FALLBACK_VERSION in the original common.sh.
|
||||
* Update this constant when the minimum supported baseline version changes.
|
||||
*/
|
||||
const FALLBACK_VERSION = '04.00.00';
|
||||
/**
|
||||
* Fallback version used when README.md cannot be parsed.
|
||||
* NOTE: Kept in sync with _FALLBACK_VERSION in the original common.sh.
|
||||
* Update this constant when the minimum supported baseline version changes.
|
||||
*/
|
||||
const FALLBACK_VERSION = '04.00.00';
|
||||
|
||||
const REPO_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API';
|
||||
const REPO_URL_GITHUB = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards';
|
||||
const COPYRIGHT = 'Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>';
|
||||
const LICENSE = 'GPL-3.0-or-later';
|
||||
const REPO_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API';
|
||||
const REPO_URL_GITHUB = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards';
|
||||
const COPYRIGHT = 'Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>';
|
||||
const LICENSE = 'GPL-3.0-or-later';
|
||||
|
||||
// Exit codes
|
||||
const EXIT_SUCCESS = 0;
|
||||
const EXIT_ERROR = 1;
|
||||
const EXIT_INVALID_ARGS = 2;
|
||||
const EXIT_NOT_FOUND = 3;
|
||||
const EXIT_PERMISSION = 4;
|
||||
// Exit codes
|
||||
const EXIT_SUCCESS = 0;
|
||||
const EXIT_ERROR = 1;
|
||||
const EXIT_INVALID_ARGS = 2;
|
||||
const EXIT_NOT_FOUND = 3;
|
||||
const EXIT_PERMISSION = 4;
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────
|
||||
// ── Logging ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Print an informational message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function info(string $message): void
|
||||
{
|
||||
echo 'ℹ️ ' . $message . "\n";
|
||||
}
|
||||
/**
|
||||
* Print an informational message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function info(string $message): void
|
||||
{
|
||||
echo 'ℹ️ ' . $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a success message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function success(string $message): void
|
||||
{
|
||||
echo '✅ ' . $message . "\n";
|
||||
}
|
||||
/**
|
||||
* Print a success message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function success(string $message): void
|
||||
{
|
||||
echo '✅ ' . $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a warning message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function warn(string $message): void
|
||||
{
|
||||
echo '⚠️ ' . $message . "\n";
|
||||
}
|
||||
/**
|
||||
* Print a warning message.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function warn(string $message): void
|
||||
{
|
||||
echo '⚠️ ' . $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an error message to STDERR.
|
||||
*
|
||||
* @param string $message Error text.
|
||||
*/
|
||||
public static function error(string $message): void
|
||||
{
|
||||
fwrite(STDERR, '❌ ' . $message . "\n");
|
||||
}
|
||||
/**
|
||||
* Print an error message to STDERR.
|
||||
*
|
||||
* @param string $message Error text.
|
||||
*/
|
||||
public static function error(string $message): void
|
||||
{
|
||||
fwrite(STDERR, '❌ ' . $message . "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a fatal error to STDERR and exit.
|
||||
*
|
||||
* @param string $message Error text.
|
||||
* @param int $exitCode One of the EXIT_* constants.
|
||||
* @return never
|
||||
*/
|
||||
public static function fatal(string $message, int $exitCode = self::EXIT_ERROR): never
|
||||
{
|
||||
fwrite(STDERR, '❌ ' . $message . "\n");
|
||||
exit($exitCode);
|
||||
}
|
||||
/**
|
||||
* Print a fatal error to STDERR and exit.
|
||||
*
|
||||
* @param string $message Error text.
|
||||
* @param int $exitCode One of the EXIT_* constants.
|
||||
* @return never
|
||||
*/
|
||||
public static function fatal(string $message, int $exitCode = self::EXIT_ERROR): never
|
||||
{
|
||||
fwrite(STDERR, '❌ ' . $message . "\n");
|
||||
exit($exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a debug message to STDERR when the DEBUG env var is set.
|
||||
*
|
||||
* @param string $message Debug text.
|
||||
*/
|
||||
public static function debug(string $message): void
|
||||
{
|
||||
if (!empty($_SERVER['DEBUG'] ?? getenv('DEBUG'))) {
|
||||
fwrite(STDERR, '🔍 ' . $message . "\n");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Print a debug message to STDERR when the DEBUG env var is set.
|
||||
*
|
||||
* @param string $message Debug text.
|
||||
*/
|
||||
public static function debug(string $message): void
|
||||
{
|
||||
if (!empty($_SERVER['DEBUG'] ?? getenv('DEBUG'))) {
|
||||
fwrite(STDERR, '🔍 ' . $message . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a plain message to stdout.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function plain(string $message): void
|
||||
{
|
||||
echo $message . "\n";
|
||||
}
|
||||
/**
|
||||
* Print a plain message to stdout.
|
||||
*
|
||||
* @param string $message Text to display.
|
||||
*/
|
||||
public static function plain(string $message): void
|
||||
{
|
||||
echo $message . "\n";
|
||||
}
|
||||
|
||||
// ── Guards ────────────────────────────────────────────────────────────────
|
||||
// ── Guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Abort if a command is not available on PATH.
|
||||
*
|
||||
* @param string $cmd Command name (e.g. 'git').
|
||||
* @param string $description Human-readable description for the error message.
|
||||
*/
|
||||
public static function requireCommand(string $cmd, string $description = ''): void
|
||||
{
|
||||
$which = trim((string) shell_exec('command -v ' . escapeshellarg($cmd) . ' 2>/dev/null'));
|
||||
if ($which === '') {
|
||||
$msg = $description !== '' ? $description : "Command required: {$cmd}";
|
||||
self::fatal($msg, self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Abort if a command is not available on PATH.
|
||||
*
|
||||
* @param string $cmd Command name (e.g. 'git').
|
||||
* @param string $description Human-readable description for the error message.
|
||||
*/
|
||||
public static function requireCommand(string $cmd, string $description = ''): void
|
||||
{
|
||||
$which = trim((string) shell_exec('command -v ' . escapeshellarg($cmd) . ' 2>/dev/null'));
|
||||
if ($which === '') {
|
||||
$msg = $description !== '' ? $description : "Command required: {$cmd}";
|
||||
self::fatal($msg, self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort if a file does not exist.
|
||||
*
|
||||
* @param string $path Absolute or relative file path.
|
||||
* @param string $description Human-readable label used in the error message.
|
||||
*/
|
||||
public static function requireFile(string $path, string $description = 'File'): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Abort if a file does not exist.
|
||||
*
|
||||
* @param string $path Absolute or relative file path.
|
||||
* @param string $description Human-readable label used in the error message.
|
||||
*/
|
||||
public static function requireFile(string $path, string $description = 'File'): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort if a directory does not exist.
|
||||
*
|
||||
* @param string $path Absolute or relative directory path.
|
||||
* @param string $description Human-readable label used in the error message.
|
||||
*/
|
||||
public static function requireDir(string $path, string $description = 'Directory'): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Abort if a directory does not exist.
|
||||
*
|
||||
* @param string $path Absolute or relative directory path.
|
||||
* @param string $description Human-readable label used in the error message.
|
||||
*/
|
||||
public static function requireDir(string $path, string $description = 'Directory'): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repository utilities ──────────────────────────────────────────────────
|
||||
// ── Repository utilities ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the absolute path to the repository root by walking up from cwd.
|
||||
*
|
||||
* @throws \RuntimeException When no .git directory is found.
|
||||
* @return string Absolute path (no trailing slash).
|
||||
*/
|
||||
public static function getRepoRoot(): string
|
||||
{
|
||||
$dir = (string) getcwd();
|
||||
while ($dir !== '/') {
|
||||
if (is_dir($dir . '/.git')) {
|
||||
return $dir;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
self::fatal('Not in a git repository', self::EXIT_ERROR);
|
||||
}
|
||||
/**
|
||||
* Return the absolute path to the repository root by walking up from cwd.
|
||||
*
|
||||
* @throws \RuntimeException When no .git directory is found.
|
||||
* @return string Absolute path (no trailing slash).
|
||||
*/
|
||||
public static function getRepoRoot(): string
|
||||
{
|
||||
$dir = (string) getcwd();
|
||||
while ($dir !== '/') {
|
||||
if (is_dir($dir . '/.git')) {
|
||||
return $dir;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
self::fatal('Not in a git repository', self::EXIT_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current git branch name (or "unknown").
|
||||
*
|
||||
* @return string Branch name.
|
||||
*/
|
||||
public static function getGitBranch(): string
|
||||
{
|
||||
$branch = trim((string) shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
return $branch !== '' ? $branch : 'unknown';
|
||||
}
|
||||
/**
|
||||
* Return the current git branch name (or "unknown").
|
||||
*
|
||||
* @return string Branch name.
|
||||
*/
|
||||
public static function getGitBranch(): string
|
||||
{
|
||||
$branch = trim((string) shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
return $branch !== '' ? $branch : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current full git commit hash (or "unknown").
|
||||
*
|
||||
* @return string Full commit SHA.
|
||||
*/
|
||||
public static function getGitCommit(): string
|
||||
{
|
||||
$hash = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null'));
|
||||
return $hash !== '' ? $hash : 'unknown';
|
||||
}
|
||||
/**
|
||||
* Return the current full git commit hash (or "unknown").
|
||||
*
|
||||
* @return string Full commit SHA.
|
||||
*/
|
||||
public static function getGitCommit(): string
|
||||
{
|
||||
$hash = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null'));
|
||||
return $hash !== '' ? $hash : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the short git commit hash (or "unknown").
|
||||
*
|
||||
* @return string Short commit SHA.
|
||||
*/
|
||||
public static function getGitCommitShort(): string
|
||||
{
|
||||
$hash = trim((string) shell_exec('git rev-parse --short HEAD 2>/dev/null'));
|
||||
return $hash !== '' ? $hash : 'unknown';
|
||||
}
|
||||
/**
|
||||
* Return the short git commit hash (or "unknown").
|
||||
*
|
||||
* @return string Short commit SHA.
|
||||
*/
|
||||
public static function getGitCommitShort(): string
|
||||
{
|
||||
$hash = trim((string) shell_exec('git rev-parse --short HEAD 2>/dev/null'));
|
||||
return $hash !== '' ? $hash : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the git working directory is clean.
|
||||
*
|
||||
* @return bool True if no uncommitted changes.
|
||||
*/
|
||||
public static function isGitClean(): bool
|
||||
{
|
||||
return trim((string) shell_exec('git status --porcelain 2>/dev/null')) === '';
|
||||
}
|
||||
/**
|
||||
* Return true when the git working directory is clean.
|
||||
*
|
||||
* @return bool True if no uncommitted changes.
|
||||
*/
|
||||
public static function isGitClean(): bool
|
||||
{
|
||||
return trim((string) shell_exec('git status --porcelain 2>/dev/null')) === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the current directory is inside a git repository.
|
||||
*
|
||||
* @return bool True if inside a git repo.
|
||||
*/
|
||||
public static function isGitRepo(): bool
|
||||
{
|
||||
exec('git rev-parse --git-dir 2>/dev/null', $out, $code);
|
||||
return $code === 0;
|
||||
}
|
||||
/**
|
||||
* Return true when the current directory is inside a git repository.
|
||||
*
|
||||
* @return bool True if inside a git repo.
|
||||
*/
|
||||
public static function isGitRepo(): bool
|
||||
{
|
||||
exec('git rev-parse --git-dir 2>/dev/null', $out, $code);
|
||||
return $code === 0;
|
||||
}
|
||||
|
||||
// ── Path utilities ────────────────────────────────────────────────────────
|
||||
// ── Path utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the path relative to the repository root, prefixed with '/'.
|
||||
*
|
||||
* @param string $absolutePath Absolute filesystem path.
|
||||
* @return string Repo-relative path starting with '/'.
|
||||
*/
|
||||
public static function getRelativePath(string $absolutePath): string
|
||||
{
|
||||
$root = self::getRepoRoot();
|
||||
$rel = str_starts_with($absolutePath, $root)
|
||||
? substr($absolutePath, strlen($root))
|
||||
: $absolutePath;
|
||||
return '/' . ltrim($rel, '/');
|
||||
}
|
||||
/**
|
||||
* Return the path relative to the repository root, prefixed with '/'.
|
||||
*
|
||||
* @param string $absolutePath Absolute filesystem path.
|
||||
* @return string Repo-relative path starting with '/'.
|
||||
*/
|
||||
public static function getRelativePath(string $absolutePath): string
|
||||
{
|
||||
$root = self::getRepoRoot();
|
||||
$rel = str_starts_with($absolutePath, $root)
|
||||
? substr($absolutePath, strlen($root))
|
||||
: $absolutePath;
|
||||
return '/' . ltrim($rel, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory (and parents) if it does not already exist.
|
||||
*
|
||||
* @param string $path Directory path to ensure.
|
||||
* @param string $description Human-readable label for log output.
|
||||
*/
|
||||
public static function ensureDir(string $path, string $description = 'Directory'): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0755, true);
|
||||
self::info("Created {$description}: {$path}");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a directory (and parents) if it does not already exist.
|
||||
*
|
||||
* @param string $path Directory path to ensure.
|
||||
* @param string $description Human-readable label for log output.
|
||||
*/
|
||||
public static function ensureDir(string $path, string $description = 'Directory'): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0755, true);
|
||||
self::info("Created {$description}: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Version helpers ───────────────────────────────────────────────────────
|
||||
// ── Version helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the VERSION from the FILE INFORMATION block in README.md.
|
||||
*
|
||||
* Searches upward from cwd for the repo root, then reads README.md.
|
||||
* Falls back to FALLBACK_VERSION when the file is absent or unparseable.
|
||||
*
|
||||
* @return string Zero-padded semver string, e.g. "04.00.04".
|
||||
*/
|
||||
public static function getVersionFromReadme(): string
|
||||
{
|
||||
try {
|
||||
$root = self::getRepoRoot();
|
||||
$readme = $root . '/README.md';
|
||||
if (!is_file($readme)) {
|
||||
return self::FALLBACK_VERSION;
|
||||
}
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', (string) $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to fallback
|
||||
}
|
||||
return self::FALLBACK_VERSION;
|
||||
}
|
||||
/**
|
||||
* Read the VERSION from the FILE INFORMATION block in README.md.
|
||||
*
|
||||
* Searches upward from cwd for the repo root, then reads README.md.
|
||||
* Falls back to FALLBACK_VERSION when the file is absent or unparseable.
|
||||
*
|
||||
* @return string Zero-padded semver string, e.g. "04.00.04".
|
||||
*/
|
||||
public static function getVersionFromReadme(): string
|
||||
{
|
||||
try {
|
||||
$root = self::getRepoRoot();
|
||||
$readme = $root . '/README.md';
|
||||
if (!is_file($readme)) {
|
||||
return self::FALLBACK_VERSION;
|
||||
}
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', (string) $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to fallback
|
||||
}
|
||||
return self::FALLBACK_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Abstract base class for project type plugins
|
||||
*
|
||||
*
|
||||
* Provides common functionality for all project type plugins
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
@@ -268,6 +268,6 @@ abstract class AbstractProjectPlugin implements ProjectPluginInterface
|
||||
$tags['plugin'] = $this->getPluginName();
|
||||
$tags['project_type'] = $this->getProjectType();
|
||||
|
||||
$this->metricsCollector->record($category, $name, $value, $tags);
|
||||
$this->metricsCollector->observe("{$category}.{$name}", (float) $value, $tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ class CircuitBreakerOpen extends RuntimeException
|
||||
* );
|
||||
* $response = $client->get('/repos/owner/repo');
|
||||
* ```
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
@@ -123,6 +125,8 @@ class ApiClient
|
||||
/** Circuit breaker last failure time */
|
||||
private ?DateTime $circuitLastFailure = null;
|
||||
|
||||
/** @var LoggerInterface|null Optional logger instance */
|
||||
|
||||
/** @var array<string, mixed> Request metrics */
|
||||
private array $metrics = [
|
||||
'total_requests' => 0,
|
||||
@@ -257,9 +261,9 @@ class ApiClient
|
||||
* @throws RateLimitExceeded
|
||||
* @throws CircuitBreakerOpen
|
||||
*/
|
||||
public function delete(string $endpoint): array
|
||||
public function delete(string $endpoint, ?array $body = null): array
|
||||
{
|
||||
return $this->request('DELETE', $endpoint);
|
||||
return $this->request('DELETE', $endpoint, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +397,7 @@ class ApiClient
|
||||
$waitTime = 3600 - ($now - $oldestTimestamp);
|
||||
|
||||
$this->metrics['rate_limit_waits']++;
|
||||
|
||||
|
||||
throw new RateLimitExceeded(
|
||||
"Rate limit of {$this->maxRequestsPerHour} requests/hour exceeded. Wait {$waitTime} seconds."
|
||||
);
|
||||
|
||||
@@ -58,6 +58,8 @@ use RuntimeException;
|
||||
* $transaction->logSecurityEvent('file_modified', ['file' => 'README.md']);
|
||||
* $transaction->end();
|
||||
* ```
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class AuditLogger
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ class CheckpointManager
|
||||
public function __construct(string $checkpointDir = '.checkpoints')
|
||||
{
|
||||
$this->checkpointDir = $checkpointDir;
|
||||
|
||||
|
||||
// Create checkpoint directory if it doesn't exist
|
||||
if (!is_dir($this->checkpointDir)) {
|
||||
if (!mkdir($this->checkpointDir, 0755, true) && !is_dir($this->checkpointDir)) {
|
||||
|
||||
+765
-725
File diff suppressed because it is too large
Load Diff
@@ -347,7 +347,7 @@ class Config
|
||||
public function getBool(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = $this->get($key, $default);
|
||||
|
||||
|
||||
// Handle string representations of booleans
|
||||
if (is_string($value)) {
|
||||
$value = strtolower($value);
|
||||
@@ -358,7 +358,7 @@ class Config
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
@@ -433,13 +433,13 @@ class Config
|
||||
public function validate(array $requiredKeys): void
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
|
||||
foreach ($requiredKeys as $key) {
|
||||
if ($this->get($key) === null) {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!empty($missing)) {
|
||||
throw new ConfigValidationError(
|
||||
'Missing required configuration keys: ' . implode(', ', $missing)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Enterprise
|
||||
* INGROUP: MokoStandards.Enterprise
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /lib/Enterprise/ConfigValidator.php
|
||||
* BRIEF: Validate project config against plugin JSON schema
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Configuration Validator
|
||||
*
|
||||
* Validates moko-platform configuration files (YAML, JSON, HCL)
|
||||
* against expected schemas and reports errors.
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class ConfigValidator
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private array $errors = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Validate config data against a JSON schema.
|
||||
*
|
||||
* @param array<string, mixed> $config Config to validate
|
||||
* @param array<string, mixed> $schema JSON Schema definition
|
||||
* @return bool True if valid
|
||||
*/
|
||||
public function validate(array $config, array $schema): bool
|
||||
{
|
||||
$this->errors = [];
|
||||
$this->warnings = [];
|
||||
|
||||
$this->validateNode($config, $schema, '');
|
||||
|
||||
return empty($this->errors);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateNode(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$type = $schema['type'] ?? null;
|
||||
|
||||
if ($type !== null && !$this->checkType($data, $type)) {
|
||||
$actual = gettype($data);
|
||||
$this->errors[] = $path === ''
|
||||
? "Root must be {$type}, got {$actual}"
|
||||
: "{$path}: expected {$type}, got {$actual}";
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'object') {
|
||||
$this->validateObject($data, $schema, $path);
|
||||
}
|
||||
|
||||
if ($type === 'array' && isset($schema['items'])) {
|
||||
$this->validateArray($data, $schema, $path);
|
||||
}
|
||||
|
||||
if (isset($schema['enum'])) {
|
||||
$this->validateEnum($data, $schema['enum'], $path);
|
||||
}
|
||||
|
||||
if ($type === 'string') {
|
||||
$this->validateString($data, $schema, $path);
|
||||
}
|
||||
|
||||
if ($type === 'integer' || $type === 'number') {
|
||||
$this->validateNumber($data, $schema, $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateObject(
|
||||
array $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$properties = $schema['properties'] ?? [];
|
||||
$required = $schema['required'] ?? [];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->errors[] = "{$fieldPath}: required field missing";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($properties as $field => $fieldSchema) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->validateNode($data[$field], $fieldSchema, $fieldPath);
|
||||
}
|
||||
|
||||
$known = array_keys($properties);
|
||||
|
||||
foreach (array_keys($data) as $field) {
|
||||
if (!in_array($field, $known, true)) {
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->warnings[] = "{$fieldPath}: unknown property";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateArray(
|
||||
array $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$itemSchema = $schema['items'];
|
||||
|
||||
foreach ($data as $i => $item) {
|
||||
$this->validateNode(
|
||||
$item,
|
||||
$itemSchema,
|
||||
"{$path}[{$i}]"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['minItems'])
|
||||
&& count($data) < $schema['minItems']
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "needs at least {$schema['minItems']} items";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param array<int, mixed> $allowed
|
||||
*/
|
||||
private function validateEnum(
|
||||
mixed $data,
|
||||
array $allowed,
|
||||
string $path
|
||||
): void {
|
||||
if (!in_array($data, $allowed, true)) {
|
||||
$values = implode(', ', $allowed);
|
||||
$label = $path ?: 'value';
|
||||
$this->errors[] = "{$label}: "
|
||||
. "'{$data}' not in [{$values}]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateString(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
if (!is_string($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['minLength'])
|
||||
&& strlen($data) < $schema['minLength']
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "too short (min {$schema['minLength']})";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['pattern'])
|
||||
&& !preg_match('/' . $schema['pattern'] . '/', $data)
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "does not match pattern {$schema['pattern']}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateNumber(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
if (!is_numeric($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($schema['minimum']) && $data < $schema['minimum']) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "below minimum {$schema['minimum']}";
|
||||
}
|
||||
|
||||
if (isset($schema['maximum']) && $data > $schema['maximum']) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "above maximum {$schema['maximum']}";
|
||||
}
|
||||
}
|
||||
|
||||
private function checkType(mixed $data, string $type): bool
|
||||
{
|
||||
return match ($type) {
|
||||
'object' => is_array($data),
|
||||
'array' => is_array($data)
|
||||
&& array_is_list($data),
|
||||
'string' => is_string($data),
|
||||
'integer' => is_int($data),
|
||||
'number' => is_int($data) || is_float($data),
|
||||
'boolean' => is_bool($data),
|
||||
'null' => is_null($data),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
+399
-396
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -42,457 +43,459 @@ namespace MokoEnterprise;
|
||||
* 'inline_content' => string — rendered template content (ready to push)
|
||||
* 'destination' => string — path in the target repository
|
||||
* 'always_overwrite' => bool — true: overwrite existing file; false: create-only
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class DefinitionParser
|
||||
{
|
||||
/** Map platform slug → definition file basename */
|
||||
private const PLATFORM_DEFINITION_MAP = [
|
||||
'crm-module' => 'crm-module.tf',
|
||||
'waas-component' => 'waas-component.tf',
|
||||
'generic-repository' => 'generic-repository.tf',
|
||||
'default-repository' => 'default-repository.tf',
|
||||
'standards' => 'standards-repository.tf',
|
||||
];
|
||||
/** Map platform slug → definition file basename */
|
||||
private const PLATFORM_DEFINITION_MAP = [
|
||||
'crm-module' => 'crm-module.tf',
|
||||
'waas-component' => 'waas-component.tf',
|
||||
'generic-repository' => 'generic-repository.tf',
|
||||
'default-repository' => 'default-repository.tf',
|
||||
'standards' => 'standards-repository.tf',
|
||||
];
|
||||
|
||||
/** Default definition used when platform has no specific file */
|
||||
private const FALLBACK_DEFINITION = 'default-repository.tf';
|
||||
/** Default definition used when platform has no specific file */
|
||||
private const FALLBACK_DEFINITION = 'default-repository.tf';
|
||||
|
||||
/** Directory containing the base definition files */
|
||||
private const DEFINITIONS_DIR = 'definitions/default';
|
||||
/** Directory containing the base definition files */
|
||||
private const DEFINITIONS_DIR = 'definitions/default';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a definition file by platform slug.
|
||||
*
|
||||
* @param string $platform e.g. 'crm-module', 'waas-component'
|
||||
* @param string $repoRoot Absolute path to the MokoStandards repository root
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parseForPlatform(string $platform, string $repoRoot): array
|
||||
{
|
||||
$basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION;
|
||||
$path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename;
|
||||
/**
|
||||
* Parse a definition file by platform slug.
|
||||
*
|
||||
* @param string $platform e.g. 'crm-module', 'waas-component'
|
||||
* @param string $repoRoot Absolute path to the MokoStandards repository root
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parseForPlatform(string $platform, string $repoRoot): array
|
||||
{
|
||||
$basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION;
|
||||
$path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename;
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION;
|
||||
if (!file_exists($fallback)) {
|
||||
return [];
|
||||
}
|
||||
$path = $fallback;
|
||||
}
|
||||
if (!file_exists($path)) {
|
||||
$fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION;
|
||||
if (!file_exists($fallback)) {
|
||||
return [];
|
||||
}
|
||||
$path = $fallback;
|
||||
}
|
||||
|
||||
return $this->parseFile($path);
|
||||
}
|
||||
return $this->parseFile($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a definition file at an explicit filesystem path.
|
||||
*
|
||||
* @param string $filePath Absolute path to the .tf definition file
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parseFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Parse a definition file at an explicit filesystem path.
|
||||
*
|
||||
* @param string $filePath Absolute path to the .tf definition file
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parseFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parse($content);
|
||||
}
|
||||
return $this->parse($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw HCL content.
|
||||
*
|
||||
* @param string $content Raw .tf file content
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parse(string $content): array
|
||||
{
|
||||
$entries = [];
|
||||
/**
|
||||
* Parse raw HCL content.
|
||||
*
|
||||
* @param string $content Raw .tf file content
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
public function parse(string $content): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
// root_files = [ { ... }, ... ]
|
||||
$rootFilesContent = $this->extractNamedArray($content, 'root_files');
|
||||
if ($rootFilesContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, ''));
|
||||
}
|
||||
// root_files = [ { ... }, ... ]
|
||||
$rootFilesContent = $this->extractNamedArray($content, 'root_files');
|
||||
if ($rootFilesContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, ''));
|
||||
}
|
||||
|
||||
// directories = [ { ... }, ... ]
|
||||
$dirsContent = $this->extractNamedArray($content, 'directories');
|
||||
if ($dirsContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseDirectories($dirsContent));
|
||||
}
|
||||
// directories = [ { ... }, ... ]
|
||||
$dirsContent = $this->extractNamedArray($content, 'directories');
|
||||
if ($dirsContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseDirectories($dirsContent));
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal parsing helpers
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal parsing helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Locate `name = [` inside $content and return the content between the
|
||||
* outermost `[` and its matching `]`, or null if not found.
|
||||
*/
|
||||
private function extractNamedArray(string $content, string $name): ?string
|
||||
{
|
||||
$pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/';
|
||||
/**
|
||||
* Locate `name = [` inside $content and return the content between the
|
||||
* outermost `[` and its matching `]`, or null if not found.
|
||||
*/
|
||||
private function extractNamedArray(string $content, string $name): ?string
|
||||
{
|
||||
$pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/';
|
||||
|
||||
// Build a mask of heredoc regions so the regex doesn't match inside them.
|
||||
// Replace heredoc content with spaces (preserving offsets) before matching.
|
||||
$masked = $content;
|
||||
$len = strlen($content);
|
||||
$i = 0;
|
||||
while ($i < $len - 1) {
|
||||
if ($content[$i] === '<' && $content[$i + 1] === '<') {
|
||||
$heredocEnd = $this->skipHeredoc($content, $i, $len);
|
||||
// Blank out the heredoc region in the masked copy
|
||||
for ($k = $i; $k < $heredocEnd && $k < $len; $k++) {
|
||||
$masked[$k] = ($content[$k] === "\n") ? "\n" : ' ';
|
||||
}
|
||||
$i = $heredocEnd;
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
// Build a mask of heredoc regions so the regex doesn't match inside them.
|
||||
// Replace heredoc content with spaces (preserving offsets) before matching.
|
||||
$masked = $content;
|
||||
$len = strlen($content);
|
||||
$i = 0;
|
||||
while ($i < $len - 1) {
|
||||
if ($content[$i] === '<' && $content[$i + 1] === '<') {
|
||||
$heredocEnd = $this->skipHeredoc($content, $i, $len);
|
||||
// Blank out the heredoc region in the masked copy
|
||||
for ($k = $i; $k < $heredocEnd && $k < $len; $k++) {
|
||||
$masked[$k] = ($content[$k] === "\n") ? "\n" : ' ';
|
||||
}
|
||||
$i = $heredocEnd;
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
|
||||
if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
// Position of the `[` at the end of the matched string — use original content
|
||||
$openPos = $match[0][1] + strlen($match[0][0]) - 1;
|
||||
return $this->extractBetweenPair($content, $openPos, '[', ']');
|
||||
}
|
||||
if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
// Position of the `[` at the end of the matched string — use original content
|
||||
$openPos = $match[0][1] + strlen($match[0][0]) - 1;
|
||||
return $this->extractBetweenPair($content, $openPos, '[', ']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at $pos (which must hold $open), walk forward counting depth
|
||||
* until the matching $close is found. Returns the content between them
|
||||
* (exclusive), or null on malformed input.
|
||||
*/
|
||||
private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string
|
||||
{
|
||||
if (!isset($content[$pos]) || $content[$pos] !== $open) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Starting at $pos (which must hold $open), walk forward counting depth
|
||||
* until the matching $close is found. Returns the content between them
|
||||
* (exclusive), or null on malformed input.
|
||||
*/
|
||||
private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string
|
||||
{
|
||||
if (!isset($content[$pos]) || $content[$pos] !== $open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$start = $pos;
|
||||
$len = strlen($content);
|
||||
$depth = 0;
|
||||
$start = $pos;
|
||||
$len = strlen($content);
|
||||
|
||||
for ($i = $pos; $i < $len; $i++) {
|
||||
// Skip heredoc regions — they contain unbalanced brackets in markdown/code
|
||||
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
|
||||
$i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments
|
||||
continue;
|
||||
}
|
||||
if ($content[$i] === $open) {
|
||||
$depth++;
|
||||
} elseif ($content[$i] === $close) {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return substr($content, $start + 1, $i - $start - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for ($i = $pos; $i < $len; $i++) {
|
||||
// Skip heredoc regions — they contain unbalanced brackets in markdown/code
|
||||
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
|
||||
$i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments
|
||||
continue;
|
||||
}
|
||||
if ($content[$i] === $open) {
|
||||
$depth++;
|
||||
} elseif ($content[$i] === $close) {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return substr($content, $start + 1, $i - $start - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // unterminated
|
||||
}
|
||||
return null; // unterminated
|
||||
}
|
||||
|
||||
/**
|
||||
* Split $content into top-level `{ … }` blocks (depth 1 only).
|
||||
*
|
||||
* Heredoc sections (`<<-WORD … WORD` and `<<WORD … WORD`) are skipped in
|
||||
* their entirety so that any `{` or `}` characters inside template content
|
||||
* do not corrupt the brace-depth counter.
|
||||
*
|
||||
* @return string[] Each element is the inner content of one block (without outer braces)
|
||||
*/
|
||||
private function splitBlocks(string $content): array
|
||||
{
|
||||
$blocks = [];
|
||||
$depth = 0;
|
||||
$start = null;
|
||||
$len = strlen($content);
|
||||
$i = 0;
|
||||
/**
|
||||
* Split $content into top-level `{ … }` blocks (depth 1 only).
|
||||
*
|
||||
* Heredoc sections (`<<-WORD … WORD` and `<<WORD … WORD`) are skipped in
|
||||
* their entirety so that any `{` or `}` characters inside template content
|
||||
* do not corrupt the brace-depth counter.
|
||||
*
|
||||
* @return string[] Each element is the inner content of one block (without outer braces)
|
||||
*/
|
||||
private function splitBlocks(string $content): array
|
||||
{
|
||||
$blocks = [];
|
||||
$depth = 0;
|
||||
$start = null;
|
||||
$len = strlen($content);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
// Detect heredoc: <<WORD or <<-WORD
|
||||
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
|
||||
$i = $this->skipHeredoc($content, $i, $len);
|
||||
continue;
|
||||
}
|
||||
while ($i < $len) {
|
||||
// Detect heredoc: <<WORD or <<-WORD
|
||||
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
|
||||
$i = $this->skipHeredoc($content, $i, $len);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($content[$i] === '{') {
|
||||
if ($depth === 0) {
|
||||
$start = $i;
|
||||
}
|
||||
$depth++;
|
||||
} elseif ($content[$i] === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0 && $start !== null) {
|
||||
$blocks[] = substr($content, $start + 1, $i - $start - 1);
|
||||
$start = null;
|
||||
}
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
if ($content[$i] === '{') {
|
||||
if ($depth === 0) {
|
||||
$start = $i;
|
||||
}
|
||||
$depth++;
|
||||
} elseif ($content[$i] === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0 && $start !== null) {
|
||||
$blocks[] = substr($content, $start + 1, $i - $start - 1);
|
||||
$start = null;
|
||||
}
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance past a HCL heredoc starting at position $i.
|
||||
*
|
||||
* Supports both `<<WORD` (content-preserving) and `<<-WORD`
|
||||
* (indent-stripping) forms. Returns the index immediately after the
|
||||
* closing delimiter line, or $i + 2 if the heredoc is malformed.
|
||||
*/
|
||||
private function skipHeredoc(string $content, int $i, int $len): int
|
||||
{
|
||||
$j = $i + 2; // skip <<
|
||||
/**
|
||||
* Advance past a HCL heredoc starting at position $i.
|
||||
*
|
||||
* Supports both `<<WORD` (content-preserving) and `<<-WORD`
|
||||
* (indent-stripping) forms. Returns the index immediately after the
|
||||
* closing delimiter line, or $i + 2 if the heredoc is malformed.
|
||||
*/
|
||||
private function skipHeredoc(string $content, int $i, int $len): int
|
||||
{
|
||||
$j = $i + 2; // skip <<
|
||||
|
||||
// Optional indent-strip marker
|
||||
$stripIndent = false;
|
||||
if (isset($content[$j]) && $content[$j] === '-') {
|
||||
$stripIndent = true;
|
||||
$j++;
|
||||
}
|
||||
// Optional indent-strip marker
|
||||
$stripIndent = false;
|
||||
if (isset($content[$j]) && $content[$j] === '-') {
|
||||
$stripIndent = true;
|
||||
$j++;
|
||||
}
|
||||
|
||||
// Read the delimiter identifier (alphanumeric + underscore)
|
||||
$delimiter = '';
|
||||
while ($j < $len && (ctype_alnum($content[$j]) || $content[$j] === '_')) {
|
||||
$delimiter .= $content[$j];
|
||||
$j++;
|
||||
}
|
||||
// Read the delimiter identifier (alphanumeric + underscore)
|
||||
$delimiter = '';
|
||||
while ($j < $len && (ctype_alnum($content[$j]) || $content[$j] === '_')) {
|
||||
$delimiter .= $content[$j];
|
||||
$j++;
|
||||
}
|
||||
|
||||
if ($delimiter === '') {
|
||||
return $i + 2; // Not a real heredoc
|
||||
}
|
||||
if ($delimiter === '') {
|
||||
return $i + 2; // Not a real heredoc
|
||||
}
|
||||
|
||||
// Skip optional whitespace and the rest of the opening line
|
||||
while ($j < $len && $content[$j] !== "\n") {
|
||||
$j++;
|
||||
}
|
||||
if ($j < $len) {
|
||||
$j++; // skip the newline after the opening line
|
||||
}
|
||||
// Skip optional whitespace and the rest of the opening line
|
||||
while ($j < $len && $content[$j] !== "\n") {
|
||||
$j++;
|
||||
}
|
||||
if ($j < $len) {
|
||||
$j++; // skip the newline after the opening line
|
||||
}
|
||||
|
||||
// Scan line by line until the closing delimiter
|
||||
while ($j < $len) {
|
||||
$lineEnd = strpos($content, "\n", $j);
|
||||
$lineEnd = ($lineEnd === false) ? $len : $lineEnd;
|
||||
// Scan line by line until the closing delimiter
|
||||
while ($j < $len) {
|
||||
$lineEnd = strpos($content, "\n", $j);
|
||||
$lineEnd = ($lineEnd === false) ? $len : $lineEnd;
|
||||
|
||||
$line = substr($content, $j, $lineEnd - $j);
|
||||
// For <<- (indent-stripping) form, the terminator may itself be indented;
|
||||
// strip leading whitespace before comparing. For the non-stripping form
|
||||
// (<<), the terminator must be at column 0 — but we still rtrim trailing
|
||||
// whitespace/CR to handle Windows line-endings gracefully.
|
||||
$normalised = $stripIndent ? trim($line) : rtrim($line);
|
||||
if ($normalised === $delimiter) {
|
||||
return $lineEnd + 1;
|
||||
}
|
||||
$j = $lineEnd + 1;
|
||||
}
|
||||
$line = substr($content, $j, $lineEnd - $j);
|
||||
// For <<- (indent-stripping) form, the terminator may itself be indented;
|
||||
// strip leading whitespace before comparing. For the non-stripping form
|
||||
// (<<), the terminator must be at column 0 — but we still rtrim trailing
|
||||
// whitespace/CR to handle Windows line-endings gracefully.
|
||||
$normalised = $stripIndent ? trim($line) : rtrim($line);
|
||||
if ($normalised === $delimiter) {
|
||||
return $lineEnd + 1;
|
||||
}
|
||||
$j = $lineEnd + 1;
|
||||
}
|
||||
|
||||
return $len; // unterminated heredoc — consume to EOF
|
||||
}
|
||||
return $len; // unterminated heredoc — consume to EOF
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all file blocks inside a `files = [ … ]` array content,
|
||||
* returning only those that have a `template` field.
|
||||
*
|
||||
* @param string $arrayContent Inner content between the outer `[` and `]`
|
||||
* @param string $dirPath Directory prefix for the destination ('' = repo root)
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseFileBlocks(string $arrayContent, string $dirPath): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($this->splitBlocks($arrayContent) as $block) {
|
||||
$entry = $this->parseFileBlock($block, $dirPath);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
/**
|
||||
* Parse all file blocks inside a `files = [ … ]` array content,
|
||||
* returning only those that have a `template` field.
|
||||
*
|
||||
* @param string $arrayContent Inner content between the outer `[` and `]`
|
||||
* @param string $dirPath Directory prefix for the destination ('' = repo root)
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseFileBlocks(string $arrayContent, string $dirPath): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($this->splitBlocks($arrayContent) as $block) {
|
||||
$entry = $this->parseFileBlock($block, $dirPath);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single file block `{ name = "…", template = "…", … }` or
|
||||
* `{ name = "…", stub_content = <<-EOT … EOT, … }`.
|
||||
*
|
||||
* When a `stub_content` heredoc is present it takes priority over a
|
||||
* `template` file-path reference. Returns null when the block has
|
||||
* neither (structural-only entry that should not be synced).
|
||||
*
|
||||
* @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null
|
||||
*/
|
||||
private function parseFileBlock(string $block, string $dirPath): ?array
|
||||
{
|
||||
// --- try stub_content heredoc first (preferred) ---
|
||||
$inlineContent = $this->extractHeredoc($block, 'stub_content');
|
||||
/**
|
||||
* Parse a single file block `{ name = "…", template = "…", … }` or
|
||||
* `{ name = "…", stub_content = <<-EOT … EOT, … }`.
|
||||
*
|
||||
* When a `stub_content` heredoc is present it takes priority over a
|
||||
* `template` file-path reference. Returns null when the block has
|
||||
* neither (structural-only entry that should not be synced).
|
||||
*
|
||||
* @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null
|
||||
*/
|
||||
private function parseFileBlock(string $block, string $dirPath): ?array
|
||||
{
|
||||
// --- try stub_content heredoc first (preferred) ---
|
||||
$inlineContent = $this->extractHeredoc($block, 'stub_content');
|
||||
|
||||
// --- fall back to stub_content as a quoted string (e.g. "line1\nline2") ---
|
||||
if ($inlineContent === null) {
|
||||
if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) {
|
||||
$inlineContent = stripcslashes($m[1]);
|
||||
}
|
||||
}
|
||||
// --- fall back to stub_content as a quoted string (e.g. "line1\nline2") ---
|
||||
if ($inlineContent === null) {
|
||||
if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) {
|
||||
$inlineContent = stripcslashes($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- fall back to external template path ---
|
||||
$source = null;
|
||||
if ($inlineContent === null) {
|
||||
if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
return null; // neither inline content nor template → structural entry
|
||||
}
|
||||
$source = $m[1];
|
||||
}
|
||||
// --- fall back to external template path ---
|
||||
$source = null;
|
||||
if ($inlineContent === null) {
|
||||
if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
return null; // neither inline content nor template → structural entry
|
||||
}
|
||||
$source = $m[1];
|
||||
}
|
||||
|
||||
// name is required
|
||||
if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
return null;
|
||||
}
|
||||
$filename = $m[1];
|
||||
// name is required
|
||||
if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
return null;
|
||||
}
|
||||
$filename = $m[1];
|
||||
|
||||
// destination_filename overrides name
|
||||
if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$filename = $m[1];
|
||||
}
|
||||
// destination_filename overrides name
|
||||
if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$filename = $m[1];
|
||||
}
|
||||
|
||||
// destination_path overrides dirPath
|
||||
if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$dp = trim($m[1], '/');
|
||||
$destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}";
|
||||
} else {
|
||||
$destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}";
|
||||
}
|
||||
// destination_path overrides dirPath
|
||||
if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$dp = trim($m[1], '/');
|
||||
$destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}";
|
||||
} else {
|
||||
$destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}";
|
||||
}
|
||||
|
||||
// always_overwrite — default true for all template-driven files
|
||||
$alwaysOverwrite = true;
|
||||
if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) {
|
||||
$alwaysOverwrite = ($m[1] === 'true');
|
||||
}
|
||||
// always_overwrite — default true for all template-driven files
|
||||
$alwaysOverwrite = true;
|
||||
if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) {
|
||||
$alwaysOverwrite = ($m[1] === 'true');
|
||||
}
|
||||
|
||||
// protected — when true, file is never overwritten even with --force
|
||||
$protected = false;
|
||||
if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) {
|
||||
$protected = ($m[1] === 'true');
|
||||
}
|
||||
// protected — when true, file is never overwritten even with --force
|
||||
$protected = false;
|
||||
if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) {
|
||||
$protected = ($m[1] === 'true');
|
||||
}
|
||||
|
||||
if ($inlineContent !== null) {
|
||||
return [
|
||||
'inline_content' => $inlineContent,
|
||||
'destination' => $destination,
|
||||
'always_overwrite' => $alwaysOverwrite,
|
||||
'protected' => $protected,
|
||||
];
|
||||
}
|
||||
if ($inlineContent !== null) {
|
||||
return [
|
||||
'inline_content' => $inlineContent,
|
||||
'destination' => $destination,
|
||||
'always_overwrite' => $alwaysOverwrite,
|
||||
'protected' => $protected,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'always_overwrite' => $alwaysOverwrite,
|
||||
'protected' => $protected,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'always_overwrite' => $alwaysOverwrite,
|
||||
'protected' => $protected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a heredoc value for the given field name from a block string.
|
||||
*
|
||||
* Handles both `<<WORD` (content-preserving) and `<<-WORD`
|
||||
* (indent-stripping) forms. Leading tabs/spaces are stripped uniformly
|
||||
* when the `<<-` form is used, matching HCL semantics.
|
||||
*
|
||||
* Returns null when the field is not found.
|
||||
*/
|
||||
private function extractHeredoc(string $block, string $field): ?string
|
||||
{
|
||||
$pattern = '/\b' . preg_quote($field, '/') . '\s*=\s*<<(-?)(\w+)[ \t]*\r?\n(.*?)\r?\n[ \t]*\2[ \t]*(?:\r?\n|$)/s';
|
||||
if (!preg_match($pattern, $block, $m)) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Extract a heredoc value for the given field name from a block string.
|
||||
*
|
||||
* Handles both `<<WORD` (content-preserving) and `<<-WORD`
|
||||
* (indent-stripping) forms. Leading tabs/spaces are stripped uniformly
|
||||
* when the `<<-` form is used, matching HCL semantics.
|
||||
*
|
||||
* Returns null when the field is not found.
|
||||
*/
|
||||
private function extractHeredoc(string $block, string $field): ?string
|
||||
{
|
||||
$pattern = '/\b' . preg_quote($field, '/') . '\s*=\s*<<(-?)(\w+)[ \t]*\r?\n(.*?)\r?\n[ \t]*\2[ \t]*(?:\r?\n|$)/s';
|
||||
if (!preg_match($pattern, $block, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stripIndent = ($m[1] === '-');
|
||||
$rawContent = $m[3];
|
||||
$stripIndent = ($m[1] === '-');
|
||||
$rawContent = $m[3];
|
||||
|
||||
if ($stripIndent) {
|
||||
// Determine the minimum leading-whitespace prefix across non-empty lines
|
||||
$lines = explode("\n", $rawContent);
|
||||
$minIndent = PHP_INT_MAX;
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$indent = strlen($line) - strlen(ltrim($line, " \t"));
|
||||
if ($indent < $minIndent) {
|
||||
$minIndent = $indent;
|
||||
}
|
||||
}
|
||||
if ($minIndent === PHP_INT_MAX) {
|
||||
$minIndent = 0;
|
||||
}
|
||||
// Strip that many characters from the start of each line
|
||||
$lines = array_map(
|
||||
static fn(string $l) => (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l,
|
||||
$lines
|
||||
);
|
||||
$rawContent = implode("\n", $lines);
|
||||
}
|
||||
if ($stripIndent) {
|
||||
// Determine the minimum leading-whitespace prefix across non-empty lines
|
||||
$lines = explode("\n", $rawContent);
|
||||
$minIndent = PHP_INT_MAX;
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$indent = strlen($line) - strlen(ltrim($line, " \t"));
|
||||
if ($indent < $minIndent) {
|
||||
$minIndent = $indent;
|
||||
}
|
||||
}
|
||||
if ($minIndent === PHP_INT_MAX) {
|
||||
$minIndent = 0;
|
||||
}
|
||||
// Strip that many characters from the start of each line
|
||||
$lines = array_map(
|
||||
static fn(string $l) => (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l,
|
||||
$lines
|
||||
);
|
||||
$rawContent = implode("\n", $lines);
|
||||
}
|
||||
|
||||
return $rawContent;
|
||||
}
|
||||
return $rawContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the `directories = [ … ]` array, descending into every
|
||||
* `subdirectories` block recursively.
|
||||
*
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseDirectories(string $dirsArrayContent): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($this->splitBlocks($dirsArrayContent) as $block) {
|
||||
$entries = array_merge($entries, $this->parseDirectoryBlock($block));
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
/**
|
||||
* Walk the `directories = [ … ]` array, descending into every
|
||||
* `subdirectories` block recursively.
|
||||
*
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseDirectories(string $dirsArrayContent): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($this->splitBlocks($dirsArrayContent) as $block) {
|
||||
$entries = array_merge($entries, $this->parseDirectoryBlock($block));
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one directory block: extract its path, parse its files, and
|
||||
* recurse into any subdirectories.
|
||||
*
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseDirectoryBlock(string $block): array
|
||||
{
|
||||
$entries = [];
|
||||
/**
|
||||
* Process one directory block: extract its path, parse its files, and
|
||||
* recurse into any subdirectories.
|
||||
*
|
||||
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
|
||||
*/
|
||||
private function parseDirectoryBlock(string $block): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
// Determine the path prefix for files inside this directory
|
||||
$dirPath = '';
|
||||
if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$dirPath = $m[1];
|
||||
}
|
||||
// Determine the path prefix for files inside this directory
|
||||
$dirPath = '';
|
||||
if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) {
|
||||
$dirPath = $m[1];
|
||||
}
|
||||
|
||||
// files = [ … ] inside this directory
|
||||
$filesContent = $this->extractNamedArray($block, 'files');
|
||||
if ($filesContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath));
|
||||
}
|
||||
// files = [ … ] inside this directory
|
||||
$filesContent = $this->extractNamedArray($block, 'files');
|
||||
if ($filesContent !== null) {
|
||||
$entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath));
|
||||
}
|
||||
|
||||
// subdirectories = [ … ] — recurse
|
||||
$subdirsContent = $this->extractNamedArray($block, 'subdirectories');
|
||||
if ($subdirsContent !== null) {
|
||||
foreach ($this->splitBlocks($subdirsContent) as $subBlock) {
|
||||
$entries = array_merge($entries, $this->parseDirectoryBlock($subBlock));
|
||||
}
|
||||
}
|
||||
// subdirectories = [ … ] — recurse
|
||||
$subdirsContent = $this->extractNamedArray($block, 'subdirectories');
|
||||
if ($subdirsContent !== null) {
|
||||
foreach ($this->splitBlocks($subdirsContent) as $subBlock) {
|
||||
$entries = array_merge($entries, $this->parseDirectoryBlock($subBlock));
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Enterprise Readiness Validator
|
||||
*
|
||||
*
|
||||
* Enterprise library for validating repository compliance with
|
||||
* enterprise standards including libraries, monitoring, security, and documentation.
|
||||
*/
|
||||
@@ -28,9 +29,9 @@ class EnterpriseReadinessValidator
|
||||
{
|
||||
private AuditLogger $logger;
|
||||
private SecurityValidator $securityValidator;
|
||||
|
||||
|
||||
private array $results = [];
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
@@ -41,32 +42,32 @@ class EnterpriseReadinessValidator
|
||||
$this->logger = $logger ?? new AuditLogger('enterprise_readiness');
|
||||
$this->securityValidator = $securityValidator ?? new SecurityValidator();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate enterprise readiness
|
||||
*
|
||||
*
|
||||
* @param string $path Repository path to validate
|
||||
* @return array Validation results
|
||||
*/
|
||||
public function validate(string $path): array
|
||||
{
|
||||
$this->logger->logInfo("Starting enterprise readiness validation for: {$path}");
|
||||
|
||||
|
||||
$this->results = [];
|
||||
|
||||
|
||||
// Run all validation checks
|
||||
$this->checkEnterpriseLibraries($path);
|
||||
$this->checkMonitoring($path);
|
||||
$this->checkAuditLogging($path);
|
||||
$this->checkSecurityCompliance($path);
|
||||
$this->checkDocumentation($path);
|
||||
|
||||
|
||||
$passed = count(array_filter($this->results, fn($r) => $r['passed']));
|
||||
$total = count($this->results);
|
||||
$percentage = $total > 0 ? ($passed / $total * 100) : 0;
|
||||
|
||||
|
||||
$this->logger->logInfo("Enterprise readiness validation complete: {$passed}/{$total} checks passed ({$percentage}%)");
|
||||
|
||||
|
||||
return [
|
||||
'results' => $this->results,
|
||||
'passed' => $passed,
|
||||
@@ -76,7 +77,7 @@ class EnterpriseReadinessValidator
|
||||
'compliant' => $passed === $total,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check for required enterprise libraries
|
||||
*/
|
||||
@@ -89,7 +90,7 @@ class EnterpriseReadinessValidator
|
||||
'ErrorRecovery',
|
||||
'MetricsCollector'
|
||||
];
|
||||
|
||||
|
||||
foreach ($required as $library) {
|
||||
$phpFile = "{$path}/lib/Enterprise/{$library}.php";
|
||||
$this->addResult(
|
||||
@@ -99,7 +100,7 @@ class EnterpriseReadinessValidator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check monitoring configuration
|
||||
*/
|
||||
@@ -109,24 +110,24 @@ class EnterpriseReadinessValidator
|
||||
$metricsDir = "{$path}/var/logs/metrics";
|
||||
$hasMetricsDir = is_dir($metricsDir);
|
||||
$hasComposer = file_exists($path . '/composer.json');
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'Metrics directory configured',
|
||||
$hasMetricsDir || !$hasComposer,
|
||||
$hasMetricsDir ? "Metrics directory exists at {$metricsDir}" : 'Metrics logging not configured'
|
||||
);
|
||||
|
||||
|
||||
// Check for monitoring documentation
|
||||
$monitoringDocs = "{$path}/docs/monitoring";
|
||||
$hasMonitoringDocs = is_dir($monitoringDocs) || file_exists("{$path}/docs/monitoring.md");
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'Monitoring documentation exists',
|
||||
$hasMonitoringDocs,
|
||||
$hasMonitoringDocs ? "Monitoring documentation found" : 'Monitoring documentation not found'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check audit logging configuration
|
||||
*/
|
||||
@@ -135,14 +136,14 @@ class EnterpriseReadinessValidator
|
||||
$auditDir = "{$path}/var/logs/audit";
|
||||
$hasAuditDir = is_dir($auditDir);
|
||||
$hasComposer = file_exists($path . '/composer.json');
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'Audit logging directory configured',
|
||||
$hasAuditDir || !$hasComposer,
|
||||
$hasAuditDir ? "Audit directory exists at {$auditDir}" : 'Audit logging not configured'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check security compliance
|
||||
*/
|
||||
@@ -155,22 +156,23 @@ class EnterpriseReadinessValidator
|
||||
$hasSecurity,
|
||||
$hasSecurity ? "SECURITY.md found" : 'SECURITY.md not found'
|
||||
);
|
||||
|
||||
|
||||
// Check for CodeQL configuration
|
||||
$codeqlConfig = "{$path}/.github/codeql";
|
||||
$hasCodeQL = is_dir($codeqlConfig) || file_exists("{$path}/.github/codeql/codeql-config.yml");
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'CodeQL configured',
|
||||
$hasCodeQL,
|
||||
$hasCodeQL ? "CodeQL configuration found" : 'CodeQL not configured'
|
||||
);
|
||||
|
||||
|
||||
// Run security scan on PHP files
|
||||
if (is_dir("{$path}/src")) {
|
||||
$issues = $this->securityValidator->scanDirectory("{$path}/src", ['.php']);
|
||||
$this->securityValidator->scanDirectory("{$path}/src", ['.php']);
|
||||
$issues = $this->securityValidator->getFindings();
|
||||
$issueCount = count($issues);
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'No security vulnerabilities in source code',
|
||||
$issueCount === 0,
|
||||
@@ -178,32 +180,32 @@ class EnterpriseReadinessValidator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check documentation requirements
|
||||
*/
|
||||
private function checkDocumentation(string $path): void
|
||||
{
|
||||
// Check for architecture documentation
|
||||
$hasArchitecture = file_exists("{$path}/docs/architecture.md") ||
|
||||
$hasArchitecture = file_exists("{$path}/docs/architecture.md") ||
|
||||
file_exists("{$path}/docs/guide/architecture.md");
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'Architecture documentation exists',
|
||||
$hasArchitecture,
|
||||
$hasArchitecture ? "Architecture documentation found" : 'Architecture documentation not found'
|
||||
);
|
||||
|
||||
|
||||
// Check for API documentation
|
||||
$hasAPI = file_exists("{$path}/docs/api.md") || is_dir("{$path}/docs/api");
|
||||
|
||||
|
||||
$this->addResult(
|
||||
'API documentation exists',
|
||||
$hasAPI,
|
||||
$hasAPI ? "API documentation found" : 'API documentation not found'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a validation result
|
||||
*/
|
||||
@@ -215,40 +217,40 @@ class EnterpriseReadinessValidator
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all results
|
||||
*
|
||||
*
|
||||
* @return array All validation results
|
||||
*/
|
||||
public function getResults(): array
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get failed checks
|
||||
*
|
||||
*
|
||||
* @return array Array of failed checks
|
||||
*/
|
||||
public function getFailedChecks(): array
|
||||
{
|
||||
return array_filter($this->results, fn($r) => !$r['passed']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get passed checks
|
||||
*
|
||||
*
|
||||
* @return array Array of passed checks
|
||||
*/
|
||||
public function getPassedChecks(): array
|
||||
{
|
||||
return array_filter($this->results, fn($r) => $r['passed']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if fully compliant
|
||||
*
|
||||
*
|
||||
* @return bool True if all checks passed
|
||||
*/
|
||||
public function isCompliant(): bool
|
||||
|
||||
+215
-214
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -30,253 +31,253 @@ use SplFileInfo;
|
||||
*/
|
||||
class FileFixUtility
|
||||
{
|
||||
/** @var list<string> Extensions processed by fixLineEndings(). */
|
||||
private const LINE_ENDING_EXTENSIONS = ['php', 'js', 'css', 'xml', 'sh', 'md'];
|
||||
/** @var list<string> Extensions processed by fixLineEndings(). */
|
||||
private const LINE_ENDING_EXTENSIONS = ['php', 'js', 'css', 'xml', 'sh', 'md'];
|
||||
|
||||
/** @var list<string> Extensions processed when $fileType = 'all' in fixTabs(). */
|
||||
private const TABS_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash'];
|
||||
/** @var list<string> Extensions processed when $fileType = 'all' in fixTabs(). */
|
||||
private const TABS_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash'];
|
||||
|
||||
/** @var array<string,list<string>> Extension sets per file-type name in fixTabs(). */
|
||||
private const TABS_TYPE_EXTENSIONS = [
|
||||
'yaml' => ['yml', 'yaml'],
|
||||
'python' => ['py'],
|
||||
'shell' => ['sh', 'bash'],
|
||||
'all' => self::TABS_ALL_EXTENSIONS,
|
||||
];
|
||||
/** @var array<string,list<string>> Extension sets per file-type name in fixTabs(). */
|
||||
private const TABS_TYPE_EXTENSIONS = [
|
||||
'yaml' => ['yml', 'yaml'],
|
||||
'python' => ['py'],
|
||||
'shell' => ['sh', 'bash'],
|
||||
'all' => self::TABS_ALL_EXTENSIONS,
|
||||
];
|
||||
|
||||
/** @var list<string> Extensions processed when $fileType = 'all' in fixTrailingSpaces(). */
|
||||
private const TRAILING_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash', 'md', 'markdown'];
|
||||
/** @var list<string> Extensions processed when $fileType = 'all' in fixTrailingSpaces(). */
|
||||
private const TRAILING_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash', 'md', 'markdown'];
|
||||
|
||||
/** @var array<string,list<string>> Extension sets per file-type name in fixTrailingSpaces(). */
|
||||
private const TRAILING_TYPE_EXTENSIONS = [
|
||||
'yaml' => ['yml', 'yaml'],
|
||||
'python' => ['py'],
|
||||
'shell' => ['sh', 'bash'],
|
||||
'markdown' => ['md', 'markdown'],
|
||||
'all' => self::TRAILING_ALL_EXTENSIONS,
|
||||
];
|
||||
/** @var array<string,list<string>> Extension sets per file-type name in fixTrailingSpaces(). */
|
||||
private const TRAILING_TYPE_EXTENSIONS = [
|
||||
'yaml' => ['yml', 'yaml'],
|
||||
'python' => ['py'],
|
||||
'shell' => ['sh', 'bash'],
|
||||
'markdown' => ['md', 'markdown'],
|
||||
'all' => self::TRAILING_ALL_EXTENSIONS,
|
||||
];
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fix CRLF line endings to LF in tracked source files.
|
||||
*
|
||||
* Operates on all git-tracked files with extensions: php, js, css, xml, sh, md.
|
||||
* In dry-run mode, returns the list of files that would be changed without
|
||||
* modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
*/
|
||||
public static function fixLineEndings(string $repoRoot, bool $dryRun = false): array
|
||||
{
|
||||
$patterns = array_map(
|
||||
static fn(string $ext): string => '*.' . $ext,
|
||||
self::LINE_ENDING_EXTENSIONS
|
||||
);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
/**
|
||||
* Fix CRLF line endings to LF in tracked source files.
|
||||
*
|
||||
* Operates on all git-tracked files with extensions: php, js, css, xml, sh, md.
|
||||
* In dry-run mode, returns the list of files that would be changed without
|
||||
* modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
*/
|
||||
public static function fixLineEndings(string $repoRoot, bool $dryRun = false): array
|
||||
{
|
||||
$patterns = array_map(
|
||||
static fn(string $ext): string => '*.' . $ext,
|
||||
self::LINE_ENDING_EXTENSIONS
|
||||
);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
if (strpos($content, "\r\n") === false) {
|
||||
continue;
|
||||
}
|
||||
$content = (string) file_get_contents($path);
|
||||
if (strpos($content, "\r\n") === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed[] = $file;
|
||||
$changed[] = $file;
|
||||
|
||||
if (!$dryRun) {
|
||||
file_put_contents($path, str_replace("\r\n", "\n", $content));
|
||||
}
|
||||
}
|
||||
if (!$dryRun) {
|
||||
file_put_contents($path, str_replace("\r\n", "\n", $content));
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix file permissions: directories 755, regular files 644, .php/.sh scripts 755.
|
||||
*
|
||||
* Skips the .git/ directory tree. In dry-run mode, no changes are applied.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param bool $dryRun When true, report what would change without writing.
|
||||
*/
|
||||
public static function fixPermissions(string $repoRoot, bool $dryRun = false): void
|
||||
{
|
||||
if ($dryRun) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Fix file permissions: directories 755, regular files 644, .php/.sh scripts 755.
|
||||
*
|
||||
* Skips the .git/ directory tree. In dry-run mode, no changes are applied.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param bool $dryRun When true, report what would change without writing.
|
||||
*/
|
||||
public static function fixPermissions(string $repoRoot, bool $dryRun = false): void
|
||||
{
|
||||
if ($dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$path = $item->getPathname();
|
||||
foreach ($iterator as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$path = $item->getPathname();
|
||||
|
||||
if (str_contains($path, '/.git/') || str_ends_with($path, '/.git')) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($path, '/.git/') || str_ends_with($path, '/.git')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->isDir()) {
|
||||
chmod($path, 0755);
|
||||
} elseif ($item->isFile()) {
|
||||
$ext = strtolower($item->getExtension());
|
||||
$perm = in_array($ext, ['php', 'sh'], true) ? 0755 : 0644;
|
||||
chmod($path, $perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($item->isDir()) {
|
||||
chmod($path, 0755);
|
||||
} elseif ($item->isFile()) {
|
||||
$ext = strtolower($item->getExtension());
|
||||
$perm = in_array($ext, ['php', 'sh'], true) ? 0755 : 0644;
|
||||
chmod($path, $perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tab characters to spaces in tracked source files.
|
||||
*
|
||||
* YAML files use 2-space indentation; all other supported types use 4 spaces.
|
||||
* Makefile variants are always skipped. In dry-run mode, returns the list of
|
||||
* files that would be changed without modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $fileType One of yaml, python, shell, all (default: all).
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
* @throws \InvalidArgumentException When $fileType is unrecognised.
|
||||
*/
|
||||
public static function fixTabs(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
|
||||
{
|
||||
if (!array_key_exists($fileType, self::TABS_TYPE_EXTENSIONS)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown file type: {$fileType}. Valid types: " .
|
||||
implode(', ', array_keys(self::TABS_TYPE_EXTENSIONS))
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Convert tab characters to spaces in tracked source files.
|
||||
*
|
||||
* YAML files use 2-space indentation; all other supported types use 4 spaces.
|
||||
* Makefile variants are always skipped. In dry-run mode, returns the list of
|
||||
* files that would be changed without modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $fileType One of yaml, python, shell, all (default: all).
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
* @throws \InvalidArgumentException When $fileType is unrecognised.
|
||||
*/
|
||||
public static function fixTabs(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
|
||||
{
|
||||
if (!array_key_exists($fileType, self::TABS_TYPE_EXTENSIONS)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown file type: {$fileType}. Valid types: " .
|
||||
implode(', ', array_keys(self::TABS_TYPE_EXTENSIONS))
|
||||
);
|
||||
}
|
||||
|
||||
$extensions = self::TABS_TYPE_EXTENSIONS[$fileType];
|
||||
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
$extensions = self::TABS_TYPE_EXTENSIONS[$fileType];
|
||||
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isMakefile($file)) {
|
||||
continue;
|
||||
}
|
||||
if (self::isMakefile($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
if (strpos($content, "\t") === false) {
|
||||
continue;
|
||||
}
|
||||
$content = (string) file_get_contents($path);
|
||||
if (strpos($content, "\t") === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed[] = $file;
|
||||
$changed[] = $file;
|
||||
|
||||
if (!$dryRun) {
|
||||
$spaces = self::spacesForFile($file);
|
||||
$pad = str_repeat(' ', $spaces);
|
||||
file_put_contents($path, str_replace("\t", $pad, $content));
|
||||
}
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$spaces = self::spacesForFile($file);
|
||||
$pad = str_repeat(' ', $spaces);
|
||||
file_put_contents($path, str_replace("\t", $pad, $content));
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trailing whitespace from tracked source files.
|
||||
*
|
||||
* In dry-run mode, returns the list of files that would be changed without
|
||||
* modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $fileType One of yaml, python, shell, markdown, all (default: all).
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
* @throws \InvalidArgumentException When $fileType is unrecognised.
|
||||
*/
|
||||
public static function fixTrailingSpaces(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
|
||||
{
|
||||
if (!array_key_exists($fileType, self::TRAILING_TYPE_EXTENSIONS)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown file type: {$fileType}. Valid types: " .
|
||||
implode(', ', array_keys(self::TRAILING_TYPE_EXTENSIONS))
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Remove trailing whitespace from tracked source files.
|
||||
*
|
||||
* In dry-run mode, returns the list of files that would be changed without
|
||||
* modifying them.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $fileType One of yaml, python, shell, markdown, all (default: all).
|
||||
* @param bool $dryRun When true, report changes without writing.
|
||||
* @return list<string> Files that were (or would be) changed.
|
||||
* @throws \InvalidArgumentException When $fileType is unrecognised.
|
||||
*/
|
||||
public static function fixTrailingSpaces(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
|
||||
{
|
||||
if (!array_key_exists($fileType, self::TRAILING_TYPE_EXTENSIONS)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown file type: {$fileType}. Valid types: " .
|
||||
implode(', ', array_keys(self::TRAILING_TYPE_EXTENSIONS))
|
||||
);
|
||||
}
|
||||
|
||||
$extensions = self::TRAILING_TYPE_EXTENSIONS[$fileType];
|
||||
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
$extensions = self::TRAILING_TYPE_EXTENSIONS[$fileType];
|
||||
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
|
||||
$files = self::gitLsFiles($repoRoot, $patterns);
|
||||
$changed = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($files as $file) {
|
||||
$path = $repoRoot . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
if (!preg_match('/[[:space:]]+$/m', $content)) {
|
||||
continue;
|
||||
}
|
||||
$content = (string) file_get_contents($path);
|
||||
if (!preg_match('/[[:space:]]+$/m', $content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed[] = $file;
|
||||
$changed[] = $file;
|
||||
|
||||
if (!$dryRun) {
|
||||
$fixed = preg_replace('/[[:space:]]+$/m', '', $content);
|
||||
file_put_contents($path, (string) $fixed);
|
||||
}
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$fixed = preg_replace('/[[:space:]]+$/m', '', $content);
|
||||
file_put_contents($path, (string) $fixed);
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run git ls-files in the given root with the provided glob patterns.
|
||||
*
|
||||
* @param string $repoRoot Repository root path.
|
||||
* @param list<string> $patterns Shell glob patterns.
|
||||
* @return list<string> Relative file paths.
|
||||
*/
|
||||
private static function gitLsFiles(string $repoRoot, array $patterns): array
|
||||
{
|
||||
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
|
||||
$cmd = 'git -C ' . escapeshellarg($repoRoot) . " ls-files {$quoted} 2>/dev/null";
|
||||
$output = shell_exec($cmd) ?? '';
|
||||
return array_values(array_filter(explode("\n", $output)));
|
||||
}
|
||||
/**
|
||||
* Run git ls-files in the given root with the provided glob patterns.
|
||||
*
|
||||
* @param string $repoRoot Repository root path.
|
||||
* @param list<string> $patterns Shell glob patterns.
|
||||
* @return list<string> Relative file paths.
|
||||
*/
|
||||
private static function gitLsFiles(string $repoRoot, array $patterns): array
|
||||
{
|
||||
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
|
||||
$cmd = 'git -C ' . escapeshellarg($repoRoot) . " ls-files {$quoted} 2>/dev/null";
|
||||
$output = shell_exec($cmd) ?? '';
|
||||
return array_values(array_filter(explode("\n", $output)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the filename matches a Makefile variant.
|
||||
*
|
||||
* @param string $path File path (only basename is examined).
|
||||
*/
|
||||
private static function isMakefile(string $path): bool
|
||||
{
|
||||
$base = strtolower(basename($path));
|
||||
return $base === 'makefile'
|
||||
|| $base === 'gnumakefile'
|
||||
|| str_starts_with($base, 'makefile.');
|
||||
}
|
||||
/**
|
||||
* Return true when the filename matches a Makefile variant.
|
||||
*
|
||||
* @param string $path File path (only basename is examined).
|
||||
*/
|
||||
private static function isMakefile(string $path): bool
|
||||
{
|
||||
$base = strtolower(basename($path));
|
||||
return $base === 'makefile'
|
||||
|| $base === 'gnumakefile'
|
||||
|| str_starts_with($base, 'makefile.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the number of spaces to substitute for a tab in a given file.
|
||||
*
|
||||
* @param string $path File path (extension determines width).
|
||||
* @return int 2 for YAML, 4 for everything else.
|
||||
*/
|
||||
private static function spacesForFile(string $path): int
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['yml', 'yaml'], true) ? 2 : 4;
|
||||
}
|
||||
/**
|
||||
* Return the number of spaces to substitute for a tab in a given file.
|
||||
*
|
||||
* @param string $path File path (extension determines width).
|
||||
* @return int 2 for YAML, 4 for everything else.
|
||||
*/
|
||||
private static function spacesForFile(string $path): int
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['yml', 'yaml'], true) ? 2 : 4;
|
||||
}
|
||||
}
|
||||
|
||||
+356
-326
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -31,397 +32,426 @@ use RuntimeException;
|
||||
* - Workflow dir: .github/workflows
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
* @version 04.06.10
|
||||
* @since 04.06.10
|
||||
* @see GitPlatformAdapter
|
||||
*/
|
||||
class GitHubAdapter implements GitPlatformAdapter
|
||||
{
|
||||
private ApiClient $apiClient;
|
||||
/** @var ApiClient HTTP client for GitHub API calls. */
|
||||
private ApiClient $apiClient;
|
||||
|
||||
public function __construct(ApiClient $apiClient)
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
}
|
||||
/**
|
||||
* @param ApiClient $apiClient Configured API client for api.github.com
|
||||
*/
|
||||
public function __construct(ApiClient $apiClient)
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
return 'github';
|
||||
}
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
return 'github';
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
return 'https://api.github.com';
|
||||
}
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
return 'https://api.github.com';
|
||||
}
|
||||
|
||||
public function getWorkflowDir(): string
|
||||
{
|
||||
return '.github/workflows';
|
||||
}
|
||||
public function getWorkflowDir(): string
|
||||
{
|
||||
return '.github/workflows';
|
||||
}
|
||||
|
||||
public function getMetadataDir(): string
|
||||
{
|
||||
return '.github';
|
||||
}
|
||||
public function getMetadataDir(): string
|
||||
{
|
||||
return '.github';
|
||||
}
|
||||
|
||||
public function getRepoWebUrl(string $org, string $repo): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}";
|
||||
}
|
||||
public function getRepoWebUrl(string $org, string $repo): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}";
|
||||
}
|
||||
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/pull/{$number}";
|
||||
}
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/pull/{$number}";
|
||||
}
|
||||
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/issues/{$number}";
|
||||
}
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/issues/{$number}";
|
||||
}
|
||||
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/tree/{$branch}";
|
||||
}
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string
|
||||
{
|
||||
return "https://github.com/{$org}/{$repo}/tree/{$branch}";
|
||||
}
|
||||
|
||||
public function getStepSummaryEnvVar(): string
|
||||
{
|
||||
return 'GITHUB_STEP_SUMMARY';
|
||||
}
|
||||
public function getStepSummaryEnvVar(): string
|
||||
{
|
||||
return 'GITHUB_STEP_SUMMARY';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array
|
||||
{
|
||||
$all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array
|
||||
{
|
||||
$all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
|
||||
|
||||
$repos = [];
|
||||
foreach ($all as $repo) {
|
||||
if ($skipArchived && ($repo['archived'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$repos[] = [
|
||||
'name' => $repo['name'],
|
||||
'full_name' => $repo['full_name'],
|
||||
'archived' => $repo['archived'] ?? false,
|
||||
'private' => $repo['private'] ?? false,
|
||||
];
|
||||
}
|
||||
$repos = [];
|
||||
foreach ($all as $repo) {
|
||||
if ($skipArchived && ($repo['archived'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$repos[] = [
|
||||
'name' => $repo['name'],
|
||||
'full_name' => $repo['full_name'],
|
||||
'archived' => $repo['archived'] ?? false,
|
||||
'private' => $repo['private'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
return $repos;
|
||||
}
|
||||
return $repos;
|
||||
}
|
||||
|
||||
public function getRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}");
|
||||
}
|
||||
public function getRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}");
|
||||
}
|
||||
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array
|
||||
{
|
||||
$data = array_merge([
|
||||
'name' => $name,
|
||||
'auto_init' => true,
|
||||
], $options);
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array
|
||||
{
|
||||
$data = array_merge([
|
||||
'name' => $name,
|
||||
'auto_init' => true,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/orgs/{$org}/repos", $data);
|
||||
}
|
||||
return $this->apiClient->post("/orgs/{$org}/repos", $data);
|
||||
}
|
||||
|
||||
public function archiveRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
|
||||
'archived' => true,
|
||||
]);
|
||||
}
|
||||
public function archiveRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
|
||||
'archived' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void
|
||||
{
|
||||
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
|
||||
'names' => $topics,
|
||||
]);
|
||||
}
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void
|
||||
{
|
||||
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
|
||||
'names' => $topics,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRepoTopics(string $org, string $repo): array
|
||||
{
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
|
||||
return $response['names'] ?? [];
|
||||
}
|
||||
public function getRepoTopics(string $org, string $repo): array
|
||||
{
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
|
||||
return $response['names'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
|
||||
{
|
||||
$params = [];
|
||||
if ($ref !== null) {
|
||||
$params['ref'] = $ref;
|
||||
}
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
|
||||
}
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
|
||||
{
|
||||
$params = [];
|
||||
if ($ref !== null) {
|
||||
$params['ref'] = $ref;
|
||||
}
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
|
||||
}
|
||||
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array {
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'content' => base64_encode($content),
|
||||
];
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array {
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'content' => base64_encode($content),
|
||||
];
|
||||
|
||||
if ($sha !== null) {
|
||||
$data['sha'] = $sha;
|
||||
}
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
if ($sha !== null) {
|
||||
$data['sha'] = $sha;
|
||||
}
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
|
||||
// GitHub uses PUT for both create and update
|
||||
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
// GitHub uses PUT for both create and update
|
||||
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array {
|
||||
// GitHub's delete endpoint requires a body with sha+message,
|
||||
// but ApiClient::delete() doesn't accept a body. Use the raw approach.
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'sha' => $sha,
|
||||
];
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array {
|
||||
// GitHub's delete endpoint requires a body with sha+message,
|
||||
// but ApiClient::delete() doesn't accept a body. Use the raw approach.
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'sha' => $sha,
|
||||
];
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
|
||||
// Work around ApiClient::delete() not accepting a body by using
|
||||
// a direct HTTP call. For now, fall back to the underlying client.
|
||||
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
}
|
||||
// Work around ApiClient::delete() not accepting a body by using
|
||||
// a direct HTTP call. For now, fall back to the underlying client.
|
||||
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
|
||||
}
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
|
||||
}
|
||||
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
|
||||
}
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
|
||||
}
|
||||
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
|
||||
}
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
|
||||
}
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
|
||||
}
|
||||
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
|
||||
}
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeIssue(string $org, string $repo, int $number): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
|
||||
'state' => 'closed',
|
||||
]);
|
||||
}
|
||||
public function closeIssue(string $org, string $repo, int $number): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
|
||||
'state' => 'closed',
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listLabels(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
|
||||
}
|
||||
public function listLabels(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
|
||||
}
|
||||
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'description' => $description,
|
||||
]);
|
||||
}
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
|
||||
{
|
||||
// GitHub accepts label names directly
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
|
||||
{
|
||||
// GitHub accepts label names directly
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
|
||||
{
|
||||
// GitHub uses rulesets API (newer) or branch protection API (legacy)
|
||||
// Map our generic rules to GitHub's branch protection format
|
||||
$protection = [
|
||||
'required_status_checks' => null,
|
||||
'enforce_admins' => $rules['enforce_admins'] ?? true,
|
||||
'required_pull_request_reviews' => null,
|
||||
'restrictions' => null,
|
||||
];
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
|
||||
{
|
||||
// GitHub uses rulesets API (newer) or branch protection API (legacy)
|
||||
// Map our generic rules to GitHub's branch protection format
|
||||
$protection = [
|
||||
'required_status_checks' => null,
|
||||
'enforce_admins' => $rules['enforce_admins'] ?? true,
|
||||
'required_pull_request_reviews' => null,
|
||||
'restrictions' => null,
|
||||
];
|
||||
|
||||
if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) {
|
||||
$protection['required_pull_request_reviews'] = [
|
||||
'required_approving_review_count' => $rules['required_reviews'],
|
||||
'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false,
|
||||
'require_code_owner_reviews' => $rules['require_code_owner'] ?? false,
|
||||
];
|
||||
}
|
||||
if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) {
|
||||
$protection['required_pull_request_reviews'] = [
|
||||
'required_approving_review_count' => $rules['required_reviews'],
|
||||
'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false,
|
||||
'require_code_owner_reviews' => $rules['require_code_owner'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->apiClient->put(
|
||||
"/repos/{$org}/{$repo}/branches/{$branch}/protection",
|
||||
$protection
|
||||
);
|
||||
}
|
||||
return $this->apiClient->put(
|
||||
"/repos/{$org}/{$repo}/branches/{$branch}/protection",
|
||||
$protection
|
||||
);
|
||||
}
|
||||
|
||||
public function listBranchProtections(string $org, string $repo): array
|
||||
{
|
||||
// GitHub doesn't have a "list all protections" endpoint; list branches and check each
|
||||
// For rulesets: GET /repos/{owner}/{repo}/rulesets
|
||||
try {
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets");
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
public function listBranchProtections(string $org, string $repo): array
|
||||
{
|
||||
// GitHub doesn't have a "list all protections" endpoint; list branches and check each
|
||||
// For rulesets: GET /repos/{owner}/{repo}/rulesets
|
||||
try {
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets");
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function resolveRef(string $org, string $repo, string $ref): string
|
||||
{
|
||||
// Try as a tag first, then as a branch
|
||||
try {
|
||||
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}");
|
||||
$object = $tag['object'] ?? [];
|
||||
public function resolveRef(string $org, string $repo, string $ref): string
|
||||
{
|
||||
// Try as a tag first, then as a branch
|
||||
try {
|
||||
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}");
|
||||
$object = $tag['object'] ?? [];
|
||||
|
||||
// Annotated tags have type 'tag' — dereference to the commit
|
||||
if (($object['type'] ?? '') === 'tag') {
|
||||
$tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}");
|
||||
return $tagObj['object']['sha'] ?? $object['sha'];
|
||||
}
|
||||
// Annotated tags have type 'tag' — dereference to the commit
|
||||
if (($object['type'] ?? '') === 'tag') {
|
||||
$tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}");
|
||||
return $tagObj['object']['sha'] ?? $object['sha'];
|
||||
}
|
||||
|
||||
return $object['sha'] ?? '';
|
||||
} catch (\Exception $e) {
|
||||
// Not a tag — try as a branch
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
return $object['sha'] ?? '';
|
||||
} catch (\Exception $e) {
|
||||
// Not a tag — try as a branch
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}");
|
||||
return $branch['object']['sha'] ?? '';
|
||||
}
|
||||
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}");
|
||||
return $branch['object']['sha'] ?? '';
|
||||
}
|
||||
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
|
||||
{
|
||||
$params = $recursive ? ['recursive' => '1'] : [];
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
|
||||
return $response['tree'] ?? [];
|
||||
}
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
|
||||
{
|
||||
$params = $recursive ? ['recursive' => '1'] : [];
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
|
||||
return $response['tree'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
$params['per_page'] = $perPage;
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
$params['per_page'] = $perPage;
|
||||
|
||||
while (true) {
|
||||
$params['page'] = $page;
|
||||
$response = $this->apiClient->get($endpoint, $params);
|
||||
while (true) {
|
||||
$params['page'] = $page;
|
||||
$response = $this->apiClient->get($endpoint, $params);
|
||||
|
||||
if (empty($response)) {
|
||||
break;
|
||||
}
|
||||
if (empty($response)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$all = array_merge($all, $response);
|
||||
$page++;
|
||||
}
|
||||
$all = array_merge($all, $response);
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
return array_values($all);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function migrateRepository(array $options): array
|
||||
{
|
||||
throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration');
|
||||
}
|
||||
public function migrateRepository(array $options): array
|
||||
{
|
||||
throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getApiClient(): ApiClient
|
||||
{
|
||||
return $this->apiClient;
|
||||
}
|
||||
public function getApiClient(): ApiClient
|
||||
{
|
||||
return $this->apiClient;
|
||||
}
|
||||
|
||||
public function listBranches(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/branches") ?? [];
|
||||
}
|
||||
|
||||
public function getCloneUrl(string $repo): string
|
||||
{
|
||||
return "https://github.com/{$repo}.git";
|
||||
}
|
||||
|
||||
public function cloneRepo(string $repo, string $path, array $options = []): bool
|
||||
{
|
||||
$url = $this->getCloneUrl($repo);
|
||||
$depth = $options['depth'] ?? 0;
|
||||
$depthFlag = $depth > 0 ? " --depth {$depth}" : '';
|
||||
$result = 0;
|
||||
passthru(
|
||||
'git clone' . $depthFlag . ' --quiet '
|
||||
. escapeshellarg($url) . ' ' . escapeshellarg($path),
|
||||
$result
|
||||
);
|
||||
return $result === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -29,429 +30,452 @@ namespace MokoEnterprise;
|
||||
*/
|
||||
interface GitPlatformAdapter
|
||||
{
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the platform name identifier.
|
||||
*
|
||||
* @return string 'github' or 'gitea'
|
||||
*/
|
||||
public function getPlatformName(): string;
|
||||
/**
|
||||
* Get the platform name identifier.
|
||||
*
|
||||
* @return string 'github' or 'gitea'
|
||||
*/
|
||||
public function getPlatformName(): string;
|
||||
|
||||
/**
|
||||
* Get the API base URL.
|
||||
*
|
||||
* @return string e.g. 'https://api.github.com' or 'https://git.mokoconsulting.tech/api/v1'
|
||||
*/
|
||||
public function getBaseUrl(): string;
|
||||
/**
|
||||
* Get the API base URL.
|
||||
*
|
||||
* @return string e.g. 'https://api.github.com' or 'https://git.mokoconsulting.tech/api/v1'
|
||||
*/
|
||||
public function getBaseUrl(): string;
|
||||
|
||||
/**
|
||||
* Get the workflow directory name for this platform.
|
||||
*
|
||||
* @return string '.github/workflows' or '.mokogitea/workflows'
|
||||
*/
|
||||
public function getWorkflowDir(): string;
|
||||
/**
|
||||
* Get the workflow directory name for this platform.
|
||||
*
|
||||
* @return string '.github/workflows' or '.mokogitea/workflows'
|
||||
*/
|
||||
public function getWorkflowDir(): string;
|
||||
|
||||
/**
|
||||
* Get the platform-specific metadata directory.
|
||||
*
|
||||
* @return string '.github' or '.mokogitea'
|
||||
*/
|
||||
public function getMetadataDir(): string;
|
||||
/**
|
||||
* Get the platform-specific metadata directory.
|
||||
*
|
||||
* @return string '.github' or '.mokogitea'
|
||||
*/
|
||||
public function getMetadataDir(): string;
|
||||
|
||||
/**
|
||||
* Get the web URL for a repository (for use in markdown links, not API calls).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return string e.g. 'https://github.com/org/repo' or 'https://git.mokoconsulting.tech/org/repo'
|
||||
*/
|
||||
public function getRepoWebUrl(string $org, string $repo): string;
|
||||
/**
|
||||
* Get the web URL for a repository (for use in markdown links, not API calls).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return string e.g. 'https://github.com/org/repo' or 'https://git.mokoconsulting.tech/org/repo'
|
||||
*/
|
||||
public function getRepoWebUrl(string $org, string $repo): string;
|
||||
|
||||
/**
|
||||
* Get the web URL for a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number PR number
|
||||
* @return string e.g. 'https://github.com/org/repo/pull/1' or 'https://git.example.com/org/repo/pulls/1'
|
||||
*/
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string;
|
||||
/**
|
||||
* Get the web URL for a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number PR number
|
||||
* @return string e.g. 'https://github.com/org/repo/pull/1' or 'https://git.example.com/org/repo/pulls/1'
|
||||
*/
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string;
|
||||
|
||||
/**
|
||||
* Get the web URL for an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue number
|
||||
* @return string
|
||||
*/
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string;
|
||||
/**
|
||||
* Get the web URL for an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue number
|
||||
* @return string
|
||||
*/
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string;
|
||||
|
||||
/**
|
||||
* Get the web URL for a branch.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch name
|
||||
* @return string e.g. 'https://github.com/org/repo/tree/branch' or 'https://git.example.com/org/repo/src/branch/branch'
|
||||
*/
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string;
|
||||
/**
|
||||
* Get the web URL for a branch.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch name
|
||||
* @return string e.g. 'https://github.com/org/repo/tree/branch' or 'https://git.example.com/org/repo/src/branch/branch'
|
||||
*/
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string;
|
||||
|
||||
/**
|
||||
* Get the environment variable name for step summary output (CI-specific).
|
||||
*
|
||||
* @return string 'GITHUB_STEP_SUMMARY' or 'GITEA_STEP_SUMMARY'
|
||||
*/
|
||||
public function getStepSummaryEnvVar(): string;
|
||||
/**
|
||||
* Get the environment variable name for step summary output (CI-specific).
|
||||
*
|
||||
* @return string 'GITHUB_STEP_SUMMARY' or 'GITEA_STEP_SUMMARY'
|
||||
*/
|
||||
public function getStepSummaryEnvVar(): string;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all repositories for an organization.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param bool $skipArchived Whether to exclude archived repos
|
||||
* @return array<int, array{name: string, full_name: string, archived: bool, private: bool}> Repository list
|
||||
*/
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array;
|
||||
/**
|
||||
* List all repositories for an organization.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param bool $skipArchived Whether to exclude archived repos
|
||||
* @return array<int, array{name: string, full_name: string, archived: bool, private: bool}> Repository list
|
||||
*/
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array;
|
||||
|
||||
/**
|
||||
* Get a single repository's information.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string, mixed> Repository data from API
|
||||
*/
|
||||
public function getRepo(string $org, string $repo): array;
|
||||
/**
|
||||
* Get a single repository's information.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string, mixed> Repository data from API
|
||||
*/
|
||||
public function getRepo(string $org, string $repo): array;
|
||||
|
||||
/**
|
||||
* Create a new repository in an organization.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $name Repository name
|
||||
* @param array<string, mixed> $options Repository options (description, private, auto_init, etc.)
|
||||
* @return array<string, mixed> Created repository data
|
||||
*/
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array;
|
||||
/**
|
||||
* Create a new repository in an organization.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $name Repository name
|
||||
* @param array<string, mixed> $options Repository options (description, private, auto_init, etc.)
|
||||
* @return array<string, mixed> Created repository data
|
||||
*/
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array;
|
||||
|
||||
/**
|
||||
* Archive a repository (set to read-only).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string, mixed> Updated repository data
|
||||
*/
|
||||
public function archiveRepo(string $org, string $repo): array;
|
||||
/**
|
||||
* Archive a repository (set to read-only).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string, mixed> Updated repository data
|
||||
*/
|
||||
public function archiveRepo(string $org, string $repo): array;
|
||||
|
||||
/**
|
||||
* Set repository topics/tags.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string> $topics List of topic strings
|
||||
* @return void
|
||||
*/
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void;
|
||||
/**
|
||||
* Set repository topics/tags.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string> $topics List of topic strings
|
||||
* @return void
|
||||
*/
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void;
|
||||
|
||||
/**
|
||||
* Get repository topics/tags.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string> List of topic strings
|
||||
*/
|
||||
public function getRepoTopics(string $org, string $repo): array;
|
||||
/**
|
||||
* Get repository topics/tags.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<string> List of topic strings
|
||||
*/
|
||||
public function getRepoTopics(string $org, string $repo): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Branches and Cloning
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get file contents from a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path within the repository
|
||||
* @param string|null $ref Branch/tag/SHA reference (null = default branch)
|
||||
* @return array{content: string, sha: string, size: int, encoding: string} File data (content is base64-encoded)
|
||||
*/
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array;
|
||||
/**
|
||||
* List all branches in a repository.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function listBranches(string $org, string $repo): array;
|
||||
|
||||
/**
|
||||
* Create or update a file in a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path
|
||||
* @param string $content Raw file content (will be base64-encoded internally)
|
||||
* @param string $message Commit message
|
||||
* @param string|null $sha SHA of existing file (null = create new, string = update existing)
|
||||
* @param string|null $branch Target branch (null = default branch)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array;
|
||||
/**
|
||||
* Get the clone URL for a repository.
|
||||
*/
|
||||
public function getCloneUrl(string $repo): string;
|
||||
|
||||
/**
|
||||
* Delete a file from a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path
|
||||
* @param string $sha SHA of the file to delete
|
||||
* @param string $message Commit message
|
||||
* @param string|null $branch Target branch (null = default branch)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array;
|
||||
/**
|
||||
* Clone a repository to a local path.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function cloneRepo(string $repo, string $path, array $options = []): bool;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List pull requests for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, head, base, sort, direction)
|
||||
* @return array<int, array<string, mixed>> Pull request list
|
||||
*/
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array;
|
||||
/**
|
||||
* Get file contents from a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path within the repository
|
||||
* @param string|null $ref Branch/tag/SHA reference (null = default branch)
|
||||
* @return array<string, mixed> File data (content is base64-encoded)
|
||||
*/
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array;
|
||||
|
||||
/**
|
||||
* Create a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $title PR title
|
||||
* @param string $head Source branch
|
||||
* @param string $base Target branch
|
||||
* @param string $body PR description
|
||||
* @param array<string, mixed> $options Additional options (labels, assignees, etc.)
|
||||
* @return array<string, mixed> Created PR data
|
||||
*/
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array;
|
||||
/**
|
||||
* Create or update a file in a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path
|
||||
* @param string $content Raw file content (will be base64-encoded internally)
|
||||
* @param string $message Commit message
|
||||
* @param string|null $sha SHA of existing file (null = create new, string = update existing)
|
||||
* @param string|null $branch Target branch (null = default branch)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Update a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number PR number
|
||||
* @param array<string, mixed> $data Fields to update (title, body, state, etc.)
|
||||
* @return array<string, mixed> Updated PR data
|
||||
*/
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array;
|
||||
/**
|
||||
* Delete a file from a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path
|
||||
* @param string $sha SHA of the file to delete
|
||||
* @param string $message Commit message
|
||||
* @param string|null $branch Target branch (null = default branch)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List issues for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, labels, assignee, etc.)
|
||||
* @return array<int, array<string, mixed>> Issue list
|
||||
*/
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array;
|
||||
/**
|
||||
* List pull requests for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, head, base, sort, direction)
|
||||
* @return array<mixed> Pull request list
|
||||
*/
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array;
|
||||
|
||||
/**
|
||||
* Create an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $title Issue title
|
||||
* @param string $body Issue body
|
||||
* @param array<string, mixed> $options Additional options (labels, assignees, milestone)
|
||||
* @return array<string, mixed> Created issue data
|
||||
*/
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array;
|
||||
/**
|
||||
* Create a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $title PR title
|
||||
* @param string $head Source branch
|
||||
* @param string $base Target branch
|
||||
* @param string $body PR description
|
||||
* @param array<string, mixed> $options Additional options (labels, assignees, etc.)
|
||||
* @return array<string, mixed> Created PR data
|
||||
*/
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Add a comment to an issue or PR.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue/PR number
|
||||
* @param string $body Comment body
|
||||
* @return array<string, mixed> Created comment data
|
||||
*/
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array;
|
||||
/**
|
||||
* Update a pull request.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number PR number
|
||||
* @param array<string, mixed> $data Fields to update (title, body, state, etc.)
|
||||
* @return array<string, mixed> Updated PR data
|
||||
*/
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array;
|
||||
|
||||
/**
|
||||
* Close an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue number
|
||||
* @return array<string, mixed> Updated issue data
|
||||
*/
|
||||
public function closeIssue(string $org, string $repo, int $number): array;
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
/**
|
||||
* List issues for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, labels, assignee, etc.)
|
||||
* @return array<mixed> Issue list
|
||||
*/
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array;
|
||||
|
||||
/**
|
||||
* List labels for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<int, array{name: string, color: string, description: string}> Label list
|
||||
*/
|
||||
public function listLabels(string $org, string $repo): array;
|
||||
/**
|
||||
* Create an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $title Issue title
|
||||
* @param string $body Issue body
|
||||
* @param array<string, mixed> $options Additional options (labels, assignees, milestone)
|
||||
* @return array<string, mixed> Created issue data
|
||||
*/
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Create a label.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $name Label name
|
||||
* @param string $color Hex color (without #)
|
||||
* @param string $description Label description
|
||||
* @return array<string, mixed> Created label data
|
||||
*/
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array;
|
||||
/**
|
||||
* Add a comment to an issue or PR.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue/PR number
|
||||
* @param string $body Comment body
|
||||
* @return array<string, mixed> Created comment data
|
||||
*/
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array;
|
||||
|
||||
/**
|
||||
* Add labels to an issue or PR.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue/PR number
|
||||
* @param array<string> $labels Label names (GitHub) or label IDs (Gitea)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array;
|
||||
/**
|
||||
* Close an issue.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue number
|
||||
* @return array<string, mixed> Updated issue data
|
||||
*/
|
||||
public function closeIssue(string $org, string $repo, int $number): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set branch protection rules.
|
||||
*
|
||||
* On GitHub this maps to rulesets; on Gitea to branch_protections.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch name or pattern
|
||||
* @param array<string, mixed> $rules Protection rules (required_reviews, dismiss_stale, etc.)
|
||||
* @return array<string, mixed> Created/updated protection data
|
||||
*/
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array;
|
||||
/**
|
||||
* List labels for a repository.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<mixed> Label list
|
||||
*/
|
||||
public function listLabels(string $org, string $repo): array;
|
||||
|
||||
/**
|
||||
* List branch protection rules.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<int, array<string, mixed>> Protection rules
|
||||
*/
|
||||
public function listBranchProtections(string $org, string $repo): array;
|
||||
/**
|
||||
* Create a label.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $name Label name
|
||||
* @param string $color Hex color (without #)
|
||||
* @param string $description Label description
|
||||
* @return array<string, mixed> Created label data
|
||||
*/
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
/**
|
||||
* Add labels to an issue or PR.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param int $number Issue/PR number
|
||||
* @param array<string> $labels Label names (GitHub) or label IDs (Gitea)
|
||||
* @return array<string, mixed> API response
|
||||
*/
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array;
|
||||
|
||||
/**
|
||||
* Resolve a tag or branch name to a commit SHA.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $ref Tag or branch name (e.g. 'v1.0.0', 'main')
|
||||
* @return string Full commit SHA
|
||||
*/
|
||||
public function resolveRef(string $org, string $repo, string $ref): string;
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the repository tree (recursive file listing).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $ref Tree SHA or branch (e.g. 'HEAD', 'main')
|
||||
* @param bool $recursive Whether to recurse into subdirectories
|
||||
* @return array<int, array{path: string, type: string, sha: string}> Tree entries
|
||||
*/
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array;
|
||||
/**
|
||||
* Set branch protection rules.
|
||||
*
|
||||
* On GitHub this maps to rulesets; on Gitea to branch_protections.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch name or pattern
|
||||
* @param array<string, mixed> $rules Protection rules (required_reviews, dismiss_stale, etc.)
|
||||
* @return array<string, mixed> Created/updated protection data
|
||||
*/
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
/**
|
||||
* List branch protection rules.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<mixed> Protection rules
|
||||
*/
|
||||
public function listBranchProtections(string $org, string $repo): array;
|
||||
|
||||
/**
|
||||
* Paginate through all pages of a list endpoint.
|
||||
*
|
||||
* @param string $endpoint API endpoint path
|
||||
* @param array<string, mixed> $params Query parameters
|
||||
* @param int $perPage Items per page (platform default if 0)
|
||||
* @return array<int, array<string, mixed>> All items across all pages
|
||||
*/
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array;
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration (Gitea-specific, no-op on GitHub)
|
||||
// ──────────────────────────────────────────────
|
||||
/**
|
||||
* Resolve a tag or branch name to a commit SHA.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $ref Tag or branch name (e.g. 'v1.0.0', 'main')
|
||||
* @return string Full commit SHA
|
||||
*/
|
||||
public function resolveRef(string $org, string $repo, string $ref): string;
|
||||
|
||||
/**
|
||||
* Migrate a repository from an external service.
|
||||
*
|
||||
* On Gitea, this calls POST /api/v1/repos/migrate.
|
||||
* On GitHub, this is a no-op (throws UnsupportedOperationException).
|
||||
*
|
||||
* @param array<string, mixed> $options Migration options (clone_addr, service, auth_token, etc.)
|
||||
* @return array<string, mixed> Migrated repository data
|
||||
* @throws \RuntimeException If the platform does not support migration
|
||||
*/
|
||||
public function migrateRepository(array $options): array;
|
||||
/**
|
||||
* Get the repository tree (recursive file listing).
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param string $ref Tree SHA or branch (e.g. 'HEAD', 'main')
|
||||
* @param bool $recursive Whether to recurse into subdirectories
|
||||
* @return array<int, array{path: string, type: string, sha: string}> Tree entries
|
||||
*/
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level API access
|
||||
// ──────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the underlying ApiClient instance.
|
||||
*
|
||||
* Escape hatch for operations not covered by this interface.
|
||||
* Prefer adding new interface methods over using this directly.
|
||||
*
|
||||
* @return ApiClient The wrapped API client
|
||||
*/
|
||||
public function getApiClient(): ApiClient;
|
||||
/**
|
||||
* Paginate through all pages of a list endpoint.
|
||||
*
|
||||
* @param string $endpoint API endpoint path
|
||||
* @param array<string, mixed> $params Query parameters
|
||||
* @param int $perPage Items per page (platform default if 0)
|
||||
* @return array<mixed> All items across all pages
|
||||
*/
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration (Gitea-specific, no-op on GitHub)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Migrate a repository from an external service.
|
||||
*
|
||||
* On Gitea, this calls POST /api/v1/repos/migrate.
|
||||
* On GitHub, this is a no-op (throws UnsupportedOperationException).
|
||||
*
|
||||
* @param array<string, mixed> $options Migration options (clone_addr, service, auth_token, etc.)
|
||||
* @return array<string, mixed> Migrated repository data
|
||||
* @throws \RuntimeException If the platform does not support migration
|
||||
*/
|
||||
public function migrateRepository(array $options): array;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level API access
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the underlying ApiClient instance.
|
||||
*
|
||||
* Escape hatch for operations not covered by this interface.
|
||||
* Prefer adding new interface methods over using this directly.
|
||||
*
|
||||
* @return ApiClient The wrapped API client
|
||||
*/
|
||||
public function getApiClient(): ApiClient;
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ class InputValidator
|
||||
// Remove dangerous shell characters
|
||||
$dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', "\n", "\r"];
|
||||
$sanitized = str_replace($dangerousChars, '', $input);
|
||||
|
||||
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ class InputValidator
|
||||
// Remove SQL injection patterns
|
||||
$dangerousPatterns = ["'", '"', '--', '/*', '*/', 'xp_', 'sp_'];
|
||||
$sanitized = str_replace($dangerousPatterns, '', $input);
|
||||
|
||||
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/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.Enterprise
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /lib/Enterprise/ManifestReader.php
|
||||
* BRIEF: Read and parse .mokogitea/manifest.xml — shared across all CLI tools
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Manifest Reader
|
||||
*
|
||||
* Parses .mokogitea/manifest.xml and provides typed access to all fields.
|
||||
* Used by CLI tools and the Enterprise library to determine platform,
|
||||
* build configuration, and deployment settings from the repository manifest.
|
||||
*
|
||||
* @since 09.01.00
|
||||
*/
|
||||
class ManifestReader
|
||||
{
|
||||
/** @var array<string, string> Parsed manifest fields */
|
||||
private array $fields = [];
|
||||
|
||||
/** @var bool Whether a manifest was found and parsed */
|
||||
private bool $loaded = false;
|
||||
|
||||
/**
|
||||
* Load manifest from a repository root directory.
|
||||
*
|
||||
* @param string $root Repository root path
|
||||
* @return self
|
||||
*/
|
||||
public static function fromPath(string $root): self
|
||||
{
|
||||
$reader = new self();
|
||||
$reader->load($root);
|
||||
return $reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the manifest file.
|
||||
*
|
||||
* @param string $root Repository root path
|
||||
*/
|
||||
public function load(string $root): void
|
||||
{
|
||||
$candidates = [
|
||||
"{$root}/.mokogitea/manifest.xml",
|
||||
"{$root}/.mokogitea/.manifest.xml",
|
||||
"{$root}/.mokogitea/.moko-platform",
|
||||
];
|
||||
|
||||
$manifestFile = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if (file_exists($candidate)) {
|
||||
$manifestFile = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
if ($xml === false) {
|
||||
// Fallback: YAML legacy format
|
||||
$content = file_get_contents($manifestFile);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$this->fields['platform'] = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
$this->loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fields = [
|
||||
'name' => (string)($xml->identity->name ?? ''),
|
||||
'org' => (string)($xml->identity->org ?? ''),
|
||||
'description' => (string)($xml->identity->description ?? ''),
|
||||
'license' => (string)($xml->identity->license ?? ''),
|
||||
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
|
||||
'version' => (string)($xml->identity->version ?? ''),
|
||||
'platform' => (string)($xml->governance->platform ?? ''),
|
||||
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
|
||||
'language' => (string)($xml->build->language ?? ''),
|
||||
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
|
||||
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
|
||||
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
|
||||
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
|
||||
];
|
||||
|
||||
// Strip empty values
|
||||
$this->fields = array_filter($this->fields, fn($v) => $v !== '');
|
||||
$this->loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a manifest was found and loaded.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isLoaded(): bool
|
||||
{
|
||||
return $this->loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single field value.
|
||||
*
|
||||
* @param string $key Field name (e.g. 'platform', 'package-type')
|
||||
* @param string $default Default value if field is missing
|
||||
* @return string
|
||||
*/
|
||||
public function get(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->fields[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform slug, normalized to canonical values.
|
||||
*
|
||||
* @return string One of: joomla, dolibarr, generic, mcp, nodejs
|
||||
*/
|
||||
public function getPlatform(): string
|
||||
{
|
||||
$raw = $this->get('platform', 'generic');
|
||||
return match ($raw) {
|
||||
'waas-component' => 'joomla',
|
||||
'crm-module' => 'dolibarr',
|
||||
default => $raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source/entry-point directory.
|
||||
*
|
||||
* @param string $root Repository root for existence checking
|
||||
* @return string Resolved source directory path (e.g. 'src', 'htdocs')
|
||||
*/
|
||||
public function getSourceDir(string $root = ''): string
|
||||
{
|
||||
$entryPoint = $this->get('entry-point', '');
|
||||
if ($entryPoint !== '') {
|
||||
// Strip trailing filename (e.g. src/index.ts → src)
|
||||
$dir = rtrim(dirname($entryPoint) === '.' ? $entryPoint : dirname($entryPoint), '/');
|
||||
if ($root === '' || is_dir("{$root}/{$dir}")) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check common directories
|
||||
if ($root !== '') {
|
||||
if (is_dir("{$root}/src")) {
|
||||
return 'src';
|
||||
}
|
||||
if (is_dir("{$root}/htdocs")) {
|
||||
return 'htdocs';
|
||||
}
|
||||
}
|
||||
|
||||
return 'src';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package type for build decisions.
|
||||
*
|
||||
* @return string e.g. 'package', 'dolibarr', 'generic', 'mcp-server'
|
||||
*/
|
||||
public function getPackageType(): string
|
||||
{
|
||||
return $this->get('package-type', 'generic');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all parsed fields.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,12 @@ declare(strict_types=1);
|
||||
* $metrics = new MetricsCollector('my_service');
|
||||
* $metrics->increment('requests_total');
|
||||
* $metrics->setGauge('cpu_usage', 45.5);
|
||||
*
|
||||
*
|
||||
* // Timing operations
|
||||
* $timer = $metrics->startTimer('operation');
|
||||
* // ... do work ...
|
||||
* $timer->stop();
|
||||
*
|
||||
*
|
||||
* // Export for monitoring
|
||||
* echo $metrics->exportPrometheus();
|
||||
* ```
|
||||
@@ -59,6 +59,8 @@ use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Timer class for timing operations
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class MetricsTimer
|
||||
{
|
||||
@@ -79,13 +81,13 @@ class MetricsTimer
|
||||
{
|
||||
$duration = microtime(true) - $this->startTime;
|
||||
$this->collector->observe($this->metricName . '_duration_seconds', $duration, $this->labels);
|
||||
|
||||
|
||||
if ($success) {
|
||||
$this->collector->increment($this->metricName . '_success_total', 1, $this->labels);
|
||||
} else {
|
||||
$this->collector->increment($this->metricName . '_failure_total', 1, $this->labels);
|
||||
}
|
||||
|
||||
|
||||
return $duration;
|
||||
}
|
||||
}
|
||||
@@ -178,13 +180,13 @@ class MetricsCollector
|
||||
if (empty($labels)) {
|
||||
return $metricName;
|
||||
}
|
||||
|
||||
|
||||
ksort($labels);
|
||||
$labelPairs = [];
|
||||
foreach ($labels as $key => $value) {
|
||||
$labelPairs[] = sprintf('%s="%s"', $key, $value);
|
||||
}
|
||||
|
||||
|
||||
return sprintf('%s{%s}', $metricName, implode(',', $labelPairs));
|
||||
}
|
||||
|
||||
@@ -219,11 +221,11 @@ class MetricsCollector
|
||||
public function getHistogramStats(string $metricName): array
|
||||
{
|
||||
$values = $this->histograms[$metricName] ?? [];
|
||||
|
||||
|
||||
if (empty($values)) {
|
||||
return ['count' => 0, 'min' => 0.0, 'max' => 0.0, 'avg' => 0.0, 'sum' => 0.0];
|
||||
}
|
||||
|
||||
|
||||
$sum = array_sum($values);
|
||||
return [
|
||||
'count' => count($values),
|
||||
@@ -243,23 +245,23 @@ class MetricsCollector
|
||||
{
|
||||
$lines = [];
|
||||
$now = new DateTime('now', new DateTimeZone('UTC'));
|
||||
|
||||
|
||||
$lines[] = sprintf('# Metrics for %s', $this->serviceName);
|
||||
$lines[] = sprintf('# Generated at %s', $now->format('c'));
|
||||
$lines[] = '';
|
||||
|
||||
|
||||
// Export counters
|
||||
foreach ($this->counters as $key => $value) {
|
||||
$lines[] = sprintf('# TYPE %s counter', $this->stripLabels($key));
|
||||
$lines[] = sprintf('%s %d', $key, $value);
|
||||
}
|
||||
|
||||
|
||||
// Export gauges
|
||||
foreach ($this->gauges as $key => $value) {
|
||||
$lines[] = sprintf('# TYPE %s gauge', $this->stripLabels($key));
|
||||
$lines[] = sprintf('%s %s', $key, $value);
|
||||
}
|
||||
|
||||
|
||||
// Export histograms
|
||||
foreach ($this->histograms as $key => $values) {
|
||||
if (!empty($values)) {
|
||||
@@ -272,12 +274,12 @@ class MetricsCollector
|
||||
$lines[] = sprintf('%s_avg %s', $key, $stats['avg']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add uptime
|
||||
$uptime = microtime(true) - $this->startTime;
|
||||
$lines[] = '# TYPE process_uptime_seconds gauge';
|
||||
$lines[] = sprintf('process_uptime_seconds %.2f', $uptime);
|
||||
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
@@ -301,7 +303,7 @@ class MetricsCollector
|
||||
echo "\n" . str_repeat('=', 60) . "\n";
|
||||
echo "Metrics Summary for {$this->serviceName}\n";
|
||||
echo str_repeat('=', 60) . "\n";
|
||||
|
||||
|
||||
if (!empty($this->counters)) {
|
||||
echo "\nCounters:\n";
|
||||
ksort($this->counters);
|
||||
@@ -309,7 +311,7 @@ class MetricsCollector
|
||||
echo " {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!empty($this->gauges)) {
|
||||
echo "\nGauges:\n";
|
||||
ksort($this->gauges);
|
||||
@@ -317,7 +319,7 @@ class MetricsCollector
|
||||
echo " {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!empty($this->histograms)) {
|
||||
echo "\nHistograms:\n";
|
||||
$keys = array_keys($this->histograms);
|
||||
@@ -331,7 +333,7 @@ class MetricsCollector
|
||||
echo sprintf(" Avg: %.4f\n", $stats['avg']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$uptime = microtime(true) - $this->startTime;
|
||||
echo sprintf("\nUptime: %.2f seconds\n", $uptime);
|
||||
echo str_repeat('=', 60) . "\n\n";
|
||||
|
||||
+490
-461
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -33,468 +34,496 @@ use RuntimeException;
|
||||
* - Workflow dir: .mokogitea/workflows
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
* @version 04.06.10
|
||||
* @since 04.06.10
|
||||
* @see GitPlatformAdapter
|
||||
*/
|
||||
class MokoGiteaAdapter implements GitPlatformAdapter
|
||||
{
|
||||
private ApiClient $apiClient;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
return 'gitea';
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
public function getWorkflowDir(): string
|
||||
{
|
||||
return '.mokogitea/workflows';
|
||||
}
|
||||
|
||||
public function getMetadataDir(): string
|
||||
{
|
||||
return '.mokogitea';
|
||||
}
|
||||
|
||||
public function getRepoWebUrl(string $org, string $repo): string
|
||||
{
|
||||
// Derive web URL from API base URL by stripping '/api/v1'
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}";
|
||||
}
|
||||
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
// Gitea uses /pulls/ (not /pull/) for web UI
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/pulls/{$number}";
|
||||
}
|
||||
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/issues/{$number}";
|
||||
}
|
||||
|
||||
public function listBranches(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/branches");
|
||||
}
|
||||
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string
|
||||
{
|
||||
// Gitea uses /src/branch/ (not /tree/) for web UI
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/src/branch/{$branch}";
|
||||
}
|
||||
|
||||
public function getStepSummaryEnvVar(): string
|
||||
{
|
||||
return 'GITEA_STEP_SUMMARY';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array
|
||||
{
|
||||
$all = $this->paginateAll("/orgs/{$org}/repos");
|
||||
|
||||
$repos = [];
|
||||
foreach ($all as $repo) {
|
||||
if ($skipArchived && ($repo['archived'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$repos[] = [
|
||||
'name' => $repo['name'],
|
||||
'full_name' => $repo['full_name'],
|
||||
'archived' => $repo['archived'] ?? false,
|
||||
'private' => $repo['private'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
public function getRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}");
|
||||
}
|
||||
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array
|
||||
{
|
||||
$data = array_merge([
|
||||
'name' => $name,
|
||||
'auto_init' => true,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/orgs/{$org}/repos", $data);
|
||||
}
|
||||
|
||||
public function archiveRepo(string $org, string $repo): array
|
||||
{
|
||||
// Gitea uses PATCH with archived flag, same as GitHub
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
|
||||
'archived' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void
|
||||
{
|
||||
// Gitea uses {"topics": [...]} not {"names": [...]}
|
||||
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
|
||||
'topics' => $topics,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRepoTopics(string $org, string $repo): array
|
||||
{
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
|
||||
return $response['topics'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
|
||||
{
|
||||
$params = [];
|
||||
if ($ref !== null) {
|
||||
$params['ref'] = $ref;
|
||||
}
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
|
||||
}
|
||||
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array {
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'content' => base64_encode($content),
|
||||
];
|
||||
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
|
||||
if ($sha !== null) {
|
||||
// Update existing file — Gitea uses PUT with SHA
|
||||
$data['sha'] = $sha;
|
||||
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
|
||||
// Create new file — Gitea uses POST
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array {
|
||||
// Gitea's delete uses the same endpoint but with DELETE method
|
||||
// ApiClient::delete() doesn't support a body, so we use the raw approach
|
||||
// For now, this matches GitHubAdapter's limitation
|
||||
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
|
||||
}
|
||||
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
|
||||
}
|
||||
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
|
||||
}
|
||||
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
// Gitea expects label IDs (int64), not names. Resolve if needed.
|
||||
if (!empty($options['labels']) && is_string($options['labels'][0] ?? null)) {
|
||||
$labelNames = $options['labels'];
|
||||
$existing = $this->listLabels($org, $repo);
|
||||
$nameToId = [];
|
||||
foreach ($existing as $label) {
|
||||
$nameToId[$label['name']] = $label['id'];
|
||||
}
|
||||
$options['labels'] = [];
|
||||
foreach ($labelNames as $name) {
|
||||
if (isset($nameToId[$name])) {
|
||||
$options['labels'][] = $nameToId[$name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeIssue(string $org, string $repo, int $number): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
|
||||
'state' => 'closed',
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listLabels(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
|
||||
}
|
||||
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
|
||||
{
|
||||
// Gitea expects color with # prefix
|
||||
$color = ltrim($color, '#');
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
|
||||
'name' => $name,
|
||||
'color' => '#' . $color,
|
||||
'description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
|
||||
{
|
||||
// Gitea requires label IDs, not names. Resolve names to IDs first.
|
||||
$allLabels = $this->listLabels($org, $repo);
|
||||
$labelMap = [];
|
||||
foreach ($allLabels as $label) {
|
||||
$labelMap[$label['name']] = $label['id'];
|
||||
}
|
||||
|
||||
$labelIds = [];
|
||||
foreach ($labels as $label) {
|
||||
if (is_int($label)) {
|
||||
$labelIds[] = $label;
|
||||
} elseif (isset($labelMap[$label])) {
|
||||
$labelIds[] = $labelMap[$label];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($labelIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
|
||||
'labels' => $labelIds,
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
|
||||
{
|
||||
// Gitea uses a flat branch protection API
|
||||
$protection = [
|
||||
'branch_name' => $branch,
|
||||
'enable_push' => true,
|
||||
'enable_push_whitelist' => false,
|
||||
'enable_merge_whitelist' => false,
|
||||
'enable_status_check' => $rules['required_status_checks'] ?? false,
|
||||
'enable_approvals_whitelist' => false,
|
||||
'required_approvals' => $rules['required_reviews'] ?? 0,
|
||||
'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false,
|
||||
'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true,
|
||||
'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false,
|
||||
'block_on_official_review_requests' => false,
|
||||
];
|
||||
|
||||
// Check if protection already exists for this branch
|
||||
try {
|
||||
$existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}");
|
||||
if (!empty($existing)) {
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection);
|
||||
}
|
||||
|
||||
public function listBranchProtections(string $org, string $repo): array
|
||||
{
|
||||
try {
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections");
|
||||
} catch (Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function resolveRef(string $org, string $repo, string $ref): string
|
||||
{
|
||||
// Try as a tag first
|
||||
try {
|
||||
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}");
|
||||
// Gitea tag objects have a 'commit' field with the SHA
|
||||
if (isset($tag['commit']['sha'])) {
|
||||
return $tag['commit']['sha'];
|
||||
}
|
||||
return $tag['id'] ?? $tag['sha'] ?? '';
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
// Try as a branch
|
||||
try {
|
||||
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}");
|
||||
return $branch['commit']['id'] ?? '';
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
// Last resort: try git/refs endpoint
|
||||
$refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}");
|
||||
return $refData['object']['sha'] ?? '';
|
||||
}
|
||||
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
|
||||
{
|
||||
$params = $recursive ? ['recursive' => 'true'] : [];
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
|
||||
return $response['tree'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
// Gitea uses 'limit' instead of 'per_page'
|
||||
$params['limit'] = $perPage;
|
||||
|
||||
while (true) {
|
||||
$params['page'] = $page;
|
||||
$response = $this->apiClient->get($endpoint, $params);
|
||||
|
||||
if (empty($response)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$all = array_merge($all, $response);
|
||||
|
||||
// If we got fewer results than the limit, we've reached the end
|
||||
if (count($response) < $perPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function migrateRepository(array $options): array
|
||||
{
|
||||
// Gitea's built-in migration endpoint
|
||||
$data = array_merge([
|
||||
'service' => 'github',
|
||||
'issues' => true,
|
||||
'labels' => true,
|
||||
'milestones' => true,
|
||||
'releases' => true,
|
||||
'wiki' => false,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post('/repos/migrate', $data);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getApiClient(): ApiClient
|
||||
{
|
||||
return $this->apiClient;
|
||||
}
|
||||
/** @var ApiClient HTTP client for Gitea API calls. */
|
||||
private ApiClient $apiClient;
|
||||
|
||||
/** @var string Base URL for Gitea API (e.g. https://git.mokoconsulting.tech/api/v1). */
|
||||
private string $baseUrl;
|
||||
|
||||
/**
|
||||
* @param ApiClient $apiClient Configured API client
|
||||
* @param string $baseUrl Gitea API base URL
|
||||
*/
|
||||
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
return 'gitea';
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
public function getWorkflowDir(): string
|
||||
{
|
||||
return '.mokogitea/workflows';
|
||||
}
|
||||
|
||||
public function getMetadataDir(): string
|
||||
{
|
||||
return '.mokogitea';
|
||||
}
|
||||
|
||||
public function getRepoWebUrl(string $org, string $repo): string
|
||||
{
|
||||
// Derive web URL from API base URL by stripping '/api/v1'
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}";
|
||||
}
|
||||
|
||||
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
// Gitea uses /pulls/ (not /pull/) for web UI
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/pulls/{$number}";
|
||||
}
|
||||
|
||||
public function getIssueWebUrl(string $org, string $repo, int $number): string
|
||||
{
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/issues/{$number}";
|
||||
}
|
||||
|
||||
public function listBranches(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/branches");
|
||||
}
|
||||
|
||||
public function getBranchWebUrl(string $org, string $repo, string $branch): string
|
||||
{
|
||||
// Gitea uses /src/branch/ (not /tree/) for web UI
|
||||
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
|
||||
return "{$webBase}/{$org}/{$repo}/src/branch/{$branch}";
|
||||
}
|
||||
|
||||
public function getStepSummaryEnvVar(): string
|
||||
{
|
||||
return 'GITEA_STEP_SUMMARY';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Repository CRUD
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listOrgRepos(string $org, bool $skipArchived = false): array
|
||||
{
|
||||
$all = $this->paginateAll("/orgs/{$org}/repos");
|
||||
|
||||
$repos = [];
|
||||
foreach ($all as $repo) {
|
||||
if ($skipArchived && ($repo['archived'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$repos[] = [
|
||||
'name' => $repo['name'],
|
||||
'full_name' => $repo['full_name'],
|
||||
'archived' => $repo['archived'] ?? false,
|
||||
'private' => $repo['private'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
public function getRepo(string $org, string $repo): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}");
|
||||
}
|
||||
|
||||
public function createOrgRepo(string $org, string $name, array $options = []): array
|
||||
{
|
||||
$data = array_merge([
|
||||
'name' => $name,
|
||||
'auto_init' => true,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/orgs/{$org}/repos", $data);
|
||||
}
|
||||
|
||||
public function archiveRepo(string $org, string $repo): array
|
||||
{
|
||||
// Gitea uses PATCH with archived flag, same as GitHub
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
|
||||
'archived' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function setRepoTopics(string $org, string $repo, array $topics): void
|
||||
{
|
||||
// Gitea uses {"topics": [...]} not {"names": [...]}
|
||||
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
|
||||
'topics' => $topics,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRepoTopics(string $org, string $repo): array
|
||||
{
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
|
||||
return $response['topics'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// File Contents
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
|
||||
{
|
||||
$params = [];
|
||||
if ($ref !== null) {
|
||||
$params['ref'] = $ref;
|
||||
}
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
|
||||
}
|
||||
|
||||
public function createOrUpdateFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $content,
|
||||
string $message,
|
||||
?string $sha = null,
|
||||
?string $branch = null
|
||||
): array {
|
||||
$data = [
|
||||
'message' => $message,
|
||||
'content' => base64_encode($content),
|
||||
];
|
||||
|
||||
if ($branch !== null) {
|
||||
$data['branch'] = $branch;
|
||||
}
|
||||
|
||||
if ($sha !== null) {
|
||||
// Update existing file — Gitea uses PUT with SHA
|
||||
$data['sha'] = $sha;
|
||||
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
|
||||
// Create new file — Gitea uses POST
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data);
|
||||
}
|
||||
|
||||
public function deleteFile(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $path,
|
||||
string $sha,
|
||||
string $message,
|
||||
?string $branch = null
|
||||
): array {
|
||||
// Gitea's delete uses the same endpoint but with DELETE method
|
||||
// ApiClient::delete() doesn't support a body, so we use the raw approach
|
||||
// For now, this matches GitHubAdapter's limitation
|
||||
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pull Requests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
|
||||
}
|
||||
|
||||
public function createPullRequest(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $head,
|
||||
string $base,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
|
||||
}
|
||||
|
||||
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Issues
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array
|
||||
{
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
|
||||
}
|
||||
|
||||
public function createIssue(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $options = []
|
||||
): array {
|
||||
// Gitea expects label IDs (int64), not names. Resolve if needed.
|
||||
if (!empty($options['labels']) && is_string($options['labels'][0] ?? null)) {
|
||||
$labelNames = $options['labels'];
|
||||
$existing = $this->listLabels($org, $repo);
|
||||
$nameToId = [];
|
||||
foreach ($existing as $label) {
|
||||
$nameToId[$label['name']] = $label['id'];
|
||||
}
|
||||
$options['labels'] = [];
|
||||
foreach ($labelNames as $name) {
|
||||
if (isset($nameToId[$name])) {
|
||||
$options['labels'][] = $nameToId[$name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = array_merge([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
public function addIssueComment(string $org, string $repo, int $number, string $body): array
|
||||
{
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeIssue(string $org, string $repo, int $number): array
|
||||
{
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
|
||||
'state' => 'closed',
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Labels
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function listLabels(string $org, string $repo): array
|
||||
{
|
||||
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
|
||||
}
|
||||
|
||||
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
|
||||
{
|
||||
// Gitea expects color with # prefix
|
||||
$color = ltrim($color, '#');
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
|
||||
'name' => $name,
|
||||
'color' => '#' . $color,
|
||||
'description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
|
||||
{
|
||||
// Gitea requires label IDs, not names. Resolve names to IDs first.
|
||||
$allLabels = $this->listLabels($org, $repo);
|
||||
$labelMap = [];
|
||||
foreach ($allLabels as $label) {
|
||||
$labelMap[$label['name']] = $label['id'];
|
||||
}
|
||||
|
||||
$labelIds = [];
|
||||
foreach ($labels as $label) {
|
||||
if (is_int($label)) {
|
||||
$labelIds[] = $label;
|
||||
} elseif (isset($labelMap[$label])) {
|
||||
$labelIds[] = $labelMap[$label];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($labelIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
|
||||
'labels' => $labelIds,
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Branch Protection
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
|
||||
{
|
||||
// Gitea uses a flat branch protection API
|
||||
$protection = [
|
||||
'branch_name' => $branch,
|
||||
'enable_push' => true,
|
||||
'enable_push_whitelist' => false,
|
||||
'enable_merge_whitelist' => false,
|
||||
'enable_status_check' => $rules['required_status_checks'] ?? false,
|
||||
'enable_approvals_whitelist' => false,
|
||||
'required_approvals' => $rules['required_reviews'] ?? 0,
|
||||
'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false,
|
||||
'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true,
|
||||
'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false,
|
||||
'block_on_official_review_requests' => false,
|
||||
];
|
||||
|
||||
// Check if protection already exists for this branch
|
||||
try {
|
||||
$existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}");
|
||||
if (!empty($existing)) {
|
||||
return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection);
|
||||
}
|
||||
|
||||
public function listBranchProtections(string $org, string $repo): array
|
||||
{
|
||||
try {
|
||||
return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections");
|
||||
} catch (Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Git Refs
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function resolveRef(string $org, string $repo, string $ref): string
|
||||
{
|
||||
// Try as a tag first
|
||||
try {
|
||||
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}");
|
||||
// Gitea tag objects have a 'commit' field with the SHA
|
||||
if (isset($tag['commit']['sha'])) {
|
||||
return $tag['commit']['sha'];
|
||||
}
|
||||
return $tag['id'] ?? $tag['sha'] ?? '';
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
// Try as a branch
|
||||
try {
|
||||
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}");
|
||||
return $branch['commit']['id'] ?? '';
|
||||
} catch (Exception $e) {
|
||||
$this->apiClient->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
// Last resort: try git/refs endpoint
|
||||
$refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}");
|
||||
return $refData['object']['sha'] ?? '';
|
||||
}
|
||||
|
||||
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
|
||||
{
|
||||
$params = $recursive ? ['recursive' => 'true'] : [];
|
||||
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
|
||||
return $response['tree'] ?? [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Pagination
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
// Gitea uses 'limit' instead of 'per_page'
|
||||
$params['limit'] = $perPage;
|
||||
|
||||
while (true) {
|
||||
$params['page'] = $page;
|
||||
$response = $this->apiClient->get($endpoint, $params);
|
||||
|
||||
if (empty($response)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$all = array_merge($all, $response);
|
||||
|
||||
// If we got fewer results than the limit, we've reached the end
|
||||
if (count($response) < $perPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
return array_values($all);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Migration
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function migrateRepository(array $options): array
|
||||
{
|
||||
// Gitea's built-in migration endpoint
|
||||
$data = array_merge([
|
||||
'service' => 'github',
|
||||
'issues' => true,
|
||||
'labels' => true,
|
||||
'milestones' => true,
|
||||
'releases' => true,
|
||||
'wiki' => false,
|
||||
], $options);
|
||||
|
||||
return $this->apiClient->post('/repos/migrate', $data);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Low-level
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function getApiClient(): ApiClient
|
||||
{
|
||||
return $this->apiClient;
|
||||
}
|
||||
|
||||
public function getCloneUrl(string $repo): string
|
||||
{
|
||||
$base = str_replace('/api/v1', '', $this->baseUrl);
|
||||
return "{$base}/{$repo}.git";
|
||||
}
|
||||
|
||||
public function cloneRepo(string $repo, string $path, array $options = []): bool
|
||||
{
|
||||
$url = $this->getCloneUrl($repo);
|
||||
$depth = $options['depth'] ?? 0;
|
||||
$depthFlag = $depth > 0 ? " --depth {$depth}" : '';
|
||||
$result = 0;
|
||||
passthru(
|
||||
'git clone' . $depthFlag . ' --quiet '
|
||||
. escapeshellarg($url) . ' ' . escapeshellarg($path),
|
||||
$result
|
||||
);
|
||||
return $result === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
|
||||
+223
-222
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -31,261 +32,261 @@ use ZipArchive;
|
||||
*/
|
||||
class PackageBuilder
|
||||
{
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a generic release package.
|
||||
*
|
||||
* Copies src/, admin/, site/, top-level *.xml files, LICENSE* files, and
|
||||
* CHANGELOG.md into a build staging directory, then archives them as
|
||||
* dist/<packageName>-<version>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $packageName Base name for the archive.
|
||||
* @param string $version Version string (e.g. "1.2.0").
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When the zip archive cannot be opened.
|
||||
*/
|
||||
public static function buildGeneric(
|
||||
string $repoRoot,
|
||||
string $packageName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$packageDir = $buildDir . '/' . $packageName;
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $packageName . '-' . $version . '.zip';
|
||||
/**
|
||||
* Build a generic release package.
|
||||
*
|
||||
* Copies src/, admin/, site/, top-level *.xml files, LICENSE* files, and
|
||||
* CHANGELOG.md into a build staging directory, then archives them as
|
||||
* dist/<packageName>-<version>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $packageName Base name for the archive.
|
||||
* @param string $version Version string (e.g. "1.2.0").
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When the zip archive cannot be opened.
|
||||
*/
|
||||
public static function buildGeneric(
|
||||
string $repoRoot,
|
||||
string $packageName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$packageDir = $buildDir . '/' . $packageName;
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $packageName . '-' . $version . '.zip';
|
||||
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($packageDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($packageDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
|
||||
foreach (['src', 'admin', 'site'] as $dir) {
|
||||
if (is_dir($repoRoot . '/' . $dir)) {
|
||||
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
|
||||
}
|
||||
}
|
||||
foreach (['src', 'admin', 'site'] as $dir) {
|
||||
if (is_dir($repoRoot . '/' . $dir)) {
|
||||
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (glob($repoRoot . '/*.xml') ?: [] as $xml) {
|
||||
copy($xml, $packageDir . '/' . basename($xml));
|
||||
}
|
||||
foreach (glob($repoRoot . '/*.xml') ?: [] as $xml) {
|
||||
copy($xml, $packageDir . '/' . basename($xml));
|
||||
}
|
||||
|
||||
foreach (glob($repoRoot . '/LICENSE*') ?: [] as $lic) {
|
||||
copy($lic, $packageDir . '/' . basename($lic));
|
||||
}
|
||||
foreach (glob($repoRoot . '/LICENSE*') ?: [] as $lic) {
|
||||
copy($lic, $packageDir . '/' . basename($lic));
|
||||
}
|
||||
|
||||
if (is_file($repoRoot . '/CHANGELOG.md')) {
|
||||
copy($repoRoot . '/CHANGELOG.md', $packageDir . '/CHANGELOG.md');
|
||||
}
|
||||
if (is_file($repoRoot . '/CHANGELOG.md')) {
|
||||
copy($repoRoot . '/CHANGELOG.md', $packageDir . '/CHANGELOG.md');
|
||||
}
|
||||
|
||||
self::zip($packageDir, $archivePath, $packageName);
|
||||
self::zip($packageDir, $archivePath, $packageName);
|
||||
|
||||
return $archivePath;
|
||||
}
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Dolibarr module release package.
|
||||
*
|
||||
* Copies everything under src/ into a build staging directory and archives
|
||||
* it as dist/<MODULE_NAME>_<VERSION>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $moduleName Module name (used in archive filename).
|
||||
* @param string $version Version string.
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When src/ is absent or archive creation fails.
|
||||
*/
|
||||
public static function buildDolibarr(
|
||||
string $repoRoot,
|
||||
string $moduleName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$srcDir = $repoRoot . '/src';
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
|
||||
/**
|
||||
* Build a Dolibarr module release package.
|
||||
*
|
||||
* Copies everything under src/ into a build staging directory and archives
|
||||
* it as dist/<MODULE_NAME>_<VERSION>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $moduleName Module name (used in archive filename).
|
||||
* @param string $version Version string.
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When src/ is absent or archive creation fails.
|
||||
*/
|
||||
public static function buildDolibarr(
|
||||
string $repoRoot,
|
||||
string $moduleName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$srcDir = $repoRoot . '/src';
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
|
||||
|
||||
if (!is_dir($srcDir)) {
|
||||
throw new \RuntimeException("src/ directory not found at {$srcDir}");
|
||||
}
|
||||
if (!is_dir($srcDir)) {
|
||||
throw new \RuntimeException("src/ directory not found at {$srcDir}");
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($buildDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($buildDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
|
||||
self::copyDirectory($srcDir, $buildDir);
|
||||
self::zip($buildDir, $archivePath, '');
|
||||
self::copyDirectory($srcDir, $buildDir);
|
||||
self::zip($buildDir, $archivePath, '');
|
||||
|
||||
return $archivePath;
|
||||
}
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Joomla component release package.
|
||||
*
|
||||
* Copies site/, admin/, optional media/ and language/ directories, and the
|
||||
* component XML manifest into a build staging directory, then archives as
|
||||
* dist/<componentName>_<version>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $componentName Component name, e.g. "com_example".
|
||||
* @param string $version Version string.
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When required directories are absent or archiving fails.
|
||||
*/
|
||||
public static function buildJoomla(
|
||||
string $repoRoot,
|
||||
string $componentName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $componentName . '_' . $version . '.zip';
|
||||
/**
|
||||
* Build a Joomla component release package.
|
||||
*
|
||||
* Copies site/, admin/, optional media/ and language/ directories, and the
|
||||
* component XML manifest into a build staging directory, then archives as
|
||||
* dist/<componentName>_<version>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $componentName Component name, e.g. "com_example".
|
||||
* @param string $version Version string.
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When required directories are absent or archiving fails.
|
||||
*/
|
||||
public static function buildJoomla(
|
||||
string $repoRoot,
|
||||
string $componentName,
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $componentName . '_' . $version . '.zip';
|
||||
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($buildDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
self::cleanDir($buildDir);
|
||||
self::cleanDir($distDir);
|
||||
mkdir($buildDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
|
||||
foreach (['site', 'admin'] as $required) {
|
||||
$src = $repoRoot . '/' . $required;
|
||||
if (!is_dir($src)) {
|
||||
throw new \RuntimeException("Required directory '{$required}/' not found at {$src}");
|
||||
}
|
||||
self::copyDirectory($src, $buildDir . '/' . $required);
|
||||
}
|
||||
foreach (['site', 'admin'] as $required) {
|
||||
$src = $repoRoot . '/' . $required;
|
||||
if (!is_dir($src)) {
|
||||
throw new \RuntimeException("Required directory '{$required}/' not found at {$src}");
|
||||
}
|
||||
self::copyDirectory($src, $buildDir . '/' . $required);
|
||||
}
|
||||
|
||||
foreach (['media', 'language'] as $optional) {
|
||||
$src = $repoRoot . '/' . $optional;
|
||||
if (is_dir($src)) {
|
||||
self::copyDirectory($src, $buildDir . '/' . $optional);
|
||||
}
|
||||
}
|
||||
foreach (['media', 'language'] as $optional) {
|
||||
$src = $repoRoot . '/' . $optional;
|
||||
if (is_dir($src)) {
|
||||
self::copyDirectory($src, $buildDir . '/' . $optional);
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = $repoRoot . '/' . $componentName . '.xml';
|
||||
if (is_file($manifest)) {
|
||||
copy($manifest, $buildDir . '/' . $componentName . '.xml');
|
||||
}
|
||||
$manifest = $repoRoot . '/' . $componentName . '.xml';
|
||||
if (is_file($manifest)) {
|
||||
copy($manifest, $buildDir . '/' . $componentName . '.xml');
|
||||
}
|
||||
|
||||
self::zip($buildDir, $archivePath, '');
|
||||
self::zip($buildDir, $archivePath, '');
|
||||
|
||||
return $archivePath;
|
||||
}
|
||||
return $archivePath;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove a directory if it exists, then recreate it.
|
||||
*
|
||||
* @param string $dir Directory path to clean.
|
||||
*/
|
||||
private static function cleanDir(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
self::deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Remove a directory if it exists, then recreate it.
|
||||
*
|
||||
* @param string $dir Directory path to clean.
|
||||
*/
|
||||
private static function cleanDir(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
self::deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a source directory to a destination.
|
||||
*
|
||||
* @param string $src Source directory path.
|
||||
* @param string $dst Destination directory path.
|
||||
*/
|
||||
private static function copyDirectory(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) {
|
||||
mkdir($dst, 0755, true);
|
||||
}
|
||||
/**
|
||||
* Recursively copy a source directory to a destination.
|
||||
*
|
||||
* @param string $src Source directory path.
|
||||
* @param string $dst Destination directory path.
|
||||
*/
|
||||
private static function copyDirectory(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) {
|
||||
mkdir($dst, 0755, true);
|
||||
}
|
||||
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$target = $dst . '/' . $iter->getSubPathname();
|
||||
if ($item->isDir()) {
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($iter as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$target = $dst . '/' . $iter->getSubPathname();
|
||||
if ($item->isDir()) {
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZIP archive from a source directory tree.
|
||||
*
|
||||
* @param string $sourceDir Directory to archive.
|
||||
* @param string $archivePath Destination archive path.
|
||||
* @param string $prefix Path prefix inside the archive (empty string for no prefix).
|
||||
* @throws \RuntimeException When the archive cannot be opened for writing.
|
||||
*/
|
||||
private static function zip(string $sourceDir, string $archivePath, string $prefix): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException("Cannot create archive: {$archivePath}");
|
||||
}
|
||||
/**
|
||||
* Create a ZIP archive from a source directory tree.
|
||||
*
|
||||
* @param string $sourceDir Directory to archive.
|
||||
* @param string $archivePath Destination archive path.
|
||||
* @param string $prefix Path prefix inside the archive (empty string for no prefix).
|
||||
* @throws \RuntimeException When the archive cannot be opened for writing.
|
||||
*/
|
||||
private static function zip(string $sourceDir, string $archivePath, string $prefix): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException("Cannot create archive: {$archivePath}");
|
||||
}
|
||||
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$rel = $iter->getSubPathname();
|
||||
$name = $prefix !== '' ? $prefix . '/' . $rel : $rel;
|
||||
if ($item->isFile()) {
|
||||
$zip->addFile($item->getPathname(), $name);
|
||||
} elseif ($item->isDir()) {
|
||||
$zip->addEmptyDir($name);
|
||||
}
|
||||
}
|
||||
foreach ($iter as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$rel = $iter->getSubPathname();
|
||||
$name = $prefix !== '' ? $prefix . '/' . $rel : $rel;
|
||||
if ($item->isFile()) {
|
||||
$zip->addFile($item->getPathname(), $name);
|
||||
} elseif ($item->isDir()) {
|
||||
$zip->addEmptyDir($name);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory and all its contents.
|
||||
*
|
||||
* @param string $dir Directory path.
|
||||
*/
|
||||
private static function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Recursively delete a directory and all its contents.
|
||||
*
|
||||
* @param string $dir Directory path.
|
||||
*/
|
||||
private static function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = array_diff((array) scandir($dir), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$path = $dir . '/' . $item;
|
||||
is_dir($path) ? self::deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
$items = array_diff((array) scandir($dir), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$path = $dir . '/' . $item;
|
||||
is_dir($path) ? self::deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -34,159 +35,161 @@ use RuntimeException;
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
* @version 04.06.10
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class PlatformAdapterFactory
|
||||
{
|
||||
/**
|
||||
* Create a GitPlatformAdapter based on configuration.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @param string|null $platformOverride Force a specific platform ('github' or 'gitea')
|
||||
* @return GitPlatformAdapter The constructed adapter
|
||||
* @throws RuntimeException If the platform is not supported or token is missing
|
||||
*/
|
||||
public static function create(Config $config, ?string $platformOverride = null): GitPlatformAdapter
|
||||
{
|
||||
$platform = $platformOverride ?? $config->getString('platform', 'gitea');
|
||||
/**
|
||||
* Create a GitPlatformAdapter based on configuration.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @param string|null $platformOverride Force a specific platform ('github' or 'gitea')
|
||||
* @return GitPlatformAdapter The constructed adapter
|
||||
* @throws RuntimeException If the platform is not supported or token is missing
|
||||
*/
|
||||
public static function create(Config $config, ?string $platformOverride = null): GitPlatformAdapter
|
||||
{
|
||||
$platform = $platformOverride ?? $config->getString('platform', 'gitea');
|
||||
|
||||
return match ($platform) {
|
||||
'github' => self::createGitHubAdapter($config),
|
||||
'gitea' => self::createMokoGiteaAdapter($config),
|
||||
default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."),
|
||||
};
|
||||
}
|
||||
return match ($platform) {
|
||||
'github' => self::createGitHubAdapter($config),
|
||||
'gitea' => self::createMokoGiteaAdapter($config),
|
||||
default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHubAdapter with configured ApiClient.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return GitHubAdapter Configured GitHub adapter
|
||||
* @throws RuntimeException If GitHub token is not available
|
||||
*/
|
||||
private static function createGitHubAdapter(Config $config): GitHubAdapter
|
||||
{
|
||||
$token = $config->getString('github.token', '');
|
||||
if (empty($token)) {
|
||||
throw new RuntimeException(
|
||||
'GitHub token not found. Set GH_TOKEN, GITHUB_TOKEN, or authenticate with `gh auth login`.'
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Create a GitHubAdapter with configured ApiClient.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return GitHubAdapter Configured GitHub adapter
|
||||
* @throws RuntimeException If GitHub token is not available
|
||||
*/
|
||||
private static function createGitHubAdapter(Config $config): GitHubAdapter
|
||||
{
|
||||
$token = $config->getString('github.token', '');
|
||||
if (empty($token)) {
|
||||
throw new RuntimeException(
|
||||
'GitHub token not found. Set GH_TOKEN, GITHUB_TOKEN, or authenticate with `gh auth login`.'
|
||||
);
|
||||
}
|
||||
|
||||
$apiClient = new ApiClient(
|
||||
baseUrl: 'https://git.mokoconsulting.tech/api/v1',
|
||||
authToken: $token,
|
||||
maxRequestsPerHour: $config->getInt('github.rate_limit', 5000),
|
||||
maxRetries: $config->getInt('github.max_retries', 3),
|
||||
authScheme: 'Bearer'
|
||||
);
|
||||
$apiClient = new ApiClient(
|
||||
baseUrl: 'https://git.mokoconsulting.tech/api/v1',
|
||||
authToken: $token,
|
||||
maxRequestsPerHour: $config->getInt('github.rate_limit', 5000),
|
||||
maxRetries: $config->getInt('github.max_retries', 3),
|
||||
authScheme: 'Bearer'
|
||||
);
|
||||
|
||||
return new GitHubAdapter($apiClient);
|
||||
}
|
||||
return new GitHubAdapter($apiClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MokoGiteaAdapter with configured ApiClient.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return MokoGiteaAdapter Configured Gitea adapter
|
||||
* @throws RuntimeException If Gitea token is not available
|
||||
*/
|
||||
private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter
|
||||
{
|
||||
$token = $config->getString('gitea.token', '');
|
||||
if (empty($token)) {
|
||||
throw new RuntimeException(
|
||||
'Gitea token not found. Set GA_TOKEN environment variable.'
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Create a MokoGiteaAdapter with configured ApiClient.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return MokoGiteaAdapter Configured Gitea adapter
|
||||
* @throws RuntimeException If Gitea token is not available
|
||||
*/
|
||||
private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter
|
||||
{
|
||||
$token = $config->getString('gitea.token', '');
|
||||
if (empty($token)) {
|
||||
throw new RuntimeException(
|
||||
'Gitea token not found. Set GA_TOKEN environment variable.'
|
||||
);
|
||||
}
|
||||
|
||||
$giteaUrl = $config->getString('gitea.url', 'https://git.mokoconsulting.tech');
|
||||
$apiBaseUrl = rtrim($giteaUrl, '/') . '/api/v1';
|
||||
$giteaUrl = $config->getString('gitea.url', 'https://git.mokoconsulting.tech');
|
||||
$apiBaseUrl = rtrim($giteaUrl, '/') . '/api/v1';
|
||||
|
||||
$apiClient = new ApiClient(
|
||||
baseUrl: $apiBaseUrl,
|
||||
authToken: $token,
|
||||
maxRequestsPerHour: $config->getInt('gitea.rate_limit', 5000),
|
||||
maxRetries: $config->getInt('gitea.max_retries', 3),
|
||||
authScheme: 'token'
|
||||
);
|
||||
$apiClient = new ApiClient(
|
||||
baseUrl: $apiBaseUrl,
|
||||
authToken: $token,
|
||||
maxRequestsPerHour: $config->getInt('gitea.rate_limit', 5000),
|
||||
maxRetries: $config->getInt('gitea.max_retries', 3),
|
||||
authScheme: 'token'
|
||||
);
|
||||
|
||||
return new MokoGiteaAdapter($apiClient, $apiBaseUrl);
|
||||
}
|
||||
return new MokoGiteaAdapter($apiClient, $apiBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create adapters for both platforms (useful during migration).
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters
|
||||
* @throws RuntimeException If either token is missing
|
||||
*/
|
||||
public static function createBoth(Config $config): array
|
||||
{
|
||||
return [
|
||||
'github' => self::createGitHubAdapter($config),
|
||||
'gitea' => self::createMokoGiteaAdapter($config),
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Create adapters for both platforms (useful during migration).
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters
|
||||
* @throws RuntimeException If either token is missing
|
||||
*/
|
||||
public static function createBoth(Config $config): array
|
||||
{
|
||||
return [
|
||||
'github' => self::createGitHubAdapter($config),
|
||||
'gitea' => self::createMokoGiteaAdapter($config),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a file between Gitea (primary) and GitHub (mirror) for a given repo.
|
||||
*
|
||||
* Reads the file from Gitea and pushes it to GitHub, ensuring both platforms
|
||||
* serve identical content. Commonly used for updates.xml sync after releases.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch to sync (default: 'main')
|
||||
* @param string $filePath Path to the file (default: 'updates.xml')
|
||||
* @return bool True if sync succeeded or file was already identical
|
||||
* @throws RuntimeException If either platform is unreachable
|
||||
*/
|
||||
public static function syncUpdatesBetweenPlatforms(
|
||||
Config $config,
|
||||
string $repo,
|
||||
string $branch = 'main',
|
||||
string $filePath = 'updates.xml'
|
||||
): bool {
|
||||
$adapters = self::createBoth($config);
|
||||
$giteaOrg = $config->getString('gitea.organization', 'mokoconsulting-tech');
|
||||
$githubOrg = $config->getString('github.organization', 'mokoconsulting-tech');
|
||||
/**
|
||||
* Sync a file between Gitea (primary) and GitHub (mirror) for a given repo.
|
||||
*
|
||||
* Reads the file from Gitea and pushes it to GitHub, ensuring both platforms
|
||||
* serve identical content. Commonly used for updates.xml sync after releases.
|
||||
*
|
||||
* @param Config $config Configuration instance
|
||||
* @param string $repo Repository name
|
||||
* @param string $branch Branch to sync (default: 'main')
|
||||
* @param string $filePath Path to the file (default: 'updates.xml')
|
||||
* @return bool True if sync succeeded or file was already identical
|
||||
* @throws RuntimeException If either platform is unreachable
|
||||
*/
|
||||
public static function syncUpdatesBetweenPlatforms(
|
||||
Config $config,
|
||||
string $repo,
|
||||
string $branch = 'main',
|
||||
string $filePath = 'updates.xml'
|
||||
): bool {
|
||||
$adapters = self::createBoth($config);
|
||||
$giteaOrg = $config->getString('gitea.organization', 'mokoconsulting-tech');
|
||||
$githubOrg = $config->getString('github.organization', 'mokoconsulting-tech');
|
||||
|
||||
// Read from Gitea (primary)
|
||||
try {
|
||||
$giteaFile = $adapters['gitea']->getFileContents($giteaOrg, $repo, $filePath, $branch);
|
||||
} catch (\Exception $e) {
|
||||
throw new RuntimeException("Failed to read {$filePath} from Gitea ({$giteaOrg}/{$repo}): " . $e->getMessage());
|
||||
}
|
||||
// Read from Gitea (primary)
|
||||
try {
|
||||
$giteaFile = $adapters['gitea']->getFileContents($giteaOrg, $repo, $filePath, $branch);
|
||||
} catch (\Exception $e) {
|
||||
throw new RuntimeException("Failed to read {$filePath} from Gitea ({$giteaOrg}/{$repo}): " . $e->getMessage());
|
||||
}
|
||||
|
||||
$giteaContent = base64_decode($giteaFile['content'] ?? '');
|
||||
if (empty($giteaContent)) {
|
||||
return false;
|
||||
}
|
||||
$giteaContent = base64_decode($giteaFile['content'] ?? '');
|
||||
if (empty($giteaContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read from GitHub (mirror) to check if update is needed
|
||||
$githubSha = null;
|
||||
try {
|
||||
$githubFile = $adapters['github']->getFileContents($githubOrg, $repo, $filePath, $branch);
|
||||
$githubContent = base64_decode($githubFile['content'] ?? '');
|
||||
$githubSha = $githubFile['sha'] ?? null;
|
||||
// Read from GitHub (mirror) to check if update is needed
|
||||
$githubSha = null;
|
||||
try {
|
||||
$githubFile = $adapters['github']->getFileContents($githubOrg, $repo, $filePath, $branch);
|
||||
$githubContent = base64_decode($githubFile['content'] ?? '');
|
||||
$githubSha = $githubFile['sha'] ?? null;
|
||||
|
||||
if ($githubContent === $giteaContent) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$adapters['github']->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
if ($githubContent === $giteaContent) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$adapters['github']->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
$adapters['github']->createOrUpdateFile(
|
||||
$githubOrg,
|
||||
$repo,
|
||||
$filePath,
|
||||
$giteaContent,
|
||||
"chore(sync): sync {$filePath} from Gitea primary",
|
||||
$githubSha,
|
||||
$branch
|
||||
);
|
||||
$adapters['github']->createOrUpdateFile(
|
||||
$githubOrg,
|
||||
$repo,
|
||||
$filePath,
|
||||
$giteaContent,
|
||||
"chore(sync): sync {$filePath} from Gitea primary",
|
||||
$githubSha,
|
||||
$branch
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Plugin Factory - Factory for creating and managing plugin instances
|
||||
*
|
||||
*
|
||||
* Provides convenient methods for plugin instantiation with dependency injection
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
|
||||
@@ -32,7 +32,7 @@ use MokoEnterprise\Plugins\McpServerPlugin;
|
||||
|
||||
/**
|
||||
* Plugin Registry - Central registry for all project type plugins
|
||||
*
|
||||
*
|
||||
* Manages plugin discovery, registration, and lifecycle
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
@@ -107,7 +107,7 @@ class PluginRegistry
|
||||
}
|
||||
|
||||
self::$pluginClasses[$projectType] = $pluginClass;
|
||||
|
||||
|
||||
// Clear cached instance if exists
|
||||
if (isset(self::$plugins[$projectType])) {
|
||||
unset(self::$plugins[$projectType]);
|
||||
@@ -253,8 +253,10 @@ class PluginRegistry
|
||||
if ($plugin !== null) {
|
||||
$bestPractices = $plugin->getBestPractices();
|
||||
foreach ($bestPractices as $practice) {
|
||||
if (stripos($practice['title'] ?? '', $feature) !== false ||
|
||||
stripos($practice['description'] ?? '', $feature) !== false) {
|
||||
if (
|
||||
stripos($practice['title'] ?? '', $feature) !== false ||
|
||||
stripos($practice['description'] ?? '', $feature) !== false
|
||||
) {
|
||||
$matches[] = $projectType;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* API/Microservices Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* API and microservices projects (REST, GraphQL, gRPC).
|
||||
*/
|
||||
@@ -56,7 +57,7 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
|
||||
// Check for API documentation
|
||||
if (!$this->hasAPIDocumentation($projectPath, $apiType)) {
|
||||
$warnings[] = 'No API documentation found (OpenAPI, GraphQL schema, etc.)';
|
||||
$errors[] = 'No API documentation found (OpenAPI, GraphQL schema, etc.)';
|
||||
}
|
||||
|
||||
// Check for proper error handling
|
||||
@@ -361,8 +362,10 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function detectAPIType(string $projectPath): string
|
||||
{
|
||||
// GraphQL
|
||||
if ($this->fileExists($projectPath, 'schema.graphql') ||
|
||||
$this->fileExists($projectPath, '*.graphql')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'schema.graphql') ||
|
||||
$this->fileExists($projectPath, '*.graphql')
|
||||
) {
|
||||
return 'graphql';
|
||||
}
|
||||
|
||||
@@ -372,10 +375,12 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// REST (OpenAPI/Swagger)
|
||||
if ($this->fileExists($projectPath, 'openapi.yaml') ||
|
||||
if (
|
||||
$this->fileExists($projectPath, 'openapi.yaml') ||
|
||||
$this->fileExists($projectPath, 'openapi.json') ||
|
||||
$this->fileExists($projectPath, 'swagger.yaml') ||
|
||||
$this->fileExists($projectPath, 'swagger.json')) {
|
||||
$this->fileExists($projectPath, 'swagger.json')
|
||||
) {
|
||||
return 'rest';
|
||||
}
|
||||
|
||||
@@ -385,8 +390,10 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content) {
|
||||
if (preg_match('/@(Get|Post|Put|Delete|Patch)\(/', $content) ||
|
||||
preg_match('/(get|post|put|delete|patch)\s*\([\'"]/', $content)) {
|
||||
if (
|
||||
preg_match('/@(Get|Post|Put|Delete|Patch)\(/', $content) ||
|
||||
preg_match('/(get|post|put|delete|patch)\s*\([\'"]/', $content)
|
||||
) {
|
||||
return 'rest';
|
||||
}
|
||||
}
|
||||
@@ -452,15 +459,17 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasErrorHandling(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
strpos($content, 'errorHandler') !== false ||
|
||||
strpos($content, 'error_handler') !== false ||
|
||||
preg_match('/class\s+\w*Error/', $content)
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -475,18 +484,20 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasAuthentication(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 15) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'jwt') !== false ||
|
||||
stripos($content, 'oauth') !== false ||
|
||||
stripos($content, 'passport') !== false ||
|
||||
stripos($content, 'authenticate') !== false ||
|
||||
stripos($content, 'api_key') !== false ||
|
||||
stripos($content, 'bearer') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -501,16 +512,18 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasAuthorization(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'authorize') !== false ||
|
||||
stripos($content, 'permission') !== false ||
|
||||
stripos($content, 'role') !== false ||
|
||||
stripos($content, 'acl') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -525,15 +538,17 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasRateLimiting(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'rate_limit') !== false ||
|
||||
stripos($content, 'rateLimit') !== false ||
|
||||
stripos($content, 'throttle') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -548,16 +563,18 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasLogging(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'logger') !== false ||
|
||||
stripos($content, 'winston') !== false ||
|
||||
stripos($content, 'logging') !== false ||
|
||||
stripos($content, 'log.') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -572,16 +589,18 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasMonitoring(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'prometheus') !== false ||
|
||||
stripos($content, 'metrics') !== false ||
|
||||
stripos($content, 'monitoring') !== false ||
|
||||
stripos($content, 'newrelic') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -596,15 +615,17 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasCaching(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'redis') !== false ||
|
||||
stripos($content, 'cache') !== false ||
|
||||
stripos($content, 'memcached') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -619,16 +640,18 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasInputValidation(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
stripos($content, 'validate') !== false ||
|
||||
stripos($content, 'validator') !== false ||
|
||||
stripos($content, 'joi') !== false ||
|
||||
stripos($content, 'yup') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -643,7 +666,7 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
private function hasCORSConfig(string $projectPath): bool
|
||||
{
|
||||
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
|
||||
|
||||
|
||||
foreach (array_slice($files, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -779,20 +802,34 @@ class ApiPlugin extends AbstractProjectPlugin
|
||||
$packageData['dependencies'] ?? [],
|
||||
$packageData['devDependencies'] ?? []
|
||||
);
|
||||
|
||||
if (isset($deps['express'])) return 'Express';
|
||||
if (isset($deps['fastify'])) return 'Fastify';
|
||||
if (isset($deps['@nestjs/core'])) return 'NestJS';
|
||||
if (isset($deps['koa'])) return 'Koa';
|
||||
|
||||
if (isset($deps['express'])) {
|
||||
return 'Express';
|
||||
}
|
||||
if (isset($deps['fastify'])) {
|
||||
return 'Fastify';
|
||||
}
|
||||
if (isset($deps['@nestjs/core'])) {
|
||||
return 'NestJS';
|
||||
}
|
||||
if (isset($deps['koa'])) {
|
||||
return 'Koa';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($language === 'Python') {
|
||||
$requirements = $this->readFile($projectPath, 'requirements.txt');
|
||||
if ($requirements) {
|
||||
if (stripos($requirements, 'fastapi') !== false) return 'FastAPI';
|
||||
if (stripos($requirements, 'flask') !== false) return 'Flask';
|
||||
if (stripos($requirements, 'django') !== false) return 'Django';
|
||||
if (stripos($requirements, 'fastapi') !== false) {
|
||||
return 'FastAPI';
|
||||
}
|
||||
if (stripos($requirements, 'flask') !== false) {
|
||||
return 'Flask';
|
||||
}
|
||||
if (stripos($requirements, 'django') !== false) {
|
||||
return 'Django';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Documentation Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* documentation-focused projects (Sphinx, MkDocs, Docusaurus, etc.).
|
||||
*/
|
||||
@@ -101,9 +102,11 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for images directory
|
||||
if (!$this->fileExists($projectPath, 'images') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'images') &&
|
||||
!$this->fileExists($projectPath, 'assets') &&
|
||||
!$this->fileExists($projectPath, 'static')) {
|
||||
!$this->fileExists($projectPath, 'static')
|
||||
) {
|
||||
$warnings[] = 'No images/assets directory found';
|
||||
}
|
||||
|
||||
@@ -369,7 +372,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
private function hasIndexPage(string $projectPath, string $docType): bool
|
||||
{
|
||||
$indexFiles = ['index.md', 'index.rst', 'index.html', 'README.md', 'docs/index.md'];
|
||||
|
||||
|
||||
foreach ($indexFiles as $file) {
|
||||
if ($this->fileExists($projectPath, $file)) {
|
||||
return true;
|
||||
@@ -409,7 +412,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
// Check for TOC files
|
||||
$tocFiles = ['SUMMARY.md', 'toc.yml', 'toc.rst', 'sidebar.js', 'sidebars.js'];
|
||||
|
||||
|
||||
foreach ($tocFiles as $file) {
|
||||
if ($this->fileExists($projectPath, $file)) {
|
||||
return true;
|
||||
@@ -434,7 +437,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$extensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
|
||||
|
||||
|
||||
foreach ($extensions as $ext) {
|
||||
$count += $this->countFiles($projectPath, "**/*.{$ext}");
|
||||
}
|
||||
@@ -461,7 +464,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$pattern = in_array($docType, ['sphinx', 'rst']) ? '**/*.rst' : '**/*.md';
|
||||
$files = $this->findFiles($projectPath, $pattern);
|
||||
|
||||
|
||||
$totalWords = 0;
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
@@ -612,7 +615,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
|
||||
private function hasBuildOutput(string $projectPath, string $docType): bool
|
||||
{
|
||||
$buildDirs = ['_build', 'build', 'site', '.docusaurus', '_site'];
|
||||
|
||||
|
||||
foreach ($buildDirs as $dir) {
|
||||
if ($this->fileExists($projectPath, $dir)) {
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Dolibarr Module Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for Dolibarr
|
||||
* modules and custom developments.
|
||||
*/
|
||||
@@ -93,8 +94,10 @@ class DolibarrPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for documentation
|
||||
if (!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'doc')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'doc')
|
||||
) {
|
||||
$warnings[] = 'No documentation found';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Generic Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* generic projects that don't fit specific technology categories.
|
||||
*/
|
||||
@@ -53,22 +54,28 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
$warnings = [];
|
||||
|
||||
// Check for README
|
||||
if (!$this->fileExists($projectPath, 'README.md') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'README') &&
|
||||
!$this->fileExists($projectPath, 'README.txt')) {
|
||||
$warnings[] = 'No README file found';
|
||||
!$this->fileExists($projectPath, 'README.txt')
|
||||
) {
|
||||
$errors[] = 'No README file found';
|
||||
}
|
||||
|
||||
// Check for LICENSE
|
||||
if (!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
!$this->fileExists($projectPath, 'LICENSE.md') &&
|
||||
!$this->fileExists($projectPath, 'COPYING')) {
|
||||
!$this->fileExists($projectPath, 'COPYING')
|
||||
) {
|
||||
$warnings[] = 'No LICENSE file found';
|
||||
}
|
||||
|
||||
// Check for version control ignore file
|
||||
if (!$this->fileExists($projectPath, '.gitignore') &&
|
||||
!$this->fileExists($projectPath, '.hgignore')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, '.gitignore') &&
|
||||
!$this->fileExists($projectPath, '.hgignore')
|
||||
) {
|
||||
$warnings[] = 'No version control ignore file found';
|
||||
}
|
||||
|
||||
@@ -79,7 +86,7 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
$this->fileExists($projectPath, '.travis.yml') ||
|
||||
$this->fileExists($projectPath, 'Jenkinsfile') ||
|
||||
$this->fileExists($projectPath, '.circleci');
|
||||
|
||||
|
||||
if (!$hasCICD) {
|
||||
$warnings[] = 'No CI/CD configuration found';
|
||||
}
|
||||
@@ -174,8 +181,10 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check version control
|
||||
if (!$this->fileExists($projectPath, '.git') &&
|
||||
!$this->fileExists($projectPath, '.hg')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, '.git') &&
|
||||
!$this->fileExists($projectPath, '.hg')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'info',
|
||||
'message' => 'Not under version control',
|
||||
@@ -184,8 +193,10 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check .gitignore
|
||||
if ($this->fileExists($projectPath, '.git') &&
|
||||
!$this->fileExists($projectPath, '.gitignore')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, '.git') &&
|
||||
!$this->fileExists($projectPath, '.gitignore')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Missing .gitignore file',
|
||||
@@ -230,8 +241,10 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for changelog
|
||||
if (!$this->fileExists($projectPath, 'CHANGELOG.md') &&
|
||||
!$this->fileExists($projectPath, 'CHANGELOG')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'CHANGELOG.md') &&
|
||||
!$this->fileExists($projectPath, 'CHANGELOG')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'info',
|
||||
'message' => 'No CHANGELOG file found',
|
||||
@@ -471,7 +484,7 @@ class GenericPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$totalLines = 0;
|
||||
$textExtensions = ['php', 'js', 'py', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rb', 'ts', 'tsx', 'jsx'];
|
||||
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Joomla Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for Joomla
|
||||
* extensions (components, modules, plugins, templates).
|
||||
*/
|
||||
@@ -78,20 +79,26 @@ class JoomlaPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for language files
|
||||
if (!$this->fileExists($projectPath, 'language') &&
|
||||
!$this->countFiles($projectPath, '**/language/*.ini')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'language') &&
|
||||
!$this->countFiles($projectPath, '**/language/*.ini')
|
||||
) {
|
||||
$warnings[] = 'No language files found';
|
||||
}
|
||||
|
||||
// Check for SQL installation files
|
||||
if (!$this->fileExists($projectPath, 'sql/install.mysql.utf8.sql') &&
|
||||
!$this->fileExists($projectPath, 'admin/sql/install.mysql.utf8.sql')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'sql/install.mysql.utf8.sql') &&
|
||||
!$this->fileExists($projectPath, 'admin/sql/install.mysql.utf8.sql')
|
||||
) {
|
||||
$warnings[] = 'No SQL installation file found';
|
||||
}
|
||||
|
||||
// Check code quality
|
||||
if (!$this->fileExists($projectPath, 'phpcs.xml') &&
|
||||
!$this->fileExists($projectPath, 'phpcs.xml.dist')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'phpcs.xml') &&
|
||||
!$this->fileExists($projectPath, 'phpcs.xml.dist')
|
||||
) {
|
||||
$warnings[] = 'No PHPCS configuration found';
|
||||
}
|
||||
|
||||
@@ -128,7 +135,7 @@ class JoomlaPlugin extends AbstractProjectPlugin
|
||||
'has_namespaces' => $this->checkForNamespaces($projectPath),
|
||||
'joomla_version' => $this->detectJoomlaVersion($projectPath),
|
||||
'uses_mvc' => $this->checkMVCStructure($projectPath),
|
||||
'has_tests' => $this->fileExists($projectPath, 'tests') ||
|
||||
'has_tests' => $this->fileExists($projectPath, 'tests') ||
|
||||
$this->fileExists($projectPath, 'test'),
|
||||
];
|
||||
|
||||
@@ -173,8 +180,10 @@ class JoomlaPlugin extends AbstractProjectPlugin
|
||||
// Check for proper directory structure
|
||||
$extensionType = $this->detectExtensionType($projectPath);
|
||||
if ($extensionType === 'component') {
|
||||
if (!$this->fileExists($projectPath, 'site') &&
|
||||
!$this->fileExists($projectPath, 'admin')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'site') &&
|
||||
!$this->fileExists($projectPath, 'admin')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Component missing standard site/admin structure',
|
||||
@@ -326,10 +335,12 @@ class JoomlaPlugin extends AbstractProjectPlugin
|
||||
$files = $this->findFiles($projectPath, '*.xml');
|
||||
foreach ($files as $file) {
|
||||
$content = $this->readFile($projectPath, basename($file));
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
strpos($content, '<extension') !== false ||
|
||||
strpos($content, '<install') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
@@ -431,7 +442,7 @@ class JoomlaPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$dirs = glob($projectPath . '/*', GLOB_ONLYDIR);
|
||||
$missingCount = 0;
|
||||
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if (!file_exists($dir . '/index.html')) {
|
||||
$missingCount++;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Mobile App Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* mobile applications (React Native, Flutter, native iOS/Android).
|
||||
*/
|
||||
@@ -59,12 +60,16 @@ class MobilePlugin extends AbstractProjectPlugin
|
||||
if (!$this->fileExists($projectPath, 'package.json')) {
|
||||
$errors[] = 'React Native project missing package.json';
|
||||
}
|
||||
if (!$this->fileExists($projectPath, 'app.json') &&
|
||||
!$this->fileExists($projectPath, 'app.config.js')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'app.json') &&
|
||||
!$this->fileExists($projectPath, 'app.config.js')
|
||||
) {
|
||||
$warnings[] = 'Missing app.json or app.config.js';
|
||||
}
|
||||
if (!$this->fileExists($projectPath, 'ios') &&
|
||||
!$this->fileExists($projectPath, 'android')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'ios') &&
|
||||
!$this->fileExists($projectPath, 'android')
|
||||
) {
|
||||
$warnings[] = 'No native platform directories found';
|
||||
}
|
||||
break;
|
||||
@@ -79,8 +84,10 @@ class MobilePlugin extends AbstractProjectPlugin
|
||||
break;
|
||||
|
||||
case 'ios':
|
||||
if (!$this->fileExists($projectPath, '*.xcodeproj') &&
|
||||
!$this->fileExists($projectPath, '*.xcworkspace')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, '*.xcodeproj') &&
|
||||
!$this->fileExists($projectPath, '*.xcworkspace')
|
||||
) {
|
||||
$errors[] = 'iOS project missing Xcode project file';
|
||||
}
|
||||
if (!$this->fileExists($projectPath, 'Podfile')) {
|
||||
@@ -427,8 +434,10 @@ class MobilePlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Android
|
||||
if ($this->fileExists($projectPath, 'build.gradle') &&
|
||||
$this->fileExists($projectPath, 'app/src/main')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'build.gradle') &&
|
||||
$this->fileExists($projectPath, 'app/src/main')
|
||||
) {
|
||||
return 'android';
|
||||
}
|
||||
|
||||
@@ -593,7 +602,7 @@ class MobilePlugin extends AbstractProjectPlugin
|
||||
private function countTotalLines(string $projectPath, string $platform): int
|
||||
{
|
||||
$extensions = [];
|
||||
|
||||
|
||||
switch ($platform) {
|
||||
case 'react-native':
|
||||
$extensions = ['js', 'jsx', 'ts', 'tsx'];
|
||||
@@ -613,9 +622,11 @@ class MobilePlugin extends AbstractProjectPlugin
|
||||
foreach ($extensions as $ext) {
|
||||
$files = $this->findFiles($projectPath, "**/*.{$ext}");
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) &&
|
||||
if (
|
||||
is_file($file) &&
|
||||
strpos($file, 'node_modules') === false &&
|
||||
strpos($file, 'build') === false) {
|
||||
strpos($file, 'build') === false
|
||||
) {
|
||||
$totalLines += count(file($file));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Node.js/TypeScript Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* Node.js and TypeScript projects.
|
||||
*/
|
||||
@@ -86,28 +87,36 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for node_modules in git
|
||||
if ($this->fileExists($projectPath, 'node_modules') &&
|
||||
!$this->isInGitignore($projectPath, 'node_modules')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'node_modules') &&
|
||||
!$this->isInGitignore($projectPath, 'node_modules')
|
||||
) {
|
||||
$warnings[] = 'node_modules should be in .gitignore';
|
||||
}
|
||||
|
||||
// Check for lock file
|
||||
if (!$this->fileExists($projectPath, 'package-lock.json') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'package-lock.json') &&
|
||||
!$this->fileExists($projectPath, 'yarn.lock') &&
|
||||
!$this->fileExists($projectPath, 'pnpm-lock.yaml')) {
|
||||
!$this->fileExists($projectPath, 'pnpm-lock.yaml')
|
||||
) {
|
||||
$warnings[] = 'No lock file found (package-lock.json, yarn.lock, or pnpm-lock.yaml)';
|
||||
}
|
||||
|
||||
// Check for linting
|
||||
if (!$this->fileExists($projectPath, '.eslintrc.js') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, '.eslintrc.js') &&
|
||||
!$this->fileExists($projectPath, '.eslintrc.json') &&
|
||||
!$this->fileExists($projectPath, '.eslintrc.yml')) {
|
||||
!$this->fileExists($projectPath, '.eslintrc.yml')
|
||||
) {
|
||||
$warnings[] = 'No ESLint configuration found';
|
||||
}
|
||||
|
||||
// Check for formatting
|
||||
if (!$this->fileExists($projectPath, '.prettierrc') &&
|
||||
!$this->fileExists($projectPath, 'prettier.config.js')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, '.prettierrc') &&
|
||||
!$this->fileExists($projectPath, 'prettier.config.js')
|
||||
) {
|
||||
$warnings[] = 'No Prettier configuration found';
|
||||
}
|
||||
|
||||
@@ -195,7 +204,7 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
$score -= 30;
|
||||
} else {
|
||||
$packageData = $this->parseJsonFile($projectPath, 'package.json');
|
||||
|
||||
|
||||
// Check for outdated dependencies (basic check)
|
||||
if ($this->hasOldDependencies($packageData)) {
|
||||
$issues[] = [
|
||||
@@ -207,9 +216,11 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for lock file
|
||||
if (!$this->fileExists($projectPath, 'package-lock.json') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'package-lock.json') &&
|
||||
!$this->fileExists($projectPath, 'yarn.lock') &&
|
||||
!$this->fileExists($projectPath, 'pnpm-lock.yaml')) {
|
||||
!$this->fileExists($projectPath, 'pnpm-lock.yaml')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'No lock file found',
|
||||
@@ -265,8 +276,10 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for node_modules in git
|
||||
if ($this->fileExists($projectPath, 'node_modules') &&
|
||||
!$this->isInGitignore($projectPath, 'node_modules')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'node_modules') &&
|
||||
!$this->isInGitignore($projectPath, 'node_modules')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'node_modules not in .gitignore',
|
||||
@@ -448,10 +461,12 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
private function hasTests(string $projectPath, ?array $packageData): bool
|
||||
{
|
||||
// Check for test directories
|
||||
if ($this->fileExists($projectPath, 'test') ||
|
||||
if (
|
||||
$this->fileExists($projectPath, 'test') ||
|
||||
$this->fileExists($projectPath, 'tests') ||
|
||||
$this->fileExists($projectPath, '__tests__') ||
|
||||
$this->fileExists($projectPath, 'spec')) {
|
||||
$this->fileExists($projectPath, 'spec')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -461,10 +476,12 @@ class NodeJsPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for test files
|
||||
if ($this->countFiles($projectPath, '**/*.test.js') > 0 ||
|
||||
if (
|
||||
$this->countFiles($projectPath, '**/*.test.js') > 0 ||
|
||||
$this->countFiles($projectPath, '**/*.test.ts') > 0 ||
|
||||
$this->countFiles($projectPath, '**/*.spec.js') > 0 ||
|
||||
$this->countFiles($projectPath, '**/*.spec.ts') > 0) {
|
||||
$this->countFiles($projectPath, '**/*.spec.ts') > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Python Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* Python projects.
|
||||
*/
|
||||
@@ -55,7 +56,7 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
// Check for project configuration
|
||||
$hasSetupPy = $this->fileExists($projectPath, 'setup.py');
|
||||
$hasPyproject = $this->fileExists($projectPath, 'pyproject.toml');
|
||||
|
||||
|
||||
if (!$hasSetupPy && !$hasPyproject) {
|
||||
$warnings[] = 'No setup.py or pyproject.toml found';
|
||||
}
|
||||
@@ -73,9 +74,11 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for requirements
|
||||
if (!$this->fileExists($projectPath, 'requirements.txt') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'requirements.txt') &&
|
||||
!$this->fileExists($projectPath, 'Pipfile') &&
|
||||
!$hasPyproject) {
|
||||
!$hasPyproject
|
||||
) {
|
||||
$warnings[] = 'No requirements file found (requirements.txt, Pipfile, or pyproject.toml)';
|
||||
}
|
||||
|
||||
@@ -91,17 +94,21 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
// Check for virtual environment in git
|
||||
$venvDirs = ['venv', '.venv', 'env', '.env'];
|
||||
foreach ($venvDirs as $dir) {
|
||||
if ($this->fileExists($projectPath, $dir) &&
|
||||
!$this->isInGitignore($projectPath, $dir)) {
|
||||
if (
|
||||
$this->fileExists($projectPath, $dir) &&
|
||||
!$this->isInGitignore($projectPath, $dir)
|
||||
) {
|
||||
$warnings[] = "Virtual environment directory '{$dir}' should be in .gitignore";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for linting/formatting
|
||||
if (!$this->fileExists($projectPath, '.flake8') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, '.flake8') &&
|
||||
!$this->fileExists($projectPath, '.pylintrc') &&
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')) {
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')
|
||||
) {
|
||||
$warnings[] = 'No linting configuration found (.flake8, .pylintrc, or pyproject.toml)';
|
||||
}
|
||||
|
||||
@@ -143,10 +150,12 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
$pythonFiles = $this->findFiles($projectPath, '**/*.py');
|
||||
$totalLines = 0;
|
||||
$docstringLines = 0;
|
||||
|
||||
|
||||
foreach ($pythonFiles as $file) {
|
||||
if (is_file($file) && strpos($file, 'venv') === false &&
|
||||
strpos($file, '.venv') === false) {
|
||||
if (
|
||||
is_file($file) && strpos($file, 'venv') === false &&
|
||||
strpos($file, '.venv') === false
|
||||
) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content) {
|
||||
$lines = explode("\n", $content);
|
||||
@@ -155,7 +164,7 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$metrics['total_lines'] = $totalLines;
|
||||
$metrics['docstring_count'] = $docstringLines;
|
||||
|
||||
@@ -182,8 +191,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
$score = 100;
|
||||
|
||||
// Check for project configuration
|
||||
if (!$this->fileExists($projectPath, 'setup.py') &&
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'setup.py') &&
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'No setup.py or pyproject.toml found',
|
||||
@@ -192,9 +203,11 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for requirements
|
||||
if (!$this->fileExists($projectPath, 'requirements.txt') &&
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'requirements.txt') &&
|
||||
!$this->fileExists($projectPath, 'Pipfile') &&
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')) {
|
||||
!$this->fileExists($projectPath, 'pyproject.toml')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'No requirements file found',
|
||||
@@ -205,8 +218,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
// Check for virtual environment in git
|
||||
$venvDirs = ['venv', '.venv', 'env'];
|
||||
foreach ($venvDirs as $dir) {
|
||||
if ($this->fileExists($projectPath, $dir) &&
|
||||
!$this->isInGitignore($projectPath, $dir)) {
|
||||
if (
|
||||
$this->fileExists($projectPath, $dir) &&
|
||||
!$this->isInGitignore($projectPath, $dir)
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => "Virtual environment '{$dir}' not in .gitignore",
|
||||
@@ -217,8 +232,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for __pycache__ in git
|
||||
if ($this->fileExists($projectPath, '__pycache__') &&
|
||||
!$this->isInGitignore($projectPath, '__pycache__')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, '__pycache__') &&
|
||||
!$this->isInGitignore($projectPath, '__pycache__')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => '__pycache__ directories not in .gitignore',
|
||||
@@ -254,8 +271,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for README
|
||||
if (!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'README.rst')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'README.rst')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Missing README file',
|
||||
@@ -264,8 +283,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for license
|
||||
if (!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
!$this->fileExists($projectPath, 'LICENSE.txt')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
!$this->fileExists($projectPath, 'LICENSE.txt')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Missing LICENSE file',
|
||||
@@ -400,7 +421,7 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
// Basic TOML parsing (simplified)
|
||||
$data = [];
|
||||
$section = '';
|
||||
|
||||
|
||||
foreach (explode("\n", $content) as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^\[(.*)\]$/', $line, $matches)) {
|
||||
@@ -459,7 +480,7 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
// Check requirements.txt
|
||||
$requirements = $this->readFile($projectPath, 'requirements.txt');
|
||||
if ($requirements) {
|
||||
$lines = array_filter(explode("\n", $requirements), function($line) {
|
||||
$lines = array_filter(explode("\n", $requirements), function ($line) {
|
||||
$line = trim($line);
|
||||
return !empty($line) && !str_starts_with($line, '#');
|
||||
});
|
||||
@@ -491,8 +512,10 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
*/
|
||||
private function detectTestFramework(string $projectPath): string
|
||||
{
|
||||
if ($this->fileExists($projectPath, 'pytest.ini') ||
|
||||
$this->fileExists($projectPath, 'pyproject.toml')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'pytest.ini') ||
|
||||
$this->fileExists($projectPath, 'pyproject.toml')
|
||||
) {
|
||||
return 'pytest';
|
||||
}
|
||||
|
||||
@@ -569,7 +592,7 @@ class PythonPlugin extends AbstractProjectPlugin
|
||||
private function hasTypeHints(string $projectPath): bool
|
||||
{
|
||||
$pythonFiles = $this->findFiles($projectPath, '*.py');
|
||||
|
||||
|
||||
foreach (array_slice($pythonFiles, 0, 5) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* Terraform Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* Terraform infrastructure-as-code projects.
|
||||
*/
|
||||
@@ -74,14 +75,18 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for terraform.tfvars in git
|
||||
if ($this->fileExists($projectPath, 'terraform.tfvars') &&
|
||||
!$this->isInGitignore($projectPath, 'terraform.tfvars')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'terraform.tfvars') &&
|
||||
!$this->isInGitignore($projectPath, 'terraform.tfvars')
|
||||
) {
|
||||
$warnings[] = 'terraform.tfvars may contain secrets and should be in .gitignore';
|
||||
}
|
||||
|
||||
// Check for .terraform directory in git
|
||||
if ($this->fileExists($projectPath, '.terraform') &&
|
||||
!$this->isInGitignore($projectPath, '.terraform')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, '.terraform') &&
|
||||
!$this->isInGitignore($projectPath, '.terraform')
|
||||
) {
|
||||
$warnings[] = '.terraform directory should be in .gitignore';
|
||||
}
|
||||
|
||||
@@ -224,8 +229,10 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for secrets in tfvars
|
||||
if ($this->fileExists($projectPath, 'terraform.tfvars') &&
|
||||
!$this->isInGitignore($projectPath, 'terraform.tfvars')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, 'terraform.tfvars') &&
|
||||
!$this->isInGitignore($projectPath, 'terraform.tfvars')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'terraform.tfvars not in .gitignore',
|
||||
@@ -234,8 +241,10 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check .terraform directory
|
||||
if ($this->fileExists($projectPath, '.terraform') &&
|
||||
!$this->isInGitignore($projectPath, '.terraform')) {
|
||||
if (
|
||||
$this->fileExists($projectPath, '.terraform') &&
|
||||
!$this->isInGitignore($projectPath, '.terraform')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => '.terraform directory not in .gitignore',
|
||||
@@ -370,7 +379,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
private function hasBackendConfig(string $projectPath): bool
|
||||
{
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -389,7 +398,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
private function hasVersionConstraints(string $projectPath): bool
|
||||
{
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -418,7 +427,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -438,7 +447,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -458,7 +467,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -478,7 +487,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -498,7 +507,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -518,7 +527,7 @@ class TerraformPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$providers = [];
|
||||
$tfFiles = $this->findFiles($projectPath, '*.tf');
|
||||
|
||||
|
||||
foreach ($tfFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
|
||||
|
||||
/**
|
||||
* WordPress Project Plugin
|
||||
*
|
||||
*
|
||||
* Provides validation, metrics, and management capabilities for
|
||||
* WordPress plugins and themes.
|
||||
*/
|
||||
@@ -79,8 +80,10 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for WordPress coding standards
|
||||
if (!$this->fileExists($projectPath, 'phpcs.xml') &&
|
||||
!$this->fileExists($projectPath, 'phpcs.xml.dist')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'phpcs.xml') &&
|
||||
!$this->fileExists($projectPath, 'phpcs.xml.dist')
|
||||
) {
|
||||
$warnings[] = 'No PHPCS configuration found (WordPress Coding Standards recommended)';
|
||||
}
|
||||
|
||||
@@ -221,8 +224,10 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for README
|
||||
if (!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'readme.txt')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'README.md') &&
|
||||
!$this->fileExists($projectPath, 'readme.txt')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Missing README file',
|
||||
@@ -231,8 +236,10 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for license
|
||||
if (!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
!$this->fileExists($projectPath, 'license.txt')) {
|
||||
if (
|
||||
!$this->fileExists($projectPath, 'LICENSE') &&
|
||||
!$this->fileExists($projectPath, 'license.txt')
|
||||
) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => 'Missing LICENSE file',
|
||||
@@ -408,7 +415,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
];
|
||||
|
||||
$nameField = $type === 'theme' ? 'Theme Name' : 'Plugin Name';
|
||||
|
||||
|
||||
if (preg_match('/' . $nameField . ':\s*(.+)/i', $content, $matches)) {
|
||||
$data['name'] = trim($matches[1]);
|
||||
}
|
||||
@@ -431,7 +438,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasTextDomain(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 5) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -450,7 +457,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasUnescapedOutput(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -473,15 +480,17 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
$protectedCount = 0;
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
strpos($content, 'defined( \'ABSPATH\' )') !== false ||
|
||||
strpos($content, 'defined(\'ABSPATH\')') !== false ||
|
||||
strpos($content, 'if ( ! defined( \'ABSPATH\' ) )') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
$protectedCount++;
|
||||
}
|
||||
}
|
||||
@@ -496,7 +505,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasSQLInjectionRisk(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -518,14 +527,16 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasNonceVerification(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 10) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
strpos($content, 'wp_verify_nonce') !== false ||
|
||||
strpos($content, 'check_ajax_referer') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -552,14 +563,16 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasHooks(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach (array_slice($phpFiles, 0, 5) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && (
|
||||
if (
|
||||
$content && (
|
||||
strpos($content, 'add_action') !== false ||
|
||||
strpos($content, 'add_filter') !== false
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -575,7 +588,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
{
|
||||
$count = 0;
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -594,7 +607,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasAjax(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -613,7 +626,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasRestAPI(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -642,7 +655,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasWidgets(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
@@ -661,7 +674,7 @@ class WordPressPlugin extends AbstractProjectPlugin
|
||||
private function hasShortcodes(string $projectPath): bool
|
||||
{
|
||||
$phpFiles = $this->findFiles($projectPath, '*.php');
|
||||
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,20 +21,22 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Project Config Validator
|
||||
*
|
||||
*
|
||||
* Enterprise library for validating project configurations against
|
||||
* project type templates and standards.
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class ProjectConfigValidator
|
||||
{
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private ProjectTypeDetector $detector;
|
||||
|
||||
|
||||
private array $validationResults = [];
|
||||
private int $errorsCount = 0;
|
||||
private int $warningsCount = 0;
|
||||
|
||||
|
||||
private const VALIDATION_RULES = [
|
||||
'nodejs' => [
|
||||
'required_files' => ['package.json'],
|
||||
@@ -66,7 +69,7 @@ class ProjectConfigValidator
|
||||
'required_fields' => [],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
@@ -79,10 +82,10 @@ class ProjectConfigValidator
|
||||
$this->metrics = $metrics ?? new MetricsCollector();
|
||||
$this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate project configuration
|
||||
*
|
||||
*
|
||||
* @param string $repoPath Path to repository
|
||||
* @param string|null $projectType Optional project type (auto-detect if null)
|
||||
* @return array Validation results
|
||||
@@ -90,38 +93,38 @@ class ProjectConfigValidator
|
||||
public function validate(string $repoPath, ?string $projectType = null): array
|
||||
{
|
||||
$this->logger->logInfo("Validating project configuration: {$repoPath}");
|
||||
|
||||
|
||||
$this->resetResults();
|
||||
|
||||
|
||||
// Detect project type if not provided
|
||||
if ($projectType === null) {
|
||||
$detection = $this->detector->detect($repoPath);
|
||||
$projectType = $detection['type'];
|
||||
$this->logger->logInfo("Auto-detected project type: {$projectType}");
|
||||
}
|
||||
|
||||
|
||||
// Get validation rules for project type
|
||||
$rules = self::VALIDATION_RULES[$projectType] ?? [];
|
||||
|
||||
|
||||
if (empty($rules)) {
|
||||
$this->addWarning('No validation rules for project type: ' . $projectType);
|
||||
return $this->getResults();
|
||||
}
|
||||
|
||||
|
||||
// Run validations
|
||||
$this->validateRequiredFiles($repoPath, $rules['required_files'] ?? []);
|
||||
$this->validateRecommendedFiles($repoPath, $rules['recommended_files'] ?? []);
|
||||
$this->validateProjectFields($repoPath, $projectType, $rules['required_fields'] ?? []);
|
||||
|
||||
|
||||
// Record metrics
|
||||
$this->metrics->setGauge('validation_errors', $this->errorsCount);
|
||||
$this->metrics->setGauge('validation_warnings', $this->warningsCount);
|
||||
|
||||
|
||||
$this->logger->logInfo("Validation complete: {$this->errorsCount} errors, {$this->warningsCount} warnings");
|
||||
|
||||
|
||||
return $this->getResults();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if validation passed (no errors)
|
||||
*/
|
||||
@@ -129,7 +132,7 @@ class ProjectConfigValidator
|
||||
{
|
||||
return $this->errorsCount === 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get validation results
|
||||
*/
|
||||
@@ -142,19 +145,19 @@ class ProjectConfigValidator
|
||||
'results' => $this->validationResults,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
private function resetResults(): void
|
||||
{
|
||||
$this->validationResults = [];
|
||||
$this->errorsCount = 0;
|
||||
$this->warningsCount = 0;
|
||||
}
|
||||
|
||||
|
||||
private function validateRequiredFiles(string $path, array $files): void
|
||||
{
|
||||
foreach ($files as $filePattern) {
|
||||
$found = false;
|
||||
|
||||
|
||||
// Handle OR patterns (file1|file2)
|
||||
if (strpos($filePattern, '|') !== false) {
|
||||
$patterns = explode('|', $filePattern);
|
||||
@@ -167,7 +170,7 @@ class ProjectConfigValidator
|
||||
} else {
|
||||
$found = $this->filePatternExists($path, $filePattern);
|
||||
}
|
||||
|
||||
|
||||
if (!$found) {
|
||||
$this->addError("Required file missing: {$filePattern}");
|
||||
} else {
|
||||
@@ -175,12 +178,12 @@ class ProjectConfigValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function validateRecommendedFiles(string $path, array $files): void
|
||||
{
|
||||
foreach ($files as $filePattern) {
|
||||
$found = false;
|
||||
|
||||
|
||||
// Handle OR patterns
|
||||
if (strpos($filePattern, '|') !== false) {
|
||||
$patterns = explode('|', $filePattern);
|
||||
@@ -193,7 +196,7 @@ class ProjectConfigValidator
|
||||
} else {
|
||||
$found = $this->filePatternExists($path, $filePattern);
|
||||
}
|
||||
|
||||
|
||||
if (!$found) {
|
||||
$this->addWarning("Recommended file missing: {$filePattern}");
|
||||
} else {
|
||||
@@ -201,13 +204,13 @@ class ProjectConfigValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function validateProjectFields(string $path, string $projectType, array $fields): void
|
||||
{
|
||||
if (empty($fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate based on project type
|
||||
switch ($projectType) {
|
||||
case 'nodejs':
|
||||
@@ -223,7 +226,7 @@ class ProjectConfigValidator
|
||||
$this->logger->logInfo("No field validation for project type: {$projectType}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function validateNodeJSFields(string $path, array $fields): void
|
||||
{
|
||||
$packageFile = "{$path}/package.json";
|
||||
@@ -231,13 +234,13 @@ class ProjectConfigValidator
|
||||
$this->addError("Cannot validate fields: package.json not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$package = json_decode(file_get_contents($packageFile), true);
|
||||
if (!$package) {
|
||||
$this->addError("Cannot parse package.json");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!isset($package[$field])) {
|
||||
$this->addError("Required field missing in package.json: {$field}");
|
||||
@@ -246,17 +249,17 @@ class ProjectConfigValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function validatePythonFields(string $path, array $fields): void
|
||||
{
|
||||
$setupFile = "{$path}/setup.py";
|
||||
$pyprojectFile = "{$path}/pyproject.toml";
|
||||
|
||||
|
||||
if (!file_exists($setupFile) && !file_exists($pyprojectFile)) {
|
||||
$this->addError("Cannot validate fields: setup.py or pyproject.toml not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Basic validation - check if fields appear in file content
|
||||
$content = '';
|
||||
if (file_exists($setupFile)) {
|
||||
@@ -264,7 +267,7 @@ class ProjectConfigValidator
|
||||
} elseif (file_exists($pyprojectFile)) {
|
||||
$content = file_get_contents($pyprojectFile);
|
||||
}
|
||||
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (stripos($content, $field) === false) {
|
||||
$this->addWarning("Field may be missing: {$field}");
|
||||
@@ -273,7 +276,7 @@ class ProjectConfigValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function validateWordPressFields(string $path, array $fields): void
|
||||
{
|
||||
$phpFiles = glob("{$path}/*.php");
|
||||
@@ -281,12 +284,12 @@ class ProjectConfigValidator
|
||||
$this->addError("No PHP files found for WordPress validation");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$content = '';
|
||||
foreach ($phpFiles as $file) {
|
||||
$content .= file_get_contents($file);
|
||||
}
|
||||
|
||||
|
||||
foreach ($fields as $field) {
|
||||
// Handle OR patterns
|
||||
if (strpos($field, '|') !== false) {
|
||||
@@ -312,7 +315,7 @@ class ProjectConfigValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function filePatternExists(string $path, string $pattern): bool
|
||||
{
|
||||
// Handle wildcard patterns
|
||||
@@ -320,10 +323,10 @@ class ProjectConfigValidator
|
||||
$files = glob("{$path}/{$pattern}");
|
||||
return !empty($files);
|
||||
}
|
||||
|
||||
|
||||
return file_exists("{$path}/{$pattern}");
|
||||
}
|
||||
|
||||
|
||||
private function addError(string $message): void
|
||||
{
|
||||
$this->validationResults[] = [
|
||||
@@ -333,7 +336,7 @@ class ProjectConfigValidator
|
||||
$this->errorsCount++;
|
||||
$this->logger->logError($message);
|
||||
}
|
||||
|
||||
|
||||
private function addWarning(string $message): void
|
||||
{
|
||||
$this->validationResults[] = [
|
||||
@@ -343,7 +346,7 @@ class ProjectConfigValidator
|
||||
$this->warningsCount++;
|
||||
$this->logger->logWarning($message);
|
||||
}
|
||||
|
||||
|
||||
private function addSuccess(string $message): void
|
||||
{
|
||||
$this->validationResults[] = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Project Metrics Collector
|
||||
*
|
||||
*
|
||||
* Enterprise library for collecting metrics specific to different
|
||||
* project types (Node.js, Python, Terraform, etc.).
|
||||
*/
|
||||
@@ -29,9 +30,9 @@ class ProjectMetricsCollector
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private ProjectTypeDetector $detector;
|
||||
|
||||
|
||||
private array $collectedMetrics = [];
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
@@ -44,10 +45,10 @@ class ProjectMetricsCollector
|
||||
$this->metrics = $metrics ?? new MetricsCollector();
|
||||
$this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collect metrics for a project
|
||||
*
|
||||
*
|
||||
* @param string $repoPath Path to repository
|
||||
* @param string|null $projectType Optional project type (auto-detect if null)
|
||||
* @return array Collected metrics
|
||||
@@ -55,18 +56,18 @@ class ProjectMetricsCollector
|
||||
public function collect(string $repoPath, ?string $projectType = null): array
|
||||
{
|
||||
$this->logger->logInfo("Collecting project metrics: {$repoPath}");
|
||||
|
||||
|
||||
$this->collectedMetrics = [];
|
||||
|
||||
|
||||
// Detect project type if not provided
|
||||
if ($projectType === null) {
|
||||
$detection = $this->detector->detect($repoPath);
|
||||
$projectType = $detection['type'];
|
||||
}
|
||||
|
||||
|
||||
// Collect common metrics
|
||||
$this->collectCommonMetrics($repoPath);
|
||||
|
||||
|
||||
// Collect type-specific metrics
|
||||
switch ($projectType) {
|
||||
case 'nodejs':
|
||||
@@ -88,19 +89,19 @@ class ProjectMetricsCollector
|
||||
$this->collectAPIMetrics($repoPath);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Record to metrics system
|
||||
foreach ($this->collectedMetrics as $key => $value) {
|
||||
if (is_numeric($value)) {
|
||||
$this->metrics->setGauge("project_{$key}", (float)$value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->logger->logInfo("Collected " . count($this->collectedMetrics) . " metrics");
|
||||
|
||||
|
||||
return $this->collectedMetrics;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get collected metrics
|
||||
*/
|
||||
@@ -108,21 +109,21 @@ class ProjectMetricsCollector
|
||||
{
|
||||
return $this->collectedMetrics;
|
||||
}
|
||||
|
||||
|
||||
private function collectCommonMetrics(string $path): void
|
||||
{
|
||||
// File counts
|
||||
$this->collectedMetrics['total_files'] = $this->countFiles($path, '*');
|
||||
$this->collectedMetrics['total_directories'] = $this->countDirectories($path);
|
||||
|
||||
|
||||
// Documentation
|
||||
$this->collectedMetrics['has_readme'] = file_exists("{$path}/README.md") ? 1 : 0;
|
||||
$this->collectedMetrics['has_license'] = file_exists("{$path}/LICENSE") ? 1 : 0;
|
||||
$this->collectedMetrics['has_contributing'] = file_exists("{$path}/CONTRIBUTING.md") ? 1 : 0;
|
||||
|
||||
|
||||
// Git
|
||||
$this->collectedMetrics['has_gitignore'] = file_exists("{$path}/.gitignore") ? 1 : 0;
|
||||
|
||||
|
||||
// CI/CD — check both .github/workflows and .gitea/workflows
|
||||
$hasGithubWf = is_dir("{$path}/.github/workflows");
|
||||
$hasGiteaWf = is_dir("{$path}/.mokogitea/workflows");
|
||||
@@ -133,7 +134,7 @@ class ProjectMetricsCollector
|
||||
$this->countFiles("{$path}/.mokogitea/workflows", '*.yml') +
|
||||
$this->countFiles("{$path}/.mokogitea/workflows", '*.yaml');
|
||||
}
|
||||
|
||||
|
||||
private function collectNodeJSMetrics(string $path): void
|
||||
{
|
||||
// Package.json analysis
|
||||
@@ -146,39 +147,39 @@ class ProjectMetricsCollector
|
||||
$this->collectedMetrics['has_npm_private'] = isset($package['private']) && $package['private'] ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TypeScript
|
||||
$this->collectedMetrics['has_typescript'] = file_exists("{$path}/tsconfig.json") ? 1 : 0;
|
||||
$this->collectedMetrics['typescript_files'] = $this->countFiles($path, '*.ts');
|
||||
$this->collectedMetrics['tsx_files'] = $this->countFiles($path, '*.tsx');
|
||||
|
||||
|
||||
// JavaScript
|
||||
$this->collectedMetrics['javascript_files'] = $this->countFiles($path, '*.js');
|
||||
$this->collectedMetrics['jsx_files'] = $this->countFiles($path, '*.jsx');
|
||||
|
||||
|
||||
// Lock files
|
||||
$this->collectedMetrics['has_package_lock'] = file_exists("{$path}/package-lock.json") ? 1 : 0;
|
||||
$this->collectedMetrics['has_yarn_lock'] = file_exists("{$path}/yarn.lock") ? 1 : 0;
|
||||
$this->collectedMetrics['has_pnpm_lock'] = file_exists("{$path}/pnpm-lock.yaml") ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
private function collectPythonMetrics(string $path): void
|
||||
{
|
||||
// Python files
|
||||
$this->collectedMetrics['python_files'] = $this->countFiles($path, '*.py');
|
||||
|
||||
|
||||
// Package configuration
|
||||
$this->collectedMetrics['has_setup_py'] = file_exists("{$path}/setup.py") ? 1 : 0;
|
||||
$this->collectedMetrics['has_pyproject_toml'] = file_exists("{$path}/pyproject.toml") ? 1 : 0;
|
||||
$this->collectedMetrics['has_requirements_txt'] = file_exists("{$path}/requirements.txt") ? 1 : 0;
|
||||
|
||||
|
||||
// Requirements count
|
||||
if (file_exists("{$path}/requirements.txt")) {
|
||||
$lines = file("{$path}/requirements.txt", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$deps = array_filter($lines, fn($line) => !str_starts_with(trim($line), '#'));
|
||||
$this->collectedMetrics['python_dependencies'] = count($deps);
|
||||
}
|
||||
|
||||
|
||||
// Virtual environment
|
||||
$venvDirs = ['venv', '.venv', 'env', '.env'];
|
||||
$hasVenv = false;
|
||||
@@ -189,37 +190,37 @@ class ProjectMetricsCollector
|
||||
}
|
||||
}
|
||||
$this->collectedMetrics['has_virtual_env'] = $hasVenv ? 1 : 0;
|
||||
|
||||
|
||||
// Testing
|
||||
$this->collectedMetrics['has_pytest'] = is_dir("{$path}/tests") || is_dir("{$path}/test") ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
private function collectTerraformMetrics(string $path): void
|
||||
{
|
||||
// Terraform files
|
||||
$this->collectedMetrics['terraform_files'] = $this->countFiles($path, '*.tf');
|
||||
$this->collectedMetrics['terraform_var_files'] = $this->countFiles($path, '*.tfvars');
|
||||
|
||||
|
||||
// Standard files
|
||||
$this->collectedMetrics['has_main_tf'] = file_exists("{$path}/main.tf") ? 1 : 0;
|
||||
$this->collectedMetrics['has_variables_tf'] = file_exists("{$path}/variables.tf") ? 1 : 0;
|
||||
$this->collectedMetrics['has_outputs_tf'] = file_exists("{$path}/outputs.tf") ? 1 : 0;
|
||||
|
||||
|
||||
// Terraform lock
|
||||
$this->collectedMetrics['has_terraform_lock'] = file_exists("{$path}/.terraform.lock.hcl") ? 1 : 0;
|
||||
|
||||
|
||||
// Terraform directory
|
||||
$this->collectedMetrics['has_terraform_dir'] = is_dir("{$path}/.terraform") ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
private function collectWordPressMetrics(string $path): void
|
||||
{
|
||||
// PHP files
|
||||
$this->collectedMetrics['php_files'] = $this->countFiles($path, '*.php');
|
||||
|
||||
|
||||
// WordPress readme
|
||||
$this->collectedMetrics['has_wp_readme'] = file_exists("{$path}/readme.txt") ? 1 : 0;
|
||||
|
||||
|
||||
// Common WordPress directories
|
||||
$wpDirs = ['includes', 'assets', 'templates', 'languages'];
|
||||
$dirCount = 0;
|
||||
@@ -229,39 +230,39 @@ class ProjectMetricsCollector
|
||||
}
|
||||
}
|
||||
$this->collectedMetrics['wordpress_directories'] = $dirCount;
|
||||
|
||||
|
||||
// Assets
|
||||
$this->collectedMetrics['css_files'] = $this->countFiles($path, '*.css');
|
||||
$this->collectedMetrics['js_files'] = $this->countFiles($path, '*.js');
|
||||
}
|
||||
|
||||
|
||||
private function collectMobileMetrics(string $path): void
|
||||
{
|
||||
// Platform detection
|
||||
$this->collectedMetrics['has_ios'] = is_dir("{$path}/ios") ? 1 : 0;
|
||||
$this->collectedMetrics['has_android'] = is_dir("{$path}/android") ? 1 : 0;
|
||||
|
||||
|
||||
// Framework detection
|
||||
$this->collectedMetrics['is_react_native'] = false;
|
||||
$this->collectedMetrics['is_flutter'] = false;
|
||||
|
||||
|
||||
if (file_exists("{$path}/package.json")) {
|
||||
$package = json_decode(file_get_contents("{$path}/package.json"), true);
|
||||
if ($package && isset($package['dependencies']['react-native'])) {
|
||||
$this->collectedMetrics['is_react_native'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (file_exists("{$path}/pubspec.yaml")) {
|
||||
$this->collectedMetrics['is_flutter'] = 1;
|
||||
$this->collectedMetrics['dart_files'] = $this->countFiles($path, '*.dart');
|
||||
}
|
||||
|
||||
|
||||
// Build configurations
|
||||
$this->collectedMetrics['has_gradle'] = file_exists("{$path}/build.gradle") ? 1 : 0;
|
||||
$this->collectedMetrics['has_xcode_project'] = $this->countFiles($path, '*.xcodeproj') > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
private function collectAPIMetrics(string $path): void
|
||||
{
|
||||
// API documentation
|
||||
@@ -274,26 +275,26 @@ class ProjectMetricsCollector
|
||||
}
|
||||
}
|
||||
$this->collectedMetrics['has_api_documentation'] = $hasApiDoc ? 1 : 0;
|
||||
|
||||
|
||||
// GraphQL
|
||||
$this->collectedMetrics['graphql_files'] = $this->countFiles($path, '*.graphql');
|
||||
$this->collectedMetrics['has_graphql_schema'] = file_exists("{$path}/schema.graphql") ? 1 : 0;
|
||||
|
||||
|
||||
// Protocol Buffers
|
||||
$this->collectedMetrics['proto_files'] = $this->countFiles($path, '*.proto');
|
||||
|
||||
|
||||
// Docker
|
||||
$this->collectedMetrics['has_dockerfile'] = file_exists("{$path}/Dockerfile") ? 1 : 0;
|
||||
$this->collectedMetrics['has_docker_compose'] =
|
||||
$this->collectedMetrics['has_docker_compose'] =
|
||||
file_exists("{$path}/docker-compose.yml") || file_exists("{$path}/docker-compose.yaml") ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
private function countFiles(string $path, string $pattern): int
|
||||
{
|
||||
$files = glob("{$path}/{$pattern}");
|
||||
return count($files ?: []);
|
||||
}
|
||||
|
||||
|
||||
private function countDirectories(string $path): int
|
||||
{
|
||||
$dirs = glob("{$path}/*", GLOB_ONLYDIR);
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Interface for project type-specific enterprise plugins
|
||||
*
|
||||
*
|
||||
* Each project type (Joomla, Node.js, Python, etc.) implements this interface
|
||||
* to provide type-specific validation, metrics, and management capabilities.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,21 +21,23 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Project Type Detector
|
||||
*
|
||||
*
|
||||
* Enterprise library for automatically detecting project types based on
|
||||
* repository structure, configuration files, and code patterns.
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class ProjectTypeDetector
|
||||
{
|
||||
private const DETECTION_THRESHOLD = 0.5;
|
||||
|
||||
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
|
||||
|
||||
private array $detectionResults = [];
|
||||
private string $detectedType = 'generic';
|
||||
private float $confidence = 0.0;
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
@@ -45,19 +48,19 @@ class ProjectTypeDetector
|
||||
$this->logger = $logger ?? new AuditLogger('project_type_detector');
|
||||
$this->metrics = $metrics ?? new MetricsCollector();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detect project type from repository path
|
||||
*
|
||||
*
|
||||
* @param string $repoPath Path to repository
|
||||
* @return array Detection results with type and confidence
|
||||
*/
|
||||
public function detect(string $repoPath): array
|
||||
{
|
||||
$this->logger->logInfo("Detecting project type for: {$repoPath}");
|
||||
|
||||
|
||||
$this->resetResults();
|
||||
|
||||
|
||||
// Run all detection methods
|
||||
$this->detectJoomla($repoPath);
|
||||
$this->detectDolibarr($repoPath);
|
||||
@@ -67,23 +70,23 @@ class ProjectTypeDetector
|
||||
$this->detectWordPress($repoPath);
|
||||
$this->detectMobile($repoPath);
|
||||
$this->detectAPI($repoPath);
|
||||
|
||||
|
||||
// Determine best match
|
||||
$this->determineBestMatch();
|
||||
|
||||
|
||||
// Record metrics
|
||||
$this->metrics->increment("project_type_detected_{$this->detectedType}");
|
||||
$this->metrics->setGauge('detection_confidence', $this->confidence);
|
||||
|
||||
|
||||
$this->logger->logInfo("Detected type: {$this->detectedType} (confidence: {$this->confidence})");
|
||||
|
||||
|
||||
return [
|
||||
'type' => $this->detectedType,
|
||||
'confidence' => $this->confidence,
|
||||
'all_scores' => $this->detectionResults,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get detected project type
|
||||
*/
|
||||
@@ -91,7 +94,7 @@ class ProjectTypeDetector
|
||||
{
|
||||
return $this->detectedType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get detection confidence
|
||||
*/
|
||||
@@ -99,7 +102,7 @@ class ProjectTypeDetector
|
||||
{
|
||||
return $this->confidence;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all detection scores
|
||||
*/
|
||||
@@ -107,7 +110,7 @@ class ProjectTypeDetector
|
||||
{
|
||||
return $this->detectionResults;
|
||||
}
|
||||
|
||||
|
||||
private function resetResults(): void
|
||||
{
|
||||
$this->detectionResults = [
|
||||
@@ -124,32 +127,32 @@ class ProjectTypeDetector
|
||||
$this->detectedType = 'generic';
|
||||
$this->confidence = 0.0;
|
||||
}
|
||||
|
||||
|
||||
private function determineBestMatch(): void
|
||||
{
|
||||
$maxScore = 0.0;
|
||||
$bestType = 'generic';
|
||||
|
||||
|
||||
foreach ($this->detectionResults as $type => $score) {
|
||||
if ($score > $maxScore && $score >= self::DETECTION_THRESHOLD) {
|
||||
$maxScore = $score;
|
||||
$bestType = $type;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->detectedType = $bestType;
|
||||
$this->confidence = $maxScore;
|
||||
}
|
||||
|
||||
|
||||
private function detectJoomla(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
// Check for Joomla manifest files
|
||||
if ($this->fileExists($path, '*.xml', ['extension', 'install'])) {
|
||||
$score += 0.5;
|
||||
}
|
||||
|
||||
|
||||
// Check for Joomla directories
|
||||
$joomlaDirs = ['site', 'admin', 'administrator', 'language', 'media'];
|
||||
foreach ($joomlaDirs as $dir) {
|
||||
@@ -157,19 +160,19 @@ class ProjectTypeDetector
|
||||
$score += 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['joomla'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectDolibarr(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
// Check for Dolibarr module descriptor
|
||||
if ($this->fileContains($path, 'mod*.class.php', 'DolibarrModules')) {
|
||||
$score += 0.6;
|
||||
}
|
||||
|
||||
|
||||
// Check for Dolibarr directories
|
||||
$dolibarrDirs = ['core/modules', 'sql', 'class', 'lib'];
|
||||
foreach ($dolibarrDirs as $dir) {
|
||||
@@ -177,17 +180,17 @@ class ProjectTypeDetector
|
||||
$score += 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['dolibarr'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectNodeJS(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
if (file_exists("{$path}/package.json")) {
|
||||
$score += 0.6;
|
||||
|
||||
|
||||
$content = @file_get_contents("{$path}/package.json");
|
||||
if ($content) {
|
||||
if (strpos($content, '"typescript"') !== false) {
|
||||
@@ -198,45 +201,45 @@ class ProjectTypeDetector
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (file_exists("{$path}/tsconfig.json")) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['nodejs'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectPython(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
if (file_exists("{$path}/setup.py") || file_exists("{$path}/pyproject.toml")) {
|
||||
$score += 0.6;
|
||||
}
|
||||
|
||||
|
||||
if (file_exists("{$path}/requirements.txt")) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
if (file_exists("{$path}/Pipfile") || file_exists("{$path}/poetry.lock")) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['python'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectTerraform(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
if ($this->fileExists($path, '*.tf')) {
|
||||
$score += 0.6;
|
||||
}
|
||||
|
||||
|
||||
if (file_exists("{$path}/.terraform.lock.hcl")) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
$commonFiles = ['main.tf', 'variables.tf', 'outputs.tf'];
|
||||
$found = 0;
|
||||
foreach ($commonFiles as $file) {
|
||||
@@ -247,19 +250,21 @@ class ProjectTypeDetector
|
||||
if ($found >= 2) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['terraform'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectWordPress(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
if ($this->fileContains($path, '*.php', 'Plugin Name:') ||
|
||||
$this->fileContains($path, '*.php', 'Theme Name:')) {
|
||||
|
||||
if (
|
||||
$this->fileContains($path, '*.php', 'Plugin Name:') ||
|
||||
$this->fileContains($path, '*.php', 'Theme Name:')
|
||||
) {
|
||||
$score += 0.6;
|
||||
}
|
||||
|
||||
|
||||
$wpFunctions = ['add_action', 'add_filter', 'wp_enqueue_script'];
|
||||
foreach ($wpFunctions as $func) {
|
||||
if ($this->fileContains($path, '*.php', $func)) {
|
||||
@@ -267,14 +272,14 @@ class ProjectTypeDetector
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['wordpress'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectMobile(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
// React Native
|
||||
if (file_exists("{$path}/package.json")) {
|
||||
$content = @file_get_contents("{$path}/package.json");
|
||||
@@ -282,24 +287,24 @@ class ProjectTypeDetector
|
||||
$score += 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Flutter
|
||||
if (file_exists("{$path}/pubspec.yaml")) {
|
||||
$score += 0.6;
|
||||
}
|
||||
|
||||
|
||||
// Native iOS/Android
|
||||
if ($this->fileExists($path, '*.xcodeproj') || file_exists("{$path}/build.gradle")) {
|
||||
$score += 0.4;
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['mobile'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function detectAPI(string $path): void
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
|
||||
// API documentation
|
||||
$apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json'];
|
||||
foreach ($apiDocs as $doc) {
|
||||
@@ -308,66 +313,70 @@ class ProjectTypeDetector
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GraphQL
|
||||
if ($this->fileExists($path, '*.graphql') || file_exists("{$path}/schema.graphql")) {
|
||||
$score += 0.3;
|
||||
}
|
||||
|
||||
|
||||
// Docker (common in APIs)
|
||||
if (file_exists("{$path}/Dockerfile")) {
|
||||
$score += 0.2;
|
||||
}
|
||||
|
||||
|
||||
// API frameworks
|
||||
if ($this->fileContains($path, '*.py', '@app.route') ||
|
||||
if (
|
||||
$this->fileContains($path, '*.py', '@app.route') ||
|
||||
$this->fileContains($path, '*.js', 'express()') ||
|
||||
$this->fileContains($path, '*.ts', '@Controller')) {
|
||||
$this->fileContains($path, '*.ts', '@Controller')
|
||||
) {
|
||||
$score += 0.3;
|
||||
}
|
||||
|
||||
|
||||
$this->detectionResults['api'] = min(1.0, $score);
|
||||
}
|
||||
|
||||
|
||||
private function fileExists(string $path, string $pattern, array $contains = []): bool
|
||||
{
|
||||
$files = glob("{$path}/{$pattern}");
|
||||
if (empty($files)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (empty($contains)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
foreach ($files as $file) {
|
||||
$content = @file_get_contents($file);
|
||||
if (!$content) continue;
|
||||
|
||||
if (!$content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($contains as $search) {
|
||||
if (strpos($content, $search) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private function fileContains(string $path, string $pattern, string $search): bool
|
||||
{
|
||||
$files = glob("{$path}/{$pattern}");
|
||||
if (empty($files)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
foreach ($files as $file) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content && strpos($content, $search) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ use DateTimeZone;
|
||||
* Example:
|
||||
* ```php
|
||||
* $manager = new RecoveryManager();
|
||||
*
|
||||
*
|
||||
* if ($manager->canRecover('my_operation')) {
|
||||
* $state = $manager->recoverOperation('my_operation');
|
||||
* // Resume from saved state
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -20,16 +21,17 @@ namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Repository Health Checker
|
||||
*
|
||||
*
|
||||
* Enterprise library for performing comprehensive repository health checks
|
||||
* with scoring system and category-based validation.
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class RepositoryHealthChecker
|
||||
{
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private UnifiedValidation $validator;
|
||||
|
||||
|
||||
private array $results = [
|
||||
'categories' => [],
|
||||
'checks' => [],
|
||||
@@ -38,51 +40,52 @@ class RepositoryHealthChecker
|
||||
'percentage' => 0.0,
|
||||
'level' => 'unknown',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(
|
||||
?AuditLogger $logger = null,
|
||||
?MetricsCollector $metrics = null,
|
||||
?UnifiedValidation $validator = null
|
||||
?UnifiedValidator $validator = null
|
||||
) {
|
||||
$this->logger = $logger ?? new AuditLogger('repo_health_checker');
|
||||
$this->metrics = $metrics ?? new MetricsCollector();
|
||||
$this->validator = $validator ?? new UnifiedValidation();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check repository health
|
||||
*
|
||||
*
|
||||
* @param string $path Repository path to check
|
||||
* @return array Health check results
|
||||
*/
|
||||
public function check(string $path): array
|
||||
{
|
||||
$this->logger->logInfo("Starting health check for: {$path}");
|
||||
|
||||
|
||||
$this->resetResults();
|
||||
|
||||
|
||||
// Run all check categories
|
||||
$this->runStructureChecks($path);
|
||||
$this->runDocumentationChecks($path);
|
||||
$this->runWorkflowChecks($path);
|
||||
$this->runSecurityChecks($path);
|
||||
|
||||
|
||||
// Calculate final scores
|
||||
$this->calculateScore();
|
||||
|
||||
|
||||
// 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'])));
|
||||
|
||||
$this->metrics->setGauge(
|
||||
'repo_health_checks_passed',
|
||||
count(array_filter($this->results['checks'], fn($c) => $c['passed']))
|
||||
);
|
||||
|
||||
$this->logger->logInfo("Health check complete: {$this->results['percentage']}% ({$this->results['level']})");
|
||||
|
||||
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset results for new check
|
||||
*/
|
||||
@@ -97,7 +100,7 @@ class RepositoryHealthChecker
|
||||
'level' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run repository structure checks
|
||||
*/
|
||||
@@ -111,24 +114,40 @@ class RepositoryHealthChecker
|
||||
'checks_passed' => 0,
|
||||
'checks_failed' => 0,
|
||||
];
|
||||
|
||||
|
||||
// Check README exists
|
||||
$this->addCheck($category, 'README.md exists',
|
||||
file_exists("{$path}/README.md"), 10);
|
||||
|
||||
$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);
|
||||
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'LICENSE file exists',
|
||||
file_exists("{$path}/LICENSE"),
|
||||
10
|
||||
);
|
||||
|
||||
// Check .gitignore exists
|
||||
$this->addCheck($category, '.gitignore exists',
|
||||
file_exists("{$path}/.gitignore"), 5);
|
||||
|
||||
$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);
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'CHANGELOG.md exists',
|
||||
file_exists("{$path}/CHANGELOG.md"),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run documentation checks
|
||||
*/
|
||||
@@ -142,23 +161,35 @@ class RepositoryHealthChecker
|
||||
'checks_passed' => 0,
|
||||
'checks_failed' => 0,
|
||||
];
|
||||
|
||||
|
||||
// Check docs directory exists
|
||||
$this->addCheck($category, 'docs/ directory exists',
|
||||
is_dir("{$path}/docs"), 10);
|
||||
|
||||
$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);
|
||||
$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);
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'CODE_OF_CONDUCT.md exists',
|
||||
file_exists("{$path}/CODE_OF_CONDUCT.md"),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run workflow checks
|
||||
*/
|
||||
@@ -180,17 +211,25 @@ class RepositoryHealthChecker
|
||||
$workflowDir = is_dir($giteaDir) ? $giteaDir : $githubDir;
|
||||
|
||||
// Check workflows directory exists
|
||||
$this->addCheck($category, 'Workflows directory exists',
|
||||
$hasWorkflowDir, 10);
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'Workflows directory exists',
|
||||
$hasWorkflowDir,
|
||||
10
|
||||
);
|
||||
|
||||
// Check for CI workflow
|
||||
if ($hasWorkflowDir) {
|
||||
$hasCI = !empty(glob("{$workflowDir}/ci*.yml")) || !empty(glob("{$workflowDir}/ci*.yaml"));
|
||||
$this->addCheck($category, 'CI workflow exists',
|
||||
$hasCI, 10);
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'CI workflow exists',
|
||||
$hasCI,
|
||||
10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run security checks
|
||||
*/
|
||||
@@ -204,12 +243,16 @@ class RepositoryHealthChecker
|
||||
'checks_passed' => 0,
|
||||
'checks_failed' => 0,
|
||||
];
|
||||
|
||||
|
||||
// Check for SECURITY.md
|
||||
$this->addCheck($category, 'SECURITY.md exists',
|
||||
file_exists("{$path}/SECURITY.md") ||
|
||||
file_exists("{$path}/.github/SECURITY.md"), 10);
|
||||
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'SECURITY.md exists',
|
||||
file_exists("{$path}/SECURITY.md") ||
|
||||
file_exists("{$path}/.github/SECURITY.md"),
|
||||
10
|
||||
);
|
||||
|
||||
// Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea)
|
||||
$githubWf = "{$path}/.github/workflows";
|
||||
$giteaWf = "{$path}/.mokogitea/workflows";
|
||||
@@ -220,17 +263,25 @@ class RepositoryHealthChecker
|
||||
if (!$hasSecurityScan && is_dir($giteaWf)) {
|
||||
$hasSecurityScan = !empty(glob("{$giteaWf}/*trivy*.yml")) || !empty(glob("{$giteaWf}/*trivy*.yaml"));
|
||||
}
|
||||
$this->addCheck($category, 'Security scanning workflow exists',
|
||||
$hasSecurityScan, 10);
|
||||
$this->addCheck(
|
||||
$category,
|
||||
'Security scanning workflow exists',
|
||||
$hasSecurityScan,
|
||||
10
|
||||
);
|
||||
|
||||
// Check for dependency management (Dependabot on GitHub, Renovate on Gitea)
|
||||
$this->addCheck($category, 'Dependency management configured',
|
||||
$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);
|
||||
file_exists("{$path}/.renovaterc.json"),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a check result
|
||||
*/
|
||||
@@ -242,7 +293,7 @@ class RepositoryHealthChecker
|
||||
'passed' => $passed,
|
||||
'points' => $points,
|
||||
];
|
||||
|
||||
|
||||
if ($passed) {
|
||||
$this->results['categories'][$category]['earned_points'] += $points;
|
||||
$this->results['categories'][$category]['checks_passed']++;
|
||||
@@ -250,7 +301,7 @@ class RepositoryHealthChecker
|
||||
$this->results['categories'][$category]['checks_failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate overall score and health level
|
||||
*/
|
||||
@@ -258,16 +309,16 @@ class RepositoryHealthChecker
|
||||
{
|
||||
$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 health level
|
||||
$pct = $this->results['percentage'];
|
||||
if ($pct >= 90) {
|
||||
@@ -282,30 +333,30 @@ class RepositoryHealthChecker
|
||||
$this->results['level'] = 'critical';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get failed checks
|
||||
*
|
||||
*
|
||||
* @return array Array of failed checks
|
||||
*/
|
||||
public function getFailedChecks(): array
|
||||
{
|
||||
return array_filter($this->results['checks'], fn($c) => !$c['passed']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get passed checks
|
||||
*
|
||||
*
|
||||
* @return array Array of passed checks
|
||||
*/
|
||||
public function getPassedChecks(): array
|
||||
{
|
||||
return array_filter($this->results['checks'], fn($c) => $c['passed']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if repository meets threshold
|
||||
*
|
||||
*
|
||||
* @param float $threshold Minimum percentage required
|
||||
* @return bool True if meets threshold
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -23,9 +24,11 @@ use RuntimeException;
|
||||
|
||||
/**
|
||||
* Repository Synchronizer
|
||||
*
|
||||
*
|
||||
* Enterprise library for synchronizing files across multiple repositories
|
||||
* based on configuration and override files.
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class RepositorySynchronizer
|
||||
{
|
||||
@@ -38,13 +41,12 @@ class RepositorySynchronizer
|
||||
private const VERSION_BRANCH = 'version/' . self::STANDARDS_MAJOR;
|
||||
private const SYNC_BRANCH = 'chore/sync-mokostandards-v' . self::STANDARDS_MINOR;
|
||||
|
||||
private ApiClient $apiClient;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private CheckpointManager $checkpoints;
|
||||
private DefinitionParser $definitionParser;
|
||||
private MokoStandardsParser $manifestParser;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private CheckpointManager $checkpoints;
|
||||
private DefinitionParser $definitionParser;
|
||||
private MokoStandardsParser $manifestParser;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@@ -64,7 +66,6 @@ class RepositorySynchronizer
|
||||
?DefinitionParser $definitionParser = null,
|
||||
?GitPlatformAdapter $adapter = null
|
||||
) {
|
||||
$this->apiClient = $apiClient;
|
||||
$this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient);
|
||||
$this->logger = $logger;
|
||||
$this->metrics = $metrics;
|
||||
@@ -72,10 +73,10 @@ class RepositorySynchronizer
|
||||
$this->definitionParser = $definitionParser ?? new DefinitionParser();
|
||||
$this->manifestParser = new MokoStandardsParser();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get list of repositories for an organization
|
||||
*
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param bool $skipArchived Whether to skip archived repositories
|
||||
* @return array Array of repository information
|
||||
@@ -86,10 +87,10 @@ class RepositorySynchronizer
|
||||
$this->metrics->setGauge('repositories_found', count($repos));
|
||||
return $repos;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if repository has override file
|
||||
*
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return bool True if override file exists
|
||||
@@ -99,15 +100,15 @@ class RepositorySynchronizer
|
||||
try {
|
||||
$overridePath = $this->adapter->getMetadataDir() . '/' . self::SYNC_OVERRIDE_FILE_SUFFIX;
|
||||
$override = $this->adapter->getFileContents($org, $repo, $overridePath);
|
||||
return !empty($override);
|
||||
return $override !== '';
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process single repository
|
||||
*
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param bool $dryRun Whether to perform a dry run
|
||||
@@ -147,17 +148,16 @@ class RepositorySynchronizer
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$txn->end('failure');
|
||||
$this->logger->logError("Failed to process repository {$repo}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Synchronize files to a repository
|
||||
*
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param bool $force Force override protected files
|
||||
@@ -199,7 +199,11 @@ class RepositorySynchronizer
|
||||
$defCount = count($filesToSync) - count($sharedFiles);
|
||||
$sharedAdded = count($filesToSync) - $defCount;
|
||||
$sharedTotal = count($sharedFiles);
|
||||
$this->logger->logInfo("Loaded " . count($filesToSync) . " sync entries for {$platform} (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, " . ($sharedTotal - $sharedAdded) . " deduped)");
|
||||
$this->logger->logInfo(
|
||||
"Loaded " . count($filesToSync) . " sync entries for {$platform}"
|
||||
. " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, "
|
||||
. ($sharedTotal - $sharedAdded) . " deduped)"
|
||||
);
|
||||
// Log shared workflow destinations for debugging
|
||||
foreach ($sharedFiles as $sf) {
|
||||
$dest = $sf['destination'] ?? '?';
|
||||
@@ -242,7 +246,7 @@ class RepositorySynchronizer
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if there's already an open PR for sync
|
||||
*/
|
||||
@@ -263,7 +267,7 @@ class RepositorySynchronizer
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate / update the repository tracking definition after a successful sync.
|
||||
*
|
||||
@@ -388,13 +392,12 @@ HCL;
|
||||
$this->metrics->increment('definitions_generated');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logger->logError("Failed to write tracking definition for {$repo}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detect platform from repository info
|
||||
*/
|
||||
@@ -497,8 +500,10 @@ HCL;
|
||||
}
|
||||
|
||||
// Check description patterns
|
||||
if (str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template')
|
||||
|| str_contains($description, 'joomla 4 template')) {
|
||||
if (
|
||||
str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template')
|
||||
|| str_contains($description, 'joomla 4 template')
|
||||
) {
|
||||
return 'joomla';
|
||||
}
|
||||
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
|
||||
@@ -545,13 +550,17 @@ HCL;
|
||||
$this->logger->logWarning("Could not list branches for {$repo}, syncing default only: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->logger->logInfo("Syncing files to {$org}/{$repo} across " . count($branchesToSync) . " branch(es): " . implode(', ', $branchesToSync));
|
||||
$this->logger->logInfo(
|
||||
"Syncing files to {$org}/{$repo} across "
|
||||
. count($branchesToSync) . " branch(es): "
|
||||
. implode(', ', $branchesToSync)
|
||||
);
|
||||
|
||||
// Sync to each branch
|
||||
$combinedSummary = ['copied' => [], 'skipped' => [], 'total' => 0];
|
||||
foreach ($branchesToSync as $branchName) {
|
||||
$this->logger->logInfo(" Syncing branch: {$branchName}");
|
||||
$branchSummary = $this->syncFilesToBranch($org, $repo, $platform, $filesToSync, $repoRoot, $force, $branchName, $moduleId ?? null);
|
||||
$branchSummary = $this->syncFilesToBranch($org, $repo, $platform, $filesToSync, $repoRoot, $force, $branchName, null);
|
||||
// Merge summaries — only count first branch's copied files to avoid duplicates in tracking
|
||||
if ($branchName === $defaultBranch) {
|
||||
$combinedSummary = $branchSummary;
|
||||
@@ -581,13 +590,16 @@ HCL;
|
||||
'assignees' => ['jmiller'],
|
||||
]);
|
||||
$issueNumber = $issueData['number'] ?? null;
|
||||
$this->logger->logInfo("Created tracking issue #{$issueNumber} — " . count($summary['copied']) . " files synced directly to {$defaultBranch}");
|
||||
$this->logger->logInfo(
|
||||
"Created tracking issue #{$issueNumber} — "
|
||||
. count($summary['copied'])
|
||||
. " files synced directly to {$defaultBranch}"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->logWarning("Could not create tracking issue: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return ['number' => $issueNumber, 'summary' => $summary];
|
||||
|
||||
} catch (CircuitBreakerOpen | RateLimitExceeded $e) {
|
||||
$this->logger->logError("Sync failed: " . $e->getMessage());
|
||||
throw $e;
|
||||
@@ -596,7 +608,7 @@ HCL;
|
||||
return $nullResult;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace all {{TOKEN}} placeholders in a template file with repo-specific values.
|
||||
*
|
||||
@@ -655,8 +667,16 @@ HCL;
|
||||
* @param string|null $moduleId Dolibarr module ID (pre-fetched)
|
||||
* @return array Summary of operations
|
||||
*/
|
||||
private function syncFilesToBranch(string $org, string $repo, string $platform, array $filesToSync, string $repoRoot, bool $force, string $branchName, ?string $moduleId): array
|
||||
{
|
||||
private function syncFilesToBranch(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $platform,
|
||||
array $filesToSync,
|
||||
string $repoRoot,
|
||||
bool $force,
|
||||
string $branchName,
|
||||
?string $moduleId
|
||||
): array {
|
||||
$repoInfo = $this->adapter->getRepo($org, $repo);
|
||||
$summary = ['copied' => [], 'skipped' => [], 'total' => 0];
|
||||
|
||||
@@ -719,19 +739,24 @@ HCL;
|
||||
}
|
||||
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $targetPath, $content,
|
||||
$org,
|
||||
$repo,
|
||||
$targetPath,
|
||||
$content,
|
||||
"chore: update {$targetPath} from MokoStandards",
|
||||
$existingFile['sha'] ?? null,
|
||||
$branchName
|
||||
);
|
||||
$this->logger->logInfo("Updated: {$targetPath} ({$branchName})");
|
||||
$summary['copied'][] = ['file' => $targetPath, 'action' => 'updated'];
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->adapter->getApiClient()->resetCircuitBreaker();
|
||||
try {
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $targetPath, $content,
|
||||
$org,
|
||||
$repo,
|
||||
$targetPath,
|
||||
$content,
|
||||
"chore: add {$targetPath} from MokoStandards",
|
||||
null,
|
||||
$branchName
|
||||
@@ -744,7 +769,10 @@ HCL;
|
||||
$this->adapter->getApiClient()->resetCircuitBreaker();
|
||||
$existing = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName);
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $targetPath, $content,
|
||||
$org,
|
||||
$repo,
|
||||
$targetPath,
|
||||
$content,
|
||||
"chore: update {$targetPath} from MokoStandards",
|
||||
$existing['sha'] ?? null,
|
||||
$branchName
|
||||
@@ -778,8 +806,8 @@ HCL;
|
||||
string $repo,
|
||||
string $branchName,
|
||||
string $platform,
|
||||
array $repoInfo,
|
||||
array &$summary
|
||||
array $repoInfo,
|
||||
array &$summary
|
||||
): void {
|
||||
$metaDir = $this->adapter->getMetadataDir();
|
||||
$targetPath = "{$metaDir}/.mokostandards";
|
||||
@@ -847,8 +875,13 @@ HCL;
|
||||
|
||||
try {
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $targetPath, $xmlContent,
|
||||
$commitMsg, $targetSha, $branchName
|
||||
$org,
|
||||
$repo,
|
||||
$targetPath,
|
||||
$xmlContent,
|
||||
$commitMsg,
|
||||
$targetSha,
|
||||
$branchName
|
||||
);
|
||||
$this->logger->logInfo(ucfirst($action) . "d XML .mokostandards → {$targetPath}");
|
||||
$summary['copied'][] = ['file' => $targetPath, 'action' => "{$action}d (XML manifest)"];
|
||||
@@ -866,7 +899,10 @@ HCL;
|
||||
}
|
||||
try {
|
||||
$this->adapter->deleteFile(
|
||||
$org, $repo, $path, $data['sha'],
|
||||
$org,
|
||||
$repo,
|
||||
$path,
|
||||
$data['sha'],
|
||||
"chore: remove legacy {$path} (replaced by {$targetPath})",
|
||||
$branchName
|
||||
);
|
||||
@@ -891,10 +927,10 @@ HCL;
|
||||
* @return string Well-formed XML content
|
||||
*/
|
||||
private function generateMokoStandardsXml(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $platform,
|
||||
array $repoInfo,
|
||||
string $org,
|
||||
string $repo,
|
||||
string $platform,
|
||||
array $repoInfo,
|
||||
?string $existingContent
|
||||
): string {
|
||||
$params = [
|
||||
@@ -1029,7 +1065,10 @@ HCL;
|
||||
|
||||
try {
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, 'composer.json', $newContent,
|
||||
$org,
|
||||
$repo,
|
||||
'composer.json',
|
||||
$newContent,
|
||||
'chore: add mokoconsulting-tech/enterprise dependency',
|
||||
$file['sha'] ?? null,
|
||||
$branchName
|
||||
@@ -1044,28 +1083,140 @@ HCL;
|
||||
/**
|
||||
* Template repo mapping — canonical source for each platform's workflows.
|
||||
* The sync engine clones these at runtime to get the latest workflow files.
|
||||
*
|
||||
* Template-Generic is the single source of truth for universal workflows.
|
||||
* During sync, universal workflows flow: Generic → Joomla/Dolibarr → governed repos.
|
||||
*/
|
||||
private const TEMPLATE_REPOS = [
|
||||
'joomla' => 'MokoConsulting/MokoStandards-Template-Joomla',
|
||||
'dolibarr' => 'MokoConsulting/MokoStandards-Template-Dolibarr',
|
||||
'generic' => 'MokoConsulting/MokoStandards-Template-Generic',
|
||||
'client' => 'MokoConsulting/MokoStandards-Template-Client',
|
||||
'joomla' => 'MokoConsulting/Template-Joomla',
|
||||
'waas-component' => 'MokoConsulting/Template-Joomla',
|
||||
'dolibarr' => 'MokoConsulting/Template-Dolibarr',
|
||||
'crm-module' => 'MokoConsulting/Template-Dolibarr',
|
||||
'generic' => 'MokoConsulting/Template-Generic',
|
||||
'mcp' => 'MokoConsulting/Template-Generic',
|
||||
'client' => 'MokoConsulting/Template-Client-WaaS',
|
||||
];
|
||||
|
||||
/**
|
||||
* Universal workflows sourced from Template-Generic and pushed to all templates.
|
||||
* These are platform-agnostic — they detect platform from manifest.xml at runtime.
|
||||
*/
|
||||
private const UNIVERSAL_WORKFLOWS = [
|
||||
'auto-release.yml',
|
||||
'pre-release.yml',
|
||||
];
|
||||
|
||||
/**
|
||||
* All template repos that receive universal workflows from Template-Generic.
|
||||
*/
|
||||
private const TEMPLATE_SYNC_TARGETS = [
|
||||
'MokoConsulting/Template-Joomla',
|
||||
'MokoConsulting/Template-Dolibarr',
|
||||
'MokoConsulting/Template-Client-WaaS',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sync universal workflows from Template-Generic to all other template repos.
|
||||
*
|
||||
* This ensures Template-Generic is the single source of truth for universal
|
||||
* workflows (auto-release.yml, pre-release.yml). Called once at the start
|
||||
* of a bulk sync before processing individual repos.
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @return int Number of files updated across template repos
|
||||
*/
|
||||
public function syncUniversalWorkflowsToTemplates(string $org): int
|
||||
{
|
||||
$wfDir = $this->adapter->getWorkflowDir();
|
||||
$genericRepo = self::TEMPLATE_REPOS['generic'];
|
||||
$genericParts = explode('/', $genericRepo);
|
||||
$genericOrg = $genericParts[0];
|
||||
$genericName = $genericParts[1];
|
||||
$updated = 0;
|
||||
|
||||
// Read universal workflow files from Template-Generic
|
||||
$sourceFiles = [];
|
||||
foreach (self::UNIVERSAL_WORKFLOWS as $wfName) {
|
||||
$path = "{$wfDir}/{$wfName}";
|
||||
try {
|
||||
$file = $this->adapter->getFileContents($genericOrg, $genericName, $path, 'dev');
|
||||
$content = $file['content'] ?? '';
|
||||
if (!empty($content)) {
|
||||
$sourceFiles[$wfName] = [
|
||||
'content' => $content,
|
||||
'path' => $path,
|
||||
];
|
||||
$this->logger->logInfo("Read universal workflow: {$wfName} from {$genericRepo}");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logger->logWarning("Failed to read {$wfName} from {$genericRepo}: " . $e->getMessage());
|
||||
$this->adapter->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($sourceFiles)) {
|
||||
$this->logger->logWarning("No universal workflows found in {$genericRepo}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Push to each template target
|
||||
foreach (self::TEMPLATE_SYNC_TARGETS as $targetRepo) {
|
||||
$targetParts = explode('/', $targetRepo);
|
||||
$targetOrg = $targetParts[0];
|
||||
$targetName = $targetParts[1];
|
||||
|
||||
foreach ($sourceFiles as $wfName => $source) {
|
||||
$destPath = $source['path'];
|
||||
try {
|
||||
// Get existing file SHA for update
|
||||
$existing = null;
|
||||
try {
|
||||
$existing = $this->adapter->getFileContents($targetOrg, $targetName, $destPath, 'dev');
|
||||
} catch (Exception $e) {
|
||||
$this->adapter->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
$existingSha = $existing['sha'] ?? null;
|
||||
|
||||
// Skip if content is identical
|
||||
if ($existing !== null) {
|
||||
$existingContent = $existing['content'] ?? '';
|
||||
if (str_replace("\n", '', $existingContent) === str_replace("\n", '', $source['content'])) {
|
||||
$this->logger->logInfo(" {$targetName}/{$wfName}: identical — skipped");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Push update via Contents API
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$targetOrg,
|
||||
$targetName,
|
||||
$destPath,
|
||||
$source['content'],
|
||||
"chore(ci): sync {$wfName} from Template-Generic [skip ci]",
|
||||
$existingSha,
|
||||
'dev'
|
||||
);
|
||||
|
||||
$this->logger->logInfo(" {$targetName}/{$wfName}: updated");
|
||||
$updated++;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->logWarning(" {$targetName}/{$wfName}: failed — " . $e->getMessage());
|
||||
$this->adapter->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->logInfo("Universal workflow sync complete: {$updated} file(s) updated across templates");
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function getSharedWorkflows(string $platform, string $repoRoot): array
|
||||
{
|
||||
$wfDir = $this->adapter->getWorkflowDir();
|
||||
|
||||
// Determine which template repo to source from
|
||||
$templateType = match (true) {
|
||||
in_array($platform, ['dolibarr', 'platform']) => 'dolibarr',
|
||||
in_array($platform, ['joomla', 'joomla']) => 'joomla',
|
||||
str_starts_with($platform, 'client') => 'client',
|
||||
default => 'generic',
|
||||
};
|
||||
|
||||
// Clone template repo to tmp if not already cached
|
||||
$templateRepo = self::TEMPLATE_REPOS[$templateType];
|
||||
$templateRepo = self::TEMPLATE_REPOS[$platform] ?? self::TEMPLATE_REPOS['generic'];
|
||||
$cacheDir = sys_get_temp_dir() . '/mokostandards-sync/' . basename($templateRepo);
|
||||
|
||||
if (!is_dir($cacheDir)) {
|
||||
@@ -1077,8 +1228,12 @@ HCL;
|
||||
}
|
||||
}
|
||||
|
||||
// Read all .yml files from the template's .gitea/workflows/
|
||||
$sourceDir = "{$cacheDir}/.gitea/workflows";
|
||||
// Read all .yml files from the template's workflow directory
|
||||
$sourceDir = "{$cacheDir}/.mokogitea/workflows";
|
||||
// Fallback to legacy path if .mokogitea doesn't exist
|
||||
if (!is_dir($sourceDir)) {
|
||||
$sourceDir = "{$cacheDir}/.gitea/workflows";
|
||||
}
|
||||
$shared = [];
|
||||
|
||||
if (is_dir($sourceDir)) {
|
||||
@@ -1098,14 +1253,15 @@ HCL;
|
||||
'dolibarr' => 'templates/configs/gitignore.dolibarr',
|
||||
'platform' => 'templates/configs/gitignore.dolibarr',
|
||||
'joomla' => 'templates/configs/.gitignore.joomla',
|
||||
'joomla' => 'templates/configs/.gitignore.joomla',
|
||||
];
|
||||
$gitignoreTemplate = $gitignoreMap[$platform] ?? 'templates/configs/gitignore';
|
||||
$shared[] = [$gitignoreTemplate, '.gitignore'];
|
||||
|
||||
// Create TODO.md stub if it doesn't exist (gitignored after first commit)
|
||||
$entries[] = [
|
||||
'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in version control (.gitignore). It is for local task tracking only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n",
|
||||
'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in "
|
||||
. "version control (.gitignore). It is for local task tracking "
|
||||
. "only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n",
|
||||
'destination' => 'TODO.md',
|
||||
'always_overwrite' => false,
|
||||
];
|
||||
@@ -1123,7 +1279,7 @@ HCL;
|
||||
];
|
||||
|
||||
foreach ($shared as [$source, $dest]) {
|
||||
$fullSource = "{$root}/{$source}";
|
||||
$fullSource = "{$repoRoot}/{$source}";
|
||||
if (file_exists($fullSource)) {
|
||||
$entries[] = [
|
||||
'source' => $source, // relative — RepositorySynchronizer prepends repoRoot
|
||||
@@ -1276,11 +1432,11 @@ HCL;
|
||||
* @return string Processed content
|
||||
*/
|
||||
private function processTemplateContent(
|
||||
string $content,
|
||||
string $repo,
|
||||
string $org = '',
|
||||
string $platform = '',
|
||||
array $repoInfo = [],
|
||||
string $content,
|
||||
string $repo,
|
||||
string $org = '',
|
||||
string $platform = '',
|
||||
array $repoInfo = [],
|
||||
?string $moduleId = null
|
||||
): string {
|
||||
// Strip .template suffix from workflow file references
|
||||
@@ -1381,7 +1537,7 @@ HCL;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate PR body text
|
||||
*/
|
||||
@@ -1389,14 +1545,14 @@ HCL;
|
||||
{
|
||||
$body = "## MokoStandards Synchronization\n\n";
|
||||
$body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository.\n\n";
|
||||
|
||||
|
||||
// Summary statistics
|
||||
$body .= "### Summary\n";
|
||||
$body .= "- 🆕 **Created**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'created')) . " files\n";
|
||||
$body .= "- 🔄 **Updated**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'updated')) . " files\n";
|
||||
$body .= "- ⚠️ **Skipped**: " . count($summary['skipped']) . " files\n";
|
||||
$body .= "- 📊 **Total**: " . $summary['total'] . " files processed\n\n";
|
||||
|
||||
|
||||
// List copied files
|
||||
if (!empty($summary['copied'])) {
|
||||
$body .= "### Files Copied\n\n";
|
||||
@@ -1406,7 +1562,7 @@ HCL;
|
||||
}
|
||||
$body .= "\n";
|
||||
}
|
||||
|
||||
|
||||
// List skipped files
|
||||
if (!empty($summary['skipped'])) {
|
||||
$body .= "### Files Skipped\n\n";
|
||||
@@ -1415,22 +1571,22 @@ HCL;
|
||||
}
|
||||
$body .= "\n";
|
||||
}
|
||||
|
||||
|
||||
$body .= "### Review Notes\n";
|
||||
$body .= "- Please review all changes carefully\n";
|
||||
$body .= "- Ensure no custom configurations are overwritten\n";
|
||||
$body .= "- Test workflows and scripts after merging\n";
|
||||
$body .= "- Verify issue templates render correctly\n\n";
|
||||
|
||||
|
||||
$body .= "---\n";
|
||||
$body .= "*This PR was automatically generated by the MokoStandards bulk sync process.*\n";
|
||||
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Synchronize multiple repositories
|
||||
*
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param array $options Sync options (repo, skipArchived, dryRun, force)
|
||||
* @return array Sync results with statistics
|
||||
@@ -1441,17 +1597,17 @@ HCL;
|
||||
$skipArchived = $options['skipArchived'] ?? false;
|
||||
$dryRun = $options['dryRun'] ?? false;
|
||||
$force = $options['force'] ?? false;
|
||||
|
||||
|
||||
$txn = $this->logger->startTransaction('bulk_synchronize');
|
||||
|
||||
|
||||
try {
|
||||
// Get list of repositories
|
||||
$repos = $this->getRepositories($org, $skipArchived);
|
||||
|
||||
|
||||
if ($specificRepo) {
|
||||
$repos = array_filter($repos, fn($repo) => $repo['name'] === $specificRepo);
|
||||
}
|
||||
|
||||
|
||||
$total = count($repos);
|
||||
$results = [
|
||||
'total' => $total,
|
||||
@@ -1460,29 +1616,29 @@ HCL;
|
||||
'failed' => 0,
|
||||
'repositories' => [],
|
||||
];
|
||||
|
||||
|
||||
foreach ($repos as $index => $repo) {
|
||||
$repoName = $repo['name'];
|
||||
$progress = $index + 1;
|
||||
|
||||
|
||||
try {
|
||||
$updated = $this->processRepository($org, $repoName, $dryRun, $force);
|
||||
|
||||
|
||||
if ($updated) {
|
||||
$results['success']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'success']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'success']);
|
||||
$results['repositories'][$repoName] = 'updated';
|
||||
} else {
|
||||
$results['skipped']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'skipped']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'skipped']);
|
||||
$results['repositories'][$repoName] = 'skipped';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$results['failed']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'failed']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'failed']);
|
||||
$results['repositories'][$repoName] = 'failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
|
||||
// Save checkpoint
|
||||
$this->checkpoints->saveCheckpoint('bulk_sync', [
|
||||
'processed' => $progress,
|
||||
@@ -1490,11 +1646,10 @@ HCL;
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
$txn->end('success');
|
||||
|
||||
return $results;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$txn->end('failure');
|
||||
throw $e;
|
||||
@@ -1518,7 +1673,10 @@ HCL;
|
||||
foreach ($labels as $label) {
|
||||
if (!in_array($label, $existingNames, true)) {
|
||||
try {
|
||||
$this->adapter->createLabel($org, $repo, $label,
|
||||
$this->adapter->createLabel(
|
||||
$org,
|
||||
$repo,
|
||||
$label,
|
||||
match ($label) {
|
||||
'mokostandards' => 'B60205',
|
||||
'type: chore' => 'FEF2C0',
|
||||
@@ -1532,7 +1690,9 @@ HCL;
|
||||
default => '',
|
||||
}
|
||||
);
|
||||
} catch (\Exception $createEx) { /* already exists race — ignore */ }
|
||||
} catch (\Exception $createEx) {
|
||||
/* already exists race — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,11 +93,11 @@ class RetryHelper
|
||||
for ($attempt = 0; $attempt < $this->maxRetries; $attempt++) {
|
||||
try {
|
||||
$result = $callable();
|
||||
|
||||
|
||||
if ($attempt > 0) {
|
||||
error_log("Function succeeded on attempt " . ($attempt + 1));
|
||||
}
|
||||
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
// Check if this exception is retryable
|
||||
|
||||
@@ -31,12 +31,12 @@ declare(strict_types=1);
|
||||
* ```php
|
||||
* $validator = new SecurityValidator();
|
||||
* $findings = $validator->scanFile('config.php');
|
||||
*
|
||||
*
|
||||
* if ($validator->hasCriticalFindings()) {
|
||||
* $validator->printReport();
|
||||
* exit(1);
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // Scan entire directory
|
||||
* $validator->scanDirectory('src/', ['.php', '.js']);
|
||||
* ```
|
||||
@@ -59,6 +59,8 @@ use RecursiveIteratorIterator;
|
||||
|
||||
/**
|
||||
* Exception raised when security violations are detected
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class SecurityViolation extends Exception
|
||||
{
|
||||
@@ -169,7 +171,7 @@ class SecurityValidator
|
||||
if (preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$matchedValue = isset($matches[1]) && !empty($matches[1]) ? $matches[1][0][0] : $match[0];
|
||||
|
||||
|
||||
if ($this->isPlaceholder($matchedValue)) {
|
||||
continue;
|
||||
}
|
||||
@@ -236,14 +238,14 @@ class SecurityValidator
|
||||
'your_', 'example', 'placeholder', 'xxx', 'test',
|
||||
'dummy', 'sample', 'replace', 'changeme', 'todo'
|
||||
];
|
||||
|
||||
|
||||
$valueLower = strtolower($value);
|
||||
foreach ($placeholders as $placeholder) {
|
||||
if (strpos($valueLower, $placeholder) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -27,7 +28,7 @@ class SynchronizationNotImplementedException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* Create exception for unimplemented synchronization logic
|
||||
*
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function create(): self
|
||||
|
||||
@@ -36,11 +36,11 @@ declare(strict_types=1);
|
||||
* }, function() {
|
||||
* // Rollback: delete user
|
||||
* });
|
||||
*
|
||||
*
|
||||
* $txn->execute('send_email', function() {
|
||||
* // Send welcome email
|
||||
* });
|
||||
*
|
||||
*
|
||||
* $txn->commit();
|
||||
* } catch (TransactionError $e) {
|
||||
* // Automatic rollback on failure
|
||||
@@ -96,8 +96,6 @@ class TransactionStep
|
||||
*/
|
||||
class Transaction
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
|
||||
private string $name;
|
||||
/** @var array<int, TransactionStep> */
|
||||
private array $steps = [];
|
||||
@@ -160,7 +158,7 @@ class Transaction
|
||||
|
||||
$this->committed = true;
|
||||
$this->endTime = new DateTime('now', new DateTimeZone('UTC'));
|
||||
|
||||
|
||||
$duration = $this->endTime->getTimestamp() - $this->startTime->getTimestamp();
|
||||
error_log("Transaction committed: {$this->name} (" . count($this->steps) . " steps, {$duration}s)");
|
||||
}
|
||||
@@ -312,7 +310,7 @@ class TransactionManager
|
||||
{
|
||||
$committed = 0;
|
||||
$rolledBack = 0;
|
||||
|
||||
|
||||
foreach ($this->transactions as $txn) {
|
||||
if ($txn->isCommitted()) {
|
||||
$committed++;
|
||||
|
||||
@@ -37,12 +37,12 @@ declare(strict_types=1);
|
||||
* $validator = new UnifiedValidator();
|
||||
* $validator->addPlugin(new PathValidatorPlugin());
|
||||
* $validator->addPlugin(new MarkdownValidatorPlugin());
|
||||
*
|
||||
*
|
||||
* $context = [
|
||||
* 'paths' => ['/tmp', '/usr'],
|
||||
* 'markdown_files' => ['README.md']
|
||||
* ];
|
||||
*
|
||||
*
|
||||
* $results = $validator->validateAll($context);
|
||||
* $validator->printSummary();
|
||||
* ```
|
||||
@@ -143,7 +143,7 @@ class PathValidatorPlugin extends ValidationPlugin
|
||||
public function validate(array $context): ValidationResult
|
||||
{
|
||||
$paths = $context['paths'] ?? [];
|
||||
|
||||
|
||||
if (empty($paths)) {
|
||||
return new ValidationResult($this->name, true, 'No paths to validate');
|
||||
}
|
||||
@@ -181,7 +181,7 @@ class MarkdownValidatorPlugin extends ValidationPlugin
|
||||
public function validate(array $context): ValidationResult
|
||||
{
|
||||
$files = $context['markdown_files'] ?? [];
|
||||
|
||||
|
||||
if (empty($files)) {
|
||||
return new ValidationResult($this->name, true, 'No Markdown files to validate');
|
||||
}
|
||||
@@ -193,7 +193,7 @@ class MarkdownValidatorPlugin extends ValidationPlugin
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
|
||||
|
||||
// Check for broken links
|
||||
if (strpos($content, '](404') !== false || strpos($content, '](broken') !== false) {
|
||||
$issues[] = "{$filePath}: Potential broken links";
|
||||
@@ -226,7 +226,7 @@ class LicenseValidatorPlugin extends ValidationPlugin
|
||||
public function validate(array $context): ValidationResult
|
||||
{
|
||||
$files = $context['source_files'] ?? [];
|
||||
|
||||
|
||||
if (empty($files)) {
|
||||
return new ValidationResult($this->name, true, 'No source files to validate');
|
||||
}
|
||||
@@ -288,7 +288,8 @@ class WorkflowValidatorPlugin extends ValidationPlugin
|
||||
);
|
||||
$altDir = ($workflowDir === '.mokogitea/workflows') ? '.github/workflows' : '.mokogitea/workflows';
|
||||
if (is_dir($altDir)) {
|
||||
$workflows = array_merge($workflows,
|
||||
$workflows = array_merge(
|
||||
$workflows,
|
||||
glob($altDir . '/*.yml') ?: [],
|
||||
glob($altDir . '/*.yaml') ?: []
|
||||
);
|
||||
@@ -301,7 +302,7 @@ class WorkflowValidatorPlugin extends ValidationPlugin
|
||||
$issues = [];
|
||||
foreach ($workflows as $workflow) {
|
||||
$content = file_get_contents($workflow);
|
||||
|
||||
|
||||
// Basic checks
|
||||
if (strpos($content, 'on:') === false && strpos($content, 'on :') === false) {
|
||||
$issues[] = basename($workflow) . ": Missing 'on:' trigger";
|
||||
@@ -386,7 +387,7 @@ class UnifiedValidator
|
||||
|
||||
/** @var array<string, ValidationPlugin> */
|
||||
private array $plugins = [];
|
||||
|
||||
|
||||
/** @var array<int, ValidationResult> */
|
||||
private array $results = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -24,7 +25,7 @@ use Exception;
|
||||
|
||||
/**
|
||||
* Joomla Update XML Generator
|
||||
*
|
||||
*
|
||||
* Generates and updates updates.xml files for Joomla extensions
|
||||
* following the Joomla update server specification
|
||||
*/
|
||||
@@ -34,10 +35,10 @@ class UpdateXmlGenerator
|
||||
private string $extensionType;
|
||||
private string $element;
|
||||
private string $clientId;
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
*
|
||||
* @param string $extensionName Human-readable extension name
|
||||
* @param string $extensionType Extension type (component, module, plugin, etc.)
|
||||
* @param string $element Extension element (e.g., com_example, mod_custom)
|
||||
@@ -54,10 +55,10 @@ class UpdateXmlGenerator
|
||||
$this->element = $element ?: $this->deriveElement($extensionName, $extensionType);
|
||||
$this->clientId = $clientId;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate updates.xml from release information
|
||||
*
|
||||
*
|
||||
* @param array $release Release information
|
||||
* @return string XML content
|
||||
*/
|
||||
@@ -66,20 +67,20 @@ class UpdateXmlGenerator
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
$dom->formatOutput = true;
|
||||
$dom->preserveWhiteSpace = false;
|
||||
|
||||
|
||||
// Create root element
|
||||
$updates = $dom->createElement('updates');
|
||||
$dom->appendChild($updates);
|
||||
|
||||
|
||||
// Add update entry
|
||||
$this->addUpdateEntry($dom, $updates, $release);
|
||||
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update existing updates.xml file with new release
|
||||
*
|
||||
*
|
||||
* @param string $xmlPath Path to existing updates.xml
|
||||
* @param array $release New release information
|
||||
* @return string Updated XML content
|
||||
@@ -90,24 +91,24 @@ class UpdateXmlGenerator
|
||||
if (!file_exists($xmlPath)) {
|
||||
return $this->generate($release);
|
||||
}
|
||||
|
||||
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
$dom->formatOutput = true;
|
||||
$dom->preserveWhiteSpace = false;
|
||||
|
||||
|
||||
if (!@$dom->load($xmlPath)) {
|
||||
throw new Exception("Failed to parse existing updates.xml at {$xmlPath}");
|
||||
}
|
||||
|
||||
|
||||
$updates = $dom->getElementsByTagName('updates')->item(0);
|
||||
if (!$updates) {
|
||||
throw new Exception("Invalid updates.xml: missing <updates> root element");
|
||||
}
|
||||
|
||||
|
||||
// Check if this version already exists
|
||||
$version = $release['version'];
|
||||
$existingUpdates = $updates->getElementsByTagName('update');
|
||||
|
||||
|
||||
foreach ($existingUpdates as $existingUpdate) {
|
||||
$versionNode = $existingUpdate->getElementsByTagName('version')->item(0);
|
||||
if ($versionNode && $versionNode->textContent === $version) {
|
||||
@@ -116,13 +117,13 @@ class UpdateXmlGenerator
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add new update entry at the beginning
|
||||
$this->addUpdateEntry($dom, $updates, $release, true);
|
||||
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Map numeric client ID to Joomla client name
|
||||
*
|
||||
@@ -136,10 +137,10 @@ class UpdateXmlGenerator
|
||||
default => 'site',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add an update entry to the XML document
|
||||
*
|
||||
*
|
||||
* @param DOMDocument $dom DOM document
|
||||
* @param DOMElement $updates Updates element
|
||||
* @param array $release Release information
|
||||
@@ -152,55 +153,55 @@ class UpdateXmlGenerator
|
||||
bool $prepend = false
|
||||
): void {
|
||||
$update = $dom->createElement('update');
|
||||
|
||||
|
||||
// Required fields
|
||||
$this->addElement($dom, $update, 'name', $this->extensionName);
|
||||
$this->addElement($dom, $update, 'description', $release['description'] ?? '');
|
||||
$this->addElement($dom, $update, 'element', $this->element);
|
||||
$this->addElement($dom, $update, 'type', $this->extensionType);
|
||||
|
||||
|
||||
// Folder (for plugins)
|
||||
if (!empty($release['folder'])) {
|
||||
$this->addElement($dom, $update, 'folder', $release['folder']);
|
||||
}
|
||||
|
||||
|
||||
// Client — always emit for correct extension matching
|
||||
$this->addElement($dom, $update, 'client', $this->resolveClientName($this->clientId));
|
||||
|
||||
|
||||
$this->addElement($dom, $update, 'version', $release['version']);
|
||||
|
||||
|
||||
// Creation date
|
||||
if (!empty($release['creation_date'])) {
|
||||
$this->addElement($dom, $update, 'creationDate', $release['creation_date']);
|
||||
}
|
||||
|
||||
|
||||
// Joomla target platform
|
||||
$infourl = $this->addElement($dom, $update, 'infourl', $release['infourl'] ?? '');
|
||||
if (!empty($release['infourl'])) {
|
||||
$infourl->setAttribute('title', 'Release Information');
|
||||
}
|
||||
|
||||
|
||||
// Downloads section
|
||||
$downloads = $dom->createElement('downloads');
|
||||
$update->appendChild($downloads);
|
||||
|
||||
|
||||
$downloadUrl = $this->addElement($dom, $downloads, 'downloadurl', $release['download_url']);
|
||||
$downloadUrl->setAttribute('type', 'full');
|
||||
$downloadUrl->setAttribute('format', 'zip');
|
||||
|
||||
|
||||
// Checksums
|
||||
if (!empty($release['sha256'])) {
|
||||
$this->addElement($dom, $update, 'sha256', $release['sha256']);
|
||||
}
|
||||
|
||||
|
||||
if (!empty($release['sha384'])) {
|
||||
$this->addElement($dom, $update, 'sha384', $release['sha384']);
|
||||
}
|
||||
|
||||
|
||||
if (!empty($release['sha512'])) {
|
||||
$this->addElement($dom, $update, 'sha512', $release['sha512']);
|
||||
}
|
||||
|
||||
|
||||
// Tags
|
||||
if (!empty($release['tags'])) {
|
||||
$tags = $dom->createElement('tags');
|
||||
@@ -209,16 +210,16 @@ class UpdateXmlGenerator
|
||||
$this->addElement($dom, $tags, 'tag', $tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Maintainer information
|
||||
if (!empty($release['maintainer'])) {
|
||||
$this->addElement($dom, $update, 'maintainer', $release['maintainer']);
|
||||
}
|
||||
|
||||
|
||||
if (!empty($release['maintainer_url'])) {
|
||||
$this->addElement($dom, $update, 'maintainerurl', $release['maintainer_url']);
|
||||
}
|
||||
|
||||
|
||||
// Target platform
|
||||
if (!empty($release['target_platform'])) {
|
||||
$targetPlatform = $dom->createElement('targetplatform');
|
||||
@@ -226,12 +227,12 @@ class UpdateXmlGenerator
|
||||
$targetPlatform->setAttribute('version', $release['target_platform']);
|
||||
$update->appendChild($targetPlatform);
|
||||
}
|
||||
|
||||
|
||||
// Optional: PHP minimum version
|
||||
if (!empty($release['php_minimum'])) {
|
||||
$this->addElement($dom, $update, 'php_minimum', $release['php_minimum']);
|
||||
}
|
||||
|
||||
|
||||
// Add to updates element
|
||||
if ($prepend && $updates->firstChild) {
|
||||
$updates->insertBefore($update, $updates->firstChild);
|
||||
@@ -239,10 +240,10 @@ class UpdateXmlGenerator
|
||||
$updates->appendChild($update);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a text element to parent
|
||||
*
|
||||
*
|
||||
* @param DOMDocument $dom DOM document
|
||||
* @param DOMElement $parent Parent element
|
||||
* @param string $name Element name
|
||||
@@ -260,17 +261,17 @@ class UpdateXmlGenerator
|
||||
$parent->appendChild($element);
|
||||
return $element;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Derive element name from extension name and type
|
||||
*
|
||||
*
|
||||
* @param string $name Extension name
|
||||
* @param string $type Extension type
|
||||
* @return string Element name
|
||||
*/
|
||||
private function deriveElement(string $name, string $type): string
|
||||
{
|
||||
$prefix = match($type) {
|
||||
$prefix = match ($type) {
|
||||
'component' => 'com_',
|
||||
'module' => 'mod_',
|
||||
'plugin' => 'plg_',
|
||||
@@ -279,31 +280,31 @@ class UpdateXmlGenerator
|
||||
'package' => 'pkg_',
|
||||
default => '',
|
||||
};
|
||||
|
||||
|
||||
// Convert name to lowercase and replace spaces with underscores
|
||||
$element = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $name));
|
||||
|
||||
|
||||
// Add prefix if not already present
|
||||
if (!str_starts_with($element, $prefix)) {
|
||||
$element = $prefix . $element;
|
||||
}
|
||||
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate updates.xml structure
|
||||
*
|
||||
*
|
||||
* @param string $xmlContent XML content to validate
|
||||
* @return array Validation result ['valid' => bool, 'errors' => array]
|
||||
*/
|
||||
public static function validate(string $xmlContent): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
|
||||
$dom = new DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
|
||||
if (!$dom->loadXML($xmlContent)) {
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
$errors[] = "XML Error: {$error->message}";
|
||||
@@ -311,20 +312,20 @@ class UpdateXmlGenerator
|
||||
libxml_clear_errors();
|
||||
return ['valid' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
|
||||
// Validate structure
|
||||
$updates = $dom->getElementsByTagName('updates')->item(0);
|
||||
if (!$updates) {
|
||||
$errors[] = "Missing <updates> root element";
|
||||
return ['valid' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
|
||||
$updateElements = $updates->getElementsByTagName('update');
|
||||
if ($updateElements->length === 0) {
|
||||
$errors[] = "No <update> elements found";
|
||||
return ['valid' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
|
||||
// Validate each update entry
|
||||
foreach ($updateElements as $update) {
|
||||
$required = ['name', 'element', 'type', 'version'];
|
||||
@@ -333,12 +334,12 @@ class UpdateXmlGenerator
|
||||
$errors[] = "Missing required field: <{$field}>";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Warn if <client> is missing
|
||||
if ($update->getElementsByTagName('client')->length === 0) {
|
||||
$errors[] = "Missing <client> tag — Joomla may not match this update to the installed extension";
|
||||
}
|
||||
|
||||
|
||||
// Check for download URL
|
||||
$downloads = $update->getElementsByTagName('downloads');
|
||||
if ($downloads->length > 0) {
|
||||
@@ -348,16 +349,16 @@ class UpdateXmlGenerator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract release information from manifest XML
|
||||
*
|
||||
*
|
||||
* @param string $manifestPath Path to extension manifest XML
|
||||
* @return array Release information
|
||||
* @throws Exception If manifest cannot be parsed
|
||||
@@ -367,14 +368,14 @@ class UpdateXmlGenerator
|
||||
if (!file_exists($manifestPath)) {
|
||||
throw new Exception("Manifest file not found: {$manifestPath}");
|
||||
}
|
||||
|
||||
|
||||
$dom = new DOMDocument();
|
||||
if (!@$dom->load($manifestPath)) {
|
||||
throw new Exception("Failed to parse manifest XML: {$manifestPath}");
|
||||
}
|
||||
|
||||
|
||||
$root = $dom->documentElement;
|
||||
|
||||
|
||||
return [
|
||||
'name' => self::getElementText($dom, 'name') ?: 'Unknown Extension',
|
||||
'version' => self::getElementText($dom, 'version') ?: '1.0.0',
|
||||
@@ -385,10 +386,10 @@ class UpdateXmlGenerator
|
||||
'target_platform' => self::getElementText($dom, 'version', 'targetplatform') ?: '4.0',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get text content of an element
|
||||
*
|
||||
*
|
||||
* @param DOMDocument $dom DOM document
|
||||
* @param string $tagName Tag name
|
||||
* @param string $parentTag Optional parent tag name
|
||||
@@ -413,7 +414,7 @@ class UpdateXmlGenerator
|
||||
return trim($elements->item(0)->textContent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-->
|
||||
<ruleset name="MokoStandards PHP Coding Standards">
|
||||
<description>PHP_CodeSniffer configuration for MokoStandards projects</description>
|
||||
<ruleset name="moko-platform PHP Coding Standards">
|
||||
<description>PHP_CodeSniffer configuration for moko-platform projects</description>
|
||||
|
||||
<!-- Files to check -->
|
||||
<file>api/src</file>
|
||||
<file>api/tests</file>
|
||||
<file>lib</file>
|
||||
<file>validate</file>
|
||||
<file>automation</file>
|
||||
<file>cli</file>
|
||||
|
||||
<!-- Exclude vendor and other dependencies -->
|
||||
<exclude-pattern>*/vendor/*</exclude-pattern>
|
||||
@@ -19,11 +21,25 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<exclude-pattern>*/.git/*</exclude-pattern>
|
||||
|
||||
<!-- Use PSR-12 as base standard -->
|
||||
<rule ref="PSR12"/>
|
||||
<rule ref="PSR12">
|
||||
<!-- CLI scripts mix declarations and side effects by design -->
|
||||
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||
<!-- CLI scripts and utility files often lack namespaces -->
|
||||
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
|
||||
<!-- Multiple helper classes per file in lib/ is intentional -->
|
||||
<exclude name="PSR1.Classes.ClassDeclaration.MultipleClasses"/>
|
||||
<!-- File header ordering is advisory -->
|
||||
<exclude name="PSR12.Files.FileHeader.IncorrectOrder"/>
|
||||
<!-- Heredoc closers must match body indentation (tabs) per PHP 7.3+ -->
|
||||
<exclude name="Generic.WhiteSpace.DisallowTabIndent.TabsUsedHeredocCloser"/>
|
||||
</rule>
|
||||
|
||||
<!-- Additional rules -->
|
||||
<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
|
||||
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
|
||||
<rule ref="Generic.CodeAnalysis.EmptyStatement">
|
||||
<!-- Allow empty catch blocks (used for intentional suppression) -->
|
||||
<exclude name="Generic.CodeAnalysis.EmptyStatement.DetectedCatch"/>
|
||||
</rule>
|
||||
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
|
||||
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
|
||||
<rule ref="Generic.Files.LineLength">
|
||||
@@ -48,7 +64,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<!-- Show progress and use colors -->
|
||||
<arg value="p"/>
|
||||
<arg name="colors"/>
|
||||
|
||||
|
||||
<!-- Show sniff codes in all reports -->
|
||||
<arg value="s"/>
|
||||
</ruleset>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+14
-23
@@ -4,32 +4,23 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# PHPStan configuration for MokoStandards projects
|
||||
# PHPStan configuration for moko-platform projects
|
||||
parameters:
|
||||
level: 5
|
||||
level: 6
|
||||
paths:
|
||||
- api/src
|
||||
- api/tests
|
||||
- lib
|
||||
- validate
|
||||
- automation
|
||||
- cli
|
||||
excludePaths:
|
||||
- vendor
|
||||
- node_modules
|
||||
|
||||
# Report unknown classes and functions
|
||||
analyseAndScan:
|
||||
- vendor
|
||||
- node_modules (?)
|
||||
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
||||
# Check for dead code
|
||||
checkMissingIterableValueType: false
|
||||
checkGenericClassInNonGenericObjectType: false
|
||||
|
||||
# Additional checks
|
||||
checkAlwaysTrueCheckTypeFunctionCall: true
|
||||
checkAlwaysTrueInstanceof: true
|
||||
checkAlwaysTrueStrictComparison: true
|
||||
checkExplicitMixedMissingReturn: true
|
||||
|
||||
checkFunctionNameCase: true
|
||||
checkInternalClassCaseSensitivity: true
|
||||
|
||||
# Ignore common patterns
|
||||
ignoreErrors:
|
||||
# Add project-specific ignores here
|
||||
# - '#Call to an undefined method#'
|
||||
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user