Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d3524615 | |||
| 8058baef95 | |||
| df2efa4838 | |||
| 76bc91a383 | |||
| b53846f6f4 |
@@ -412,6 +412,12 @@ jobs:
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check gate results
|
||||
run: |
|
||||
{
|
||||
@@ -437,3 +443,46 @@ jobs:
|
||||
echo "::error::One or more CI gates failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.code-quality.result == 'failure' ||
|
||||
needs.tests.result == 'failure' ||
|
||||
needs.self-health.result == 'failure' ||
|
||||
needs.governance.result == 'failure' ||
|
||||
needs.templates.result == 'failure')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Platform CI"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Code Quality" \
|
||||
"${{ needs.code-quality.result }}" \
|
||||
"PHPCS (PSR-12), PHPStan, or PHP syntax checks failed. Run \`composer check\` locally to reproduce."
|
||||
|
||||
report_gate "Unit Tests" \
|
||||
"${{ needs.tests.result }}" \
|
||||
"PHPUnit tests failed on one or more PHP versions (8.1, 8.2, 8.3). Run \`vendor/bin/phpunit --testdox\` locally."
|
||||
|
||||
report_gate "Self-Health" \
|
||||
"${{ needs.self-health.result }}" \
|
||||
"Self-health score fell below the 80% threshold. Run \`php bin/moko health -- --path .\` locally."
|
||||
|
||||
report_gate "Governance" \
|
||||
"${{ needs.governance.result }}" \
|
||||
"Governance checks failed (license headers, secrets, or version consistency). Check the CI run summary for specifics."
|
||||
|
||||
report_gate "Template Integrity" \
|
||||
"${{ needs.templates.result }}" \
|
||||
"Workflow or gitignore templates failed YAML validation or are missing required entries."
|
||||
|
||||
@@ -234,3 +234,31 @@ jobs:
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_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
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -767,3 +767,51 @@ jobs:
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, release_config, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.release_config.result == 'failure' ||
|
||||
needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Release Configuration" \
|
||||
"${{ needs.release_config.result }}" \
|
||||
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -230,7 +230,8 @@ class PushFiles extends CliFramework
|
||||
{
|
||||
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||
try {
|
||||
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
|
||||
if (!empty($manifestData)) {
|
||||
$xml = @simplexml_load_string($manifestData);
|
||||
if ($xml !== false) {
|
||||
|
||||
+20
-1
@@ -230,12 +230,31 @@ class ReleasePackageCli extends CliFramework
|
||||
$subName = basename($pkgDir);
|
||||
$subZipPath = "{$outputDir}/{$subName}.zip";
|
||||
|
||||
// If sub-package is a full repo checkout (e.g. git submodule),
|
||||
// look for a src/ subdirectory containing a Joomla manifest XML
|
||||
// and zip that instead of the repo root.
|
||||
$subSourceDir = $pkgDir;
|
||||
$srcCandidate = "{$pkgDir}/src";
|
||||
if (is_dir($srcCandidate)) {
|
||||
$srcManifests = array_merge(
|
||||
glob("{$srcCandidate}/*.xml") ?: [],
|
||||
glob("{$srcCandidate}/pkg_*.xml") ?: []
|
||||
);
|
||||
foreach ($srcManifests as $mf) {
|
||||
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
|
||||
$subSourceDir = $srcCandidate;
|
||||
echo " Sub-package {$subName}: using src/ entry-point\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$subZip = new \ZipArchive();
|
||||
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
|
||||
continue;
|
||||
}
|
||||
$this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns);
|
||||
$this->addDirToZip($subZip, $subSourceDir, '', $this->excludePatterns);
|
||||
$subZip->close();
|
||||
|
||||
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
||||
|
||||
@@ -109,10 +109,18 @@ class VersionAutoBumpCli extends CliFramework
|
||||
echo "{$line}\n";
|
||||
}
|
||||
|
||||
// Step 2: Read version
|
||||
// Step 2: Read version (--quiet suppresses banner so only the version is output)
|
||||
$versionOutput = [];
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " 2>&1", $versionOutput, $versionRc);
|
||||
$version = trim($versionOutput[0] ?? '');
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " --quiet 2>&1", $versionOutput, $versionRc);
|
||||
// Take the last non-empty line — the version is always the final output
|
||||
$version = '';
|
||||
foreach (array_reverse($versionOutput) as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}/', $line)) {
|
||||
$version = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($version)) {
|
||||
echo "No version found — skipping\n";
|
||||
|
||||
@@ -53,6 +53,12 @@ class VersionSetPlatformCli extends CliFramework
|
||||
// Strip any existing suffix(es) before applying the correct one
|
||||
$version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||
|
||||
// Validate version format — must be XX.YY.ZZ to prevent XML corruption
|
||||
if (!preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $version)) {
|
||||
$this->log('ERROR', "Invalid version format: '{$version}' — expected XX.YY.ZZ");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append stability suffix for non-stable releases
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
|
||||
@@ -141,6 +141,26 @@ abstract class CliFramework
|
||||
/** @var float Script start time for elapsed-time reporting. */
|
||||
private float $startTime;
|
||||
|
||||
// =========================================================================
|
||||
// Display output — all decorative output goes to stderr
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Write decorative/diagnostic output to stderr.
|
||||
*
|
||||
* All non-data output (banners, progress bars, section headers, status
|
||||
* lines, log messages) MUST use this method so that stdout is reserved
|
||||
* for machine-readable data. This ensures that shell captures like
|
||||
* VERSION=$(php version_read.php --path .)
|
||||
* only receive the actual data, not decorative text.
|
||||
*
|
||||
* @since 04.00.16
|
||||
*/
|
||||
protected function display(string $text): void
|
||||
{
|
||||
fwrite(STDERR, $text);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Constructor
|
||||
// =========================================================================
|
||||
@@ -326,14 +346,14 @@ abstract class CliFramework
|
||||
protected function printHelp(): void
|
||||
{
|
||||
$w = $this->termWidth();
|
||||
echo $this->c(self::C_BOLD . self::C_CYAN, $this->scriptName);
|
||||
$this->display($this->c(self::C_BOLD . self::C_CYAN, $this->scriptName));
|
||||
if ($this->description !== '') {
|
||||
echo ' — ' . $this->description;
|
||||
$this->display(' — ' . $this->description);
|
||||
}
|
||||
echo "\n";
|
||||
echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n";
|
||||
echo $this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n";
|
||||
echo $this->c(self::C_BOLD, 'Options:') . "\n";
|
||||
$this->display("\n");
|
||||
$this->display($this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n");
|
||||
$this->display($this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n");
|
||||
$this->display($this->c(self::C_BOLD, 'Options:') . "\n");
|
||||
|
||||
$builtIn = [
|
||||
'--help' => ['desc' => 'Show this help message', 'default' => null],
|
||||
@@ -348,16 +368,16 @@ abstract class CliFramework
|
||||
$hint = ($default !== null && $default !== false)
|
||||
? $this->c(self::C_DIM, " (default: {$default})")
|
||||
: '';
|
||||
printf(
|
||||
$this->display(sprintf(
|
||||
" %s%-22s%s%s%s\n",
|
||||
self::C_CYAN,
|
||||
$name,
|
||||
self::C_RESET,
|
||||
$def['desc'],
|
||||
$hint
|
||||
);
|
||||
));
|
||||
}
|
||||
echo "\n";
|
||||
$this->display("\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -378,23 +398,23 @@ abstract class CliFramework
|
||||
$titleLine = $this->padRight($titleStyled, $inner, strlen($titleRaw));
|
||||
$descLine = ($desc !== '') ? $this->padRight(" {$desc}", $inner) : null;
|
||||
|
||||
echo "\n";
|
||||
echo $this->c(
|
||||
$this->display("\n");
|
||||
$this->display($this->c(
|
||||
self::C_CYAN,
|
||||
self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR
|
||||
) . "\n";
|
||||
echo $this->c(self::C_CYAN, self::BOX_V)
|
||||
) . "\n");
|
||||
$this->display($this->c(self::C_CYAN, self::BOX_V)
|
||||
. $this->c(self::C_BOLD, $titleLine)
|
||||
. $this->c(self::C_CYAN, self::BOX_V) . "\n";
|
||||
. $this->c(self::C_CYAN, self::BOX_V) . "\n");
|
||||
if ($descLine !== null) {
|
||||
echo $this->c(self::C_CYAN, self::BOX_V)
|
||||
$this->display($this->c(self::C_CYAN, self::BOX_V)
|
||||
. $this->c(self::C_DIM, $descLine)
|
||||
. $this->c(self::C_CYAN, self::BOX_V) . "\n";
|
||||
. $this->c(self::C_CYAN, self::BOX_V) . "\n");
|
||||
}
|
||||
echo $this->c(
|
||||
$this->display($this->c(
|
||||
self::C_CYAN,
|
||||
self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR
|
||||
) . "\n\n";
|
||||
) . "\n\n");
|
||||
}
|
||||
|
||||
/** Print the dry-run notice box. */
|
||||
@@ -403,18 +423,18 @@ abstract class CliFramework
|
||||
$w = min($this->termWidth(), 70);
|
||||
$msg = ' ' . self::ICON_DRY . ' DRY-RUN MODE — no changes will be written ';
|
||||
$row = $this->padRight($msg, $w - 2);
|
||||
echo $this->c(
|
||||
$this->display($this->c(
|
||||
self::C_YELLOW . self::C_BOLD,
|
||||
self::BOX_TL . str_repeat(self::BOX_H, $w - 2) . self::BOX_TR
|
||||
) . "\n";
|
||||
echo $this->c(
|
||||
) . "\n");
|
||||
$this->display($this->c(
|
||||
self::C_YELLOW . self::C_BOLD,
|
||||
self::BOX_V . $row . self::BOX_V
|
||||
) . "\n";
|
||||
echo $this->c(
|
||||
) . "\n");
|
||||
$this->display($this->c(
|
||||
self::C_YELLOW . self::C_BOLD,
|
||||
self::BOX_BL . str_repeat(self::BOX_H, $w - 2) . self::BOX_BR
|
||||
) . "\n\n";
|
||||
) . "\n\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -435,11 +455,11 @@ abstract class CliFramework
|
||||
$w = $this->termWidth();
|
||||
$text = " {$title} ";
|
||||
$fill = max(0, $w - strlen($text) - 4);
|
||||
echo "\n";
|
||||
echo $this->c(
|
||||
$this->display("\n");
|
||||
$this->display($this->c(
|
||||
self::C_CYAN,
|
||||
str_repeat(self::BOX_H, 2) . $text . str_repeat(self::BOX_H, $fill)
|
||||
) . "\n\n";
|
||||
) . "\n\n");
|
||||
}
|
||||
|
||||
/** Print a plain horizontal divider. */
|
||||
@@ -449,7 +469,7 @@ abstract class CliFramework
|
||||
return;
|
||||
}
|
||||
$this->clearProgress();
|
||||
echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $this->termWidth())) . "\n";
|
||||
$this->display($this->c(self::C_DIM, str_repeat(self::BOX_H, $this->termWidth())) . "\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -495,11 +515,7 @@ abstract class CliFramework
|
||||
|
||||
$line = "{$ts} {$icon} {$badge} {$text}\n";
|
||||
|
||||
if ($level === 'ERROR') {
|
||||
fwrite(STDERR, $line);
|
||||
} else {
|
||||
echo $line;
|
||||
}
|
||||
$this->display($line);
|
||||
}
|
||||
|
||||
/** Log a success message. */
|
||||
@@ -564,7 +580,7 @@ abstract class CliFramework
|
||||
? ' ' . $this->c(self::C_DIM, "— {$detail}")
|
||||
: '';
|
||||
|
||||
echo ' ' . $this->c($color . self::C_BOLD, $icon) . ' ' . $label . $suffix . "\n";
|
||||
$this->display(' ' . $this->c($color . self::C_BOLD, $icon) . ' ' . $label . $suffix . "\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -601,10 +617,10 @@ abstract class CliFramework
|
||||
$line = " [{$bar}] {$percent} {$counter}{$suffix}";
|
||||
|
||||
if ($newline) {
|
||||
echo "\r{$line}\n";
|
||||
$this->display("\r{$line}\n");
|
||||
$this->progressActive = false;
|
||||
} else {
|
||||
echo "\r{$line}";
|
||||
$this->display("\r{$line}");
|
||||
$this->progressActive = true;
|
||||
}
|
||||
}
|
||||
@@ -613,7 +629,7 @@ abstract class CliFramework
|
||||
protected function clearProgress(): void
|
||||
{
|
||||
if ($this->progressActive) {
|
||||
echo "\r" . str_repeat(' ', $this->termWidth()) . "\r";
|
||||
$this->display("\r" . str_repeat(' ', $this->termWidth()) . "\r");
|
||||
$this->progressActive = false;
|
||||
}
|
||||
}
|
||||
@@ -644,8 +660,8 @@ abstract class CliFramework
|
||||
$maxKey = max(array_map('strlen', array_keys($rows)));
|
||||
$inner = $maxKey + 20;
|
||||
|
||||
echo "\n";
|
||||
echo $this->c($color, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n";
|
||||
$this->display("\n");
|
||||
$this->display($this->c($color, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n");
|
||||
|
||||
foreach ($rows as $label => $value) {
|
||||
$valStr = (string) $value;
|
||||
@@ -653,10 +669,10 @@ abstract class CliFramework
|
||||
$padding = $inner - strlen($label) - $valVis - 4;
|
||||
$row = ' ' . $this->c(self::C_BOLD, $label)
|
||||
. str_repeat(' ', max(1, $padding)) . $valStr . ' ';
|
||||
echo $this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n";
|
||||
$this->display($this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n");
|
||||
}
|
||||
|
||||
echo $this->c($color, self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n";
|
||||
$this->display($this->c($color, self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -702,7 +718,7 @@ abstract class CliFramework
|
||||
$this->clearProgress();
|
||||
$badge = $this->c(self::C_BOLD . self::C_MAGENTA, "Step {$current}/{$total}");
|
||||
$arrow = $this->c(self::C_DIM, self::ICON_INFO);
|
||||
echo "\n{$badge} {$arrow} {$title}\n";
|
||||
$this->display("\n{$badge} {$arrow} {$title}\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -964,13 +980,13 @@ abstract class CliFramework
|
||||
}
|
||||
|
||||
// Header.
|
||||
echo $sep . "\n";
|
||||
$this->display($sep . "\n");
|
||||
$headerLine = '|';
|
||||
foreach ($headers as $i => $h) {
|
||||
$headerLine .= ' ' . $this->c(self::C_BOLD, str_pad($h, $widths[$i])) . ' |';
|
||||
}
|
||||
echo $headerLine . "\n";
|
||||
echo $sep . "\n";
|
||||
$this->display($headerLine . "\n");
|
||||
$this->display($sep . "\n");
|
||||
|
||||
// Rows.
|
||||
foreach ($rows as $row) {
|
||||
@@ -978,9 +994,9 @@ abstract class CliFramework
|
||||
foreach ($row as $i => $cell) {
|
||||
$line .= ' ' . str_pad((string) $cell, $widths[$i]) . ' |';
|
||||
}
|
||||
echo $line . "\n";
|
||||
$this->display($line . "\n");
|
||||
}
|
||||
echo $sep . "\n";
|
||||
$this->display($sep . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user