Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41e48945fd | |||
| 7d28fe522d | |||
| 8780ec7c5f | |||
| 9106cfe254 | |||
| f7d70ae95a | |||
| 5c43cf1f02 | |||
| c8c74c7afe | |||
| 7b68963b67 | |||
| 22c2a99f8b | |||
| c974970118 | |||
| c9a8deee0e | |||
| ea05851d0a | |||
| 1178975be3 | |||
| b283fad8bd | |||
| a792772397 | |||
| 4d1be56bad | |||
| 2dc745c5fa | |||
| 9dda78da7c | |||
| 6ceef765eb | |||
| 23d3528676 | |||
| 249b639c70 | |||
| 5c9db551dc | |||
| 408f2329b3 | |||
| 827025bd17 | |||
| 98da1644be | |||
| db596575a0 | |||
| 3c56dc8814 | |||
| dce712fabd | |||
| 78b0ce9650 | |||
| 500a5be6d7 | |||
| 95a747b1d5 | |||
| bb7e99ad40 | |||
| 6c6b7c888e | |||
| 2a1692d599 | |||
| 6984ac108f | |||
| 3fdbe94830 | |||
| e937dd8d8b | |||
| e7b70f54ed | |||
| b161561571 | |||
| b981cf72e3 | |||
| 9964c7e16c | |||
| ff27e77c37 | |||
| 04ce7dc896 | |||
| f87f904a21 | |||
| fc72d8e90a | |||
| 71d52e432e | |||
| 172303b61f | |||
| bfb4b53da3 | |||
| 9149fa100c | |||
| 6a2c80a8f3 | |||
| 28ee70a946 | |||
| 6d194f9bdf | |||
| 403db405cb | |||
| 39e4eb6ec8 | |||
| 79cc30e9a8 | |||
| 78ad2c999b | |||
| e3949077b0 | |||
| e469b4a857 | |||
| acae63f727 | |||
| e71ab8415f | |||
| 03ce66a4f4 | |||
| deafaeca65 | |||
| 5e74c22609 | |||
| 03f881c746 | |||
| 3a405033ae | |||
| 034795951f | |||
| 1d1b867df5 | |||
| 63b599f62c | |||
| 5bd449017c | |||
| fe3de3fbff | |||
| 3e909df6d4 | |||
| 30bb5e33e2 |
@@ -1,403 +1,467 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
# | Platform-specific: |
|
# | Platform-specific: |
|
||||||
# | joomla: XML manifest, type-prefixed packages |
|
# | joomla: XML manifest, type-prefixed packages |
|
||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, closed]
|
types: [opened, synchronize, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
paths-ignore:
|
||||||
inputs:
|
- '.mokogitea/workflows/**'
|
||||||
action:
|
- '*.md'
|
||||||
description: 'Action to perform'
|
- 'wiki/**'
|
||||||
required: false
|
- '.editorconfig'
|
||||||
type: choice
|
- '.gitignore'
|
||||||
default: release
|
- '.gitattributes'
|
||||||
options:
|
- '.gitmessage'
|
||||||
- release
|
- 'LICENSE'
|
||||||
- promote-rc
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
env:
|
action:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
description: 'Action to perform'
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
required: false
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
type: choice
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
default: release
|
||||||
|
options:
|
||||||
permissions:
|
- release
|
||||||
contents: write
|
- promote-rc
|
||||||
|
|
||||||
jobs:
|
env:
|
||||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
promote-rc:
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
name: Promote to RC
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
runs-on: release
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
if: >-
|
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
permissions:
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
contents: write
|
||||||
|
|
||||||
steps:
|
jobs:
|
||||||
- name: Checkout repository
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
promote-rc:
|
||||||
with:
|
name: Promote to RC
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
runs-on: release
|
||||||
fetch-depth: 1
|
if: >-
|
||||||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
- name: Setup mokocli tools
|
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||||
env:
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
steps:
|
||||||
run: |
|
- name: Checkout repository
|
||||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
echo Using pre-installed /opt/mokocli
|
with:
|
||||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
else
|
fetch-depth: 1
|
||||||
echo Falling back to fresh clone
|
|
||||||
if ! command -v composer > /dev/null 2>&1; then
|
- name: Setup mokocli tools
|
||||||
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
|
env:
|
||||||
fi
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
rm -rf /tmp/mokocli
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||||
cd /tmp/mokocli
|
echo Using pre-installed /opt/mokocli
|
||||||
composer install --no-dev --no-interaction --quiet
|
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
else
|
||||||
fi
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; then
|
||||||
- name: Rename branch to rc
|
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
|
||||||
run: |
|
fi
|
||||||
php ${MOKO_CLI}/branch_rename.php \
|
rm -rf /tmp/mokocli
|
||||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
cd /tmp/mokocli
|
||||||
--pr "${{ github.event.pull_request.number }}"
|
composer install --no-dev --no-interaction --quiet
|
||||||
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
- name: Checkout rc and configure git
|
fi
|
||||||
run: |
|
|
||||||
git fetch origin rc
|
- name: Rename branch to rc
|
||||||
git checkout rc
|
run: |
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
php ${MOKO_CLI}/branch_rename.php \
|
||||||
git config --local user.name "gitea-actions[bot]"
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
- name: Publish RC release
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
run: |
|
|
||||||
php ${MOKO_CLI}/release_publish.php \
|
- name: Checkout rc and configure git
|
||||||
--path . --stability rc --bump minor --branch rc \
|
run: |
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
git fetch origin rc
|
||||||
|
git checkout rc
|
||||||
- name: Update RC release notes from CHANGELOG.md
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
run: |
|
git config --local user.name "gitea-actions[bot]"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
|
||||||
|
- name: Publish RC release
|
||||||
# Extract [Unreleased] section from changelog
|
run: |
|
||||||
NOTES=""
|
php ${MOKO_CLI}/release_publish.php \
|
||||||
if [ -f "CHANGELOG.md" ]; then
|
--path . --stability rc --bump minor --branch rc \
|
||||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
fi
|
|
||||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
- name: Update RC release notes from CHANGELOG.md
|
||||||
|
run: |
|
||||||
# Find the RC release and update its body
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
"${API_BASE}/releases/tags/release-candidate" \
|
|
||||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
# Extract [Unreleased] section from changelog
|
||||||
|
NOTES=""
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
python3 -c "
|
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||||
import json, urllib.request
|
fi
|
||||||
body = open('/dev/stdin').read()
|
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||||
payload = json.dumps({'body': body}).encode()
|
|
||||||
req = urllib.request.Request(
|
# Find the RC release and update its body
|
||||||
'${API_BASE}/releases/${RELEASE_ID}',
|
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||||
data=payload, method='PATCH',
|
"${API_BASE}/releases/tags/release-candidate" \
|
||||||
headers={
|
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||||
'Authorization': 'token ${TOKEN}',
|
|
||||||
'Content-Type': 'application/json'
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
})
|
python3 -c "
|
||||||
urllib.request.urlopen(req)
|
import json, urllib.request
|
||||||
" <<< "$NOTES"
|
body = open('/dev/stdin').read()
|
||||||
echo "RC release notes updated from CHANGELOG.md"
|
payload = json.dumps({'body': body}).encode()
|
||||||
fi
|
req = urllib.request.Request(
|
||||||
|
'${API_BASE}/releases/${RELEASE_ID}',
|
||||||
- name: Summary
|
data=payload, method='PATCH',
|
||||||
if: always()
|
headers={
|
||||||
run: |
|
'Authorization': 'token ${TOKEN}',
|
||||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
'Content-Type': 'application/json'
|
||||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
})
|
||||||
|
urllib.request.urlopen(req)
|
||||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
" <<< "$NOTES"
|
||||||
release:
|
echo "RC release notes updated from CHANGELOG.md"
|
||||||
name: Build & Release Pipeline
|
fi
|
||||||
runs-on: release
|
|
||||||
if: >-
|
- name: Summary
|
||||||
github.event.pull_request.merged == true ||
|
if: always()
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
run: |
|
||||||
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
steps:
|
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||||
with:
|
release:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
name: Build & Release Pipeline
|
||||||
fetch-depth: 0
|
runs-on: release
|
||||||
|
if: >-
|
||||||
- name: Configure git for bot pushes
|
github.event.pull_request.merged == true ||
|
||||||
run: |
|
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
steps:
|
||||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- name: Check for merge conflict markers
|
with:
|
||||||
run: |
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
fetch-depth: 0
|
||||||
if [ -n "$CONFLICTS" ]; then
|
|
||||||
echo "::error::Merge conflict markers found — aborting release"
|
- name: Configure git for bot pushes
|
||||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
run: |
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
git config --local user.name "gitea-actions[bot]"
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
exit 1
|
|
||||||
fi
|
- name: Check for merge conflict markers
|
||||||
echo "No conflict markers found"
|
run: |
|
||||||
|
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||||
- name: Setup mokocli tools
|
if [ -n "$CONFLICTS" ]; then
|
||||||
env:
|
echo "::error::Merge conflict markers found — aborting release"
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
run: |
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
exit 1
|
||||||
echo Using pre-installed /opt/mokocli
|
fi
|
||||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
echo "No conflict markers found"
|
||||||
else
|
|
||||||
echo Falling back to fresh clone
|
- name: Setup mokocli tools
|
||||||
if ! command -v composer > /dev/null 2>&1; then
|
env:
|
||||||
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
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fi
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
rm -rf /tmp/mokocli
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||||
cd /tmp/mokocli
|
echo Using pre-installed /opt/mokocli
|
||||||
composer install --no-dev --no-interaction --quiet
|
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
else
|
||||||
fi
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; then
|
||||||
- name: "Determine version bump level"
|
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
|
||||||
id: bump
|
fi
|
||||||
run: |
|
rm -rf /tmp/mokocli
|
||||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||||
# Feature/dev branches: bump minor for the new stable release
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
cd /tmp/mokocli
|
||||||
case "$HEAD_REF" in
|
composer install --no-dev --no-interaction --quiet
|
||||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
*) BUMP="minor" ;;
|
fi
|
||||||
esac
|
|
||||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
- name: "Detect platform"
|
||||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
id: platform
|
||||||
|
run: |
|
||||||
- name: "Publish stable release"
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
run: |
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||||
BUMP_FLAG=""
|
|
||||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
- name: "Determine version bump level"
|
||||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
id: bump
|
||||||
fi
|
run: |
|
||||||
php ${MOKO_CLI}/release_publish.php \
|
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
# Feature/dev branches: bump minor for the new stable release
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||||
|
case "$HEAD_REF" in
|
||||||
- name: Update release notes and promote changelog
|
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||||
run: |
|
*) BUMP="minor" ;;
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
esac
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||||
# Get the stable release info (version and ID)
|
|
||||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
- name: "Publish stable release"
|
||||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
run: |
|
||||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
BUMP_FLAG=""
|
||||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||||
VERSION=$(python3 -c "
|
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||||
import json, sys, re
|
fi
|
||||||
r = json.load(sys.stdin)
|
php ${MOKO_CLI}/release_publish.php \
|
||||||
name = r.get('name', '')
|
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
print(m.group(1) if m else '')
|
|
||||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
- name: "Read published version"
|
||||||
|
id: version
|
||||||
# Extract [Unreleased] section from changelog
|
run: |
|
||||||
NOTES=""
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||||
if [ -f "CHANGELOG.md" ]; then
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]]; then
|
||||||
# Update release body via API
|
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
python3 -c "
|
else
|
||||||
import json, urllib.request
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
body = open('/dev/stdin').read()
|
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
payload = json.dumps({'body': body}).encode()
|
fi
|
||||||
req = urllib.request.Request(
|
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
'${API_BASE}/releases/${RELEASE_ID}',
|
echo "Published version: ${VERSION}"
|
||||||
data=payload, method='PATCH',
|
|
||||||
headers={
|
- name: "Create semver tag for non-Joomla repos"
|
||||||
'Authorization': 'token ${TOKEN}',
|
id: semver
|
||||||
'Content-Type': 'application/json'
|
if: |
|
||||||
})
|
steps.version.outputs.skip != 'true' &&
|
||||||
urllib.request.urlopen(req)
|
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||||
" <<< "$NOTES"
|
run: |
|
||||||
echo "Release notes updated from CHANGELOG.md"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
fi
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
SEMVER_TAG="v${VERSION}"
|
||||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
|
||||||
DATE=$(date +%Y-%m-%d)
|
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||||
python3 -c "
|
|
||||||
import sys
|
# Create the git tag via API
|
||||||
version, date = sys.argv[1], sys.argv[2]
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||||
content = open('CHANGELOG.md').read()
|
-X POST -H "Authorization: token ${TOKEN}" \
|
||||||
old = '## [Unreleased]'
|
-H "Content-Type: application/json" \
|
||||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
"${API_BASE}/tags" \
|
||||||
content = content.replace(old, new, 1)
|
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||||
open('CHANGELOG.md', 'w').write(content)
|
|
||||||
" "$VERSION" "$DATE"
|
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
git add CHANGELOG.md
|
echo "Created semver tag: ${SEMVER_TAG}"
|
||||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
elif [ "$HTTP_CODE" = "409" ]; then
|
||||||
git push origin main || true
|
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
else
|
||||||
fi
|
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
|
||||||
- name: "Step 9: Mirror release to GitHub"
|
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
- name: Update release notes and promote changelog
|
||||||
secrets.GH_MIRROR_TOKEN != ''
|
run: |
|
||||||
continue-on-error: true
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
run: |
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
# Get the stable release info (version and ID)
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||||
php ${MOKO_CLI}/release_mirror.php \
|
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
VERSION=$(python3 -c "
|
||||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
import json, sys, re
|
||||||
--branch main 2>&1 || true
|
r = json.load(sys.stdin)
|
||||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
name = r.get('name', '')
|
||||||
|
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
print(m.group(1) if m else '')
|
||||||
- name: "Step 10: Push main to GitHub mirror"
|
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
# Extract [Unreleased] section from changelog
|
||||||
secrets.GH_MIRROR_TOKEN != ''
|
NOTES=""
|
||||||
continue-on-error: true
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
run: |
|
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
fi
|
||||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
|
||||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
# Update release body via API
|
||||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
git fetch origin main --depth=1
|
python3 -c "
|
||||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
import json, urllib.request
|
||||||
&& echo "main branch pushed to GitHub mirror" \
|
body = open('/dev/stdin').read()
|
||||||
|| echo "WARNING: GitHub mirror push failed"
|
payload = json.dumps({'body': body}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
'${API_BASE}/releases/${RELEASE_ID}',
|
||||||
if: steps.version.outputs.skip != 'true'
|
data=payload, method='PATCH',
|
||||||
continue-on-error: true
|
headers={
|
||||||
run: |
|
'Authorization': 'token ${TOKEN}',
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
'Content-Type': 'application/json'
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
})
|
||||||
|
urllib.request.urlopen(req)
|
||||||
# Delete rc branch (ephemeral — created by promote-rc)
|
" <<< "$NOTES"
|
||||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
echo "Release notes updated from CHANGELOG.md"
|
||||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
fi
|
||||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
|
||||||
|
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||||
# Delete dev branch
|
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
DATE=$(date +%Y-%m-%d)
|
||||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
python3 -c "
|
||||||
|
import sys
|
||||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
version, date = sys.argv[1], sys.argv[2]
|
||||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
content = open('CHANGELOG.md').read()
|
||||||
-H "Content-Type: application/json" \
|
old = '## [Unreleased]'
|
||||||
"${API_BASE}/branches" \
|
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
content = content.replace(old, new, 1)
|
||||||
|
open('CHANGELOG.md', 'w').write(content)
|
||||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
" "$VERSION" "$DATE"
|
||||||
|
git add CHANGELOG.md
|
||||||
- name: "Step 12: Create version branch from main"
|
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||||
if: steps.version.outputs.skip != 'true'
|
git push origin main || true
|
||||||
continue-on-error: true
|
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||||
run: |
|
fi
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
- name: "Step 9: Mirror release to GitHub"
|
||||||
BRANCH_NAME="version/${VERSION}"
|
if: >-
|
||||||
MAIN_SHA=$(git rev-parse HEAD)
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
# Delete old version branch if it exists (same version re-release)
|
continue-on-error: true
|
||||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
# Create version/XX.YY.ZZ from main
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
--branch main 2>&1 || true
|
||||||
- name: "Post-release: Reset dev version"
|
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
continue-on-error: true
|
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||||
run: |
|
- name: "Step 10: Push main to GitHub mirror"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
if: >-
|
||||||
php ${MOKO_CLI}/version_reset_dev.php \
|
steps.version.outputs.skip != 'true' &&
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
--branch dev --path . 2>&1 || true
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
# -- Summary --------------------------------------------------------------
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
- name: Pipeline Summary
|
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||||
if: always()
|
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||||
run: |
|
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
git fetch origin main --depth=1
|
||||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
&& echo "main branch pushed to GitHub mirror" \
|
||||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
|| echo "WARNING: GitHub mirror push failed"
|
||||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
|
||||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||||
else
|
if: steps.version.outputs.skip != 'true'
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
continue-on-error: true
|
||||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
run: |
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
# Delete dev branch
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||||
|
|
||||||
|
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||||
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/branches" \
|
||||||
|
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||||
|
|
||||||
|
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: "Step 12: Create version branch from main"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
BRANCH_NAME="version/${VERSION}"
|
||||||
|
MAIN_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Delete old version branch if it exists (same version re-release)
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||||
|
|
||||||
|
# Create version/XX.YY.ZZ from main
|
||||||
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||||
|
|
||||||
|
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||||
|
- name: "Post-release: Reset dev version"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
|
--branch dev --path . 2>&1 || true
|
||||||
|
|
||||||
|
# -- Summary --------------------------------------------------------------
|
||||||
|
- name: Pipeline Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||||
|
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||||
|
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# BRIEF: Build and deploy MokoGitea to dev environment on push to dev branch.
|
||||||
|
# Production deploy (deploy-mokogitea.yml) only succeeds if dev is healthy.
|
||||||
|
|
||||||
|
name: Deploy MokoGitea (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-mokogitea-dev
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mokoconsulting.tech
|
||||||
|
IMAGE: mokoconsulting/mokogitea
|
||||||
|
DEPLOY_HOST: git.mokoconsulting.tech
|
||||||
|
DEPLOY_PORT: 2918
|
||||||
|
DEPLOY_USER: mokoconsulting
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-dev:
|
||||||
|
name: "Build & Deploy to Dev"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: config
|
||||||
|
run: |
|
||||||
|
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev-$(git rev-parse --short HEAD)")
|
||||||
|
echo "tag=${VERSION}-dev" >> $GITHUB_OUTPUT
|
||||||
|
echo "Version: ${VERSION}-dev"
|
||||||
|
|
||||||
|
- name: Write deploy key
|
||||||
|
env:
|
||||||
|
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
|
||||||
|
- name: Build and deploy to dev via SSH
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
TAG: ${{ steps.config.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
|
||||||
|
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
||||||
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
|
||||||
|
set -e
|
||||||
|
echo 'SSH connected to dev environment'
|
||||||
|
|
||||||
|
echo 'Cleaning Docker build cache...'
|
||||||
|
docker builder prune -af 2>/dev/null || true
|
||||||
|
docker image prune -af 2>/dev/null || true
|
||||||
|
|
||||||
|
echo 'Pulling source...'
|
||||||
|
SOURCE_DIR=/opt/gitea-dev/source
|
||||||
|
if [ ! -d \$SOURCE_DIR/.git ]; then
|
||||||
|
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
|
||||||
|
fi
|
||||||
|
cd \$SOURCE_DIR
|
||||||
|
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
|
||||||
|
git fetch origin dev
|
||||||
|
git reset --hard origin/dev
|
||||||
|
|
||||||
|
echo 'Building Docker image...'
|
||||||
|
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
|
||||||
|
-f Dockerfile .
|
||||||
|
|
||||||
|
echo 'Pushing to registry...'
|
||||||
|
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
|
||||||
|
|
||||||
|
echo 'Restarting dev container...'
|
||||||
|
cd /opt/gitea-dev
|
||||||
|
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
|
||||||
|
docker compose up -d mokogitea-dev
|
||||||
|
|
||||||
|
echo 'Health check...'
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
sleep 15
|
||||||
|
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
|
||||||
|
echo 'Dev container healthy!'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting... (attempt \$i/8)"
|
||||||
|
done
|
||||||
|
echo 'Health check failed'
|
||||||
|
docker logs mokogitea-dev --tail 20
|
||||||
|
exit 1
|
||||||
|
DEPLOY_EOF
|
||||||
|
|
||||||
|
- name: Verify dev instance
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
curl -sf https://git.dev.mokoconsulting.tech/api/healthz && echo " Dev API healthy"
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
|
||||||
# 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 MokoStandards 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}/MokoStandards-API.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-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/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
|
||||||
else
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -36,7 +36,23 @@ env:
|
|||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-dev:
|
||||||
|
name: "Verify dev environment is healthy"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check dev health
|
||||||
|
run: |
|
||||||
|
echo "Checking git.dev.mokoconsulting.tech health..."
|
||||||
|
if curl -sf --max-time 10 https://git.dev.mokoconsulting.tech/api/healthz; then
|
||||||
|
echo " Dev environment is healthy — proceeding with production deploy"
|
||||||
|
else
|
||||||
|
echo "::error::Dev environment is NOT healthy — blocking production deploy"
|
||||||
|
echo "Deploy to dev first (push to dev branch) and verify it passes before merging to main."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
needs: check-dev
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source (for version detection)
|
- name: Checkout source (for version detection)
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Security
|
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/gitleaks.yml.template
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
|
||||||
#
|
|
||||||
# +========================================================================+
|
|
||||||
# | SECRET SCANNING |
|
|
||||||
# +========================================================================+
|
|
||||||
# | |
|
|
||||||
# | Scans commits for leaked secrets using Gitleaks. |
|
|
||||||
# | |
|
|
||||||
# | - PR scan: only new commits in the PR |
|
|
||||||
# | - Scheduled: full repo scan weekly |
|
|
||||||
# | - Alerts via ntfy on findings |
|
|
||||||
# | |
|
|
||||||
# +========================================================================+
|
|
||||||
|
|
||||||
name: "Universal: Secret Scanning"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gitleaks:
|
|
||||||
name: Gitleaks Secret Scan
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Gitleaks
|
|
||||||
run: |
|
|
||||||
GITLEAKS_VERSION="8.21.2"
|
|
||||||
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
|
||||||
| tar -xz -C /usr/local/bin gitleaks
|
|
||||||
gitleaks version
|
|
||||||
|
|
||||||
- name: Scan for secrets
|
|
||||||
id: scan
|
|
||||||
run: |
|
|
||||||
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
|
|
||||||
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
# Scan only PR commits
|
|
||||||
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
|
||||||
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
if gitleaks detect $ARGS 2>&1; then
|
|
||||||
echo "result=clean" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "result=found" >> "$GITHUB_OUTPUT"
|
|
||||||
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
|
|
||||||
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on findings
|
|
||||||
if: failure() && steps.scan.outputs.result == 'found'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} — secrets detected in code" \
|
|
||||||
-H "Tags: rotating_light,key" \
|
|
||||||
-H "Priority: urgent" \
|
|
||||||
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 06.20.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Notifications
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /.gitea/workflows/notify.yml
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
|
||||||
|
|
||||||
name: "Universal: Notifications"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- "Joomla Build & Release"
|
|
||||||
- "Joomla Extension CI"
|
|
||||||
- "Deploy"
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
name: Send Notification
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.conclusion == 'success' ||
|
|
||||||
github.event.workflow_run.conclusion == 'failure'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Notify on success (releases only)
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
contains(github.event.workflow_run.name, 'Release')
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
WORKFLOW="${{ github.event.workflow_run.name }}"
|
|
||||||
URL="${{ github.event.workflow_run.html_url }}"
|
|
||||||
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} released" \
|
|
||||||
-H "Tags: white_check_mark,package" \
|
|
||||||
-H "Priority: default" \
|
|
||||||
-H "Click: ${URL}" \
|
|
||||||
-d "${WORKFLOW} completed successfully." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}"
|
|
||||||
|
|
||||||
- name: Notify on failure
|
|
||||||
if: github.event.workflow_run.conclusion == 'failure'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
WORKFLOW="${{ github.event.workflow_run.name }}"
|
|
||||||
URL="${{ github.event.workflow_run.html_url }}"
|
|
||||||
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} workflow failed" \
|
|
||||||
-H "Tags: x,warning" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-H "Click: ${URL}" \
|
|
||||||
-d "${WORKFLOW} failed. Check the run for details." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}"
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
name: Publish MCP to npm
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- '.mokogitea/mcp/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Install and build
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npx tsc
|
|
||||||
|
|
||||||
- name: Check version change
|
|
||||||
id: version
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
|
||||||
NPM_VERSION=$(npm view @mokoconsulting/mokogitea-mcp version 2>/dev/null || echo "0.0.0")
|
|
||||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
|
||||||
else
|
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version unchanged: $LOCAL_VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
if: steps.version.outputs.changed == 'true'
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: npm publish --access public
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
if: steps.version.outputs.changed == 'true'
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
npm publish --registry ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ \
|
|
||||||
--//$(echo "${{ github.server_url }}" | sed 's|https://||')/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.MOKOGITEA_TOKEN }}
|
|
||||||
@@ -487,3 +487,48 @@ jobs:
|
|||||||
echo "Source: ${FILE_COUNT} files"
|
echo "Source: ${FILE_COUNT} files"
|
||||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
[ "$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.MOKOGITEA_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 ${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."
|
||||||
|
|||||||
@@ -49,10 +49,8 @@ jobs:
|
|||||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'push') &&
|
github.event_name == 'push'
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip bump]')
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -90,8 +88,20 @@ jobs:
|
|||||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Check platform eligibility (Joomla only)
|
||||||
|
id: eligibility
|
||||||
|
run: |
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Resolve metadata and bump version
|
- name: Resolve metadata and bump version
|
||||||
id: meta
|
id: meta
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
@@ -168,6 +178,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: release
|
id: release
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -178,6 +189,7 @@ jobs:
|
|||||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
- name: Update release notes from CHANGELOG.md
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -214,6 +226,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package and upload
|
- name: Build package and upload
|
||||||
id: package
|
id: package
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
@@ -227,6 +240,7 @@ jobs:
|
|||||||
# No need to build, commit, or sync updates.xml from workflows
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoPlatform.Universal
|
# INGROUP: mokocli.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Security
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
|
||||||
|
|
||||||
name: "Universal: Security Audit"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'composer.json'
|
|
||||||
- 'composer.lock'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
name: Dependency Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Composer audit
|
|
||||||
if: hashFiles('composer.lock') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== Composer Security Audit ==="
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
|
||||||
RESULT=$?
|
|
||||||
if [ $RESULT -ne 0 ]; then
|
|
||||||
echo "::warning::Composer vulnerabilities found"
|
|
||||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "No known vulnerabilities in composer dependencies"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: NPM audit
|
|
||||||
if: hashFiles('package-lock.json') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== NPM Security Audit ==="
|
|
||||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
|
||||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
|
||||||
echo "No known vulnerabilities in npm dependencies"
|
|
||||||
else
|
|
||||||
echo "::warning::NPM vulnerabilities found"
|
|
||||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on vulnerabilities
|
|
||||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
|
||||||
-H "Tags: lock,warning" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
|
||||||
@@ -3,6 +3,17 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Wiki full-text search: case-insensitive search across all wiki page titles and content (#550)
|
||||||
|
- Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550)
|
||||||
|
- Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692)
|
||||||
|
- Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all
|
||||||
|
- Wiki revision diff: line-by-line diff view per commit in wiki page history (#667)
|
||||||
|
- Wiki categories: YAML frontmatter `categories:` with category index page (#668)
|
||||||
|
- Wiki template transclusion: `{{template:Name|key=val}}` with `_Template/` folder (#671)
|
||||||
|
- Wiki enhanced ToC: collapsible, inline via frontmatter, sticky sidebar (#673)
|
||||||
|
- Wiki folder ACL: `_access.yml` per-folder write protection (#674)
|
||||||
|
- Wiki print view and ZIP export of all wiki pages (#675)
|
||||||
|
- Wiki features documentation page in org wiki (standards/Wiki-Features)
|
||||||
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
||||||
- License CRUD with CRC32-checksummed DLID generation and format validation
|
- License CRUD with CRC32-checksummed DLID generation and format validation
|
||||||
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
||||||
@@ -10,6 +21,30 @@
|
|||||||
- 13 seeded product tiers from base to enterprise
|
- 13 seeded product tiers from base to enterprise
|
||||||
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||||
- Profile repo fallback chain: .mokogitea > .profile > .github
|
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||||
|
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
|
||||||
|
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
|
||||||
|
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
|
||||||
|
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
|
||||||
|
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
|
||||||
|
- Issue status API response includes is_required field
|
||||||
|
- Wiki recent changes page: cross-page edit activity with pagination (#670)
|
||||||
|
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Licensing API: handle DB write errors in UpdateLicense, UpdateTier, DeleteTier instead of silently discarding
|
||||||
|
- Wiki API: fix findEntryForFile URL-decode fallback for non-ASCII page names
|
||||||
|
- Metadata settings template 500 error: removed reference to deleted Version field
|
||||||
|
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
|
||||||
|
- Wiki backlinks: proper URL encoding for subdirectory pages
|
||||||
|
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
|
||||||
|
- Issue statuses template: garbled em-dash character replaced
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
|
||||||
|
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Workflows: gitleaks.yml, npm-publish.yml, notify.yml, workflow-sync-trigger.yml, composer-publish.yml, deploy-manual.yml, security-audit.yml (not applicable to Go repo)
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
## [06.19.00] --- 2026-06-20
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,29 @@
|
|||||||
# MokoGitea
|
# MokoGitea
|
||||||
|
|
||||||
Moko fork of Gitea — adding project board REST API endpoints and custom enhancements
|
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, and project board API.
|
||||||
|
|
||||||
  
|
 
|
||||||
|
|
||||||
|
|
||||||
Custom Gitea fork with Project Board API
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pages
|
## Key Features
|
||||||
|
|
||||||
- [Branding](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Branding)
|
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
|
||||||
- [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment)
|
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
|
||||||
- [Project API](Project API)
|
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection
|
||||||
- [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap)
|
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
|
||||||
|
- **Project Board API** -- REST endpoints for project columns and cards
|
||||||
---
|
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
|
||||||
|
|
||||||
**Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full documentation is available on the [Wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki).
|
- [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs
|
||||||
|
- [Wiki Features](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features) -- all 10 wiki enhancements
|
||||||
|
- [Licensing API](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/api/Licensing-API)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See the wiki for development guidelines and contribution instructions.
|
See the [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -40,4 +31,4 @@ This project is licensed under the GNU General Public License v3.0 or later -- s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki/Home)*
|
*[Moko Consulting](https://mokoconsulting.tech)*
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||||
Description string `xorm:"TEXT"`
|
Description string `xorm:"TEXT"`
|
||||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||||
|
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
|
||||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
@@ -56,14 +57,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||||
|
// Open and Closed are required (is_required=true) and cannot be deleted.
|
||||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||||
defaults := []*IssueStatusDef{
|
defaults := []*IssueStatusDef{
|
||||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
|
||||||
|
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
|
||||||
}
|
}
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||||
@@ -111,13 +113,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStatusRequired is returned when trying to delete a required status.
|
||||||
|
type ErrStatusRequired struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrStatusRequired) Error() string {
|
||||||
|
return "status is required and cannot be deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrStatusRequired checks if an error is ErrStatusRequired.
|
||||||
|
func IsErrStatusRequired(err error) bool {
|
||||||
|
_, ok := err.(ErrStatusRequired)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||||
|
// Returns ErrStatusRequired if the status is marked as required.
|
||||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||||
|
def, err := GetIssueStatusDefByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if def.IsRequired {
|
||||||
|
return ErrStatusRequired{ID: def.ID, Name: def.Name}
|
||||||
|
}
|
||||||
// Clear status_id on all issues that reference this definition
|
// Clear status_id on all issues that reference this definition
|
||||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
||||||
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
||||||
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
||||||
|
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddDeployFieldsToRepoManifest adds deploy configuration columns to repo_manifest.
|
||||||
|
func AddDeployFieldsToRepoManifest(x *xorm.Engine) error {
|
||||||
|
type RepoManifest struct {
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"`
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"`
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"`
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"`
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"`
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"`
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"`
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(RepoManifest))
|
||||||
|
}
|
||||||
@@ -50,6 +50,16 @@ type RepoMetadata struct {
|
|||||||
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
||||||
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
||||||
|
|
||||||
|
// deploy section
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"` // SSH user
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"` // remote path for source/compose
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"` // e.g. mokoconsulting/mokogitea
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"` // e.g. git.mokoconsulting.tech
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"` // Docker container name
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"` // health check URL after deploy
|
||||||
|
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ClosesIssue bool `json:"closes_issue"`
|
ClosesIssue bool `json:"closes_issue"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -1314,6 +1314,7 @@ func Routes() *web.Router {
|
|||||||
m.Get("/revisions/*", repo.ListPageRevisions)
|
m.Get("/revisions/*", repo.ListPageRevisions)
|
||||||
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
||||||
m.Get("/pages", repo.ListWikiPages)
|
m.Get("/pages", repo.ListWikiPages)
|
||||||
|
m.Get("/search", repo.SearchWikiPages)
|
||||||
}, mustEnableWiki)
|
}, mustEnableWiki)
|
||||||
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
||||||
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
||||||
@@ -1480,12 +1481,10 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), repo.DeleteTopic)
|
Delete(reqToken(), repo.DeleteTopic)
|
||||||
}, reqAdmin())
|
}, reqAdmin())
|
||||||
}, reqAnyRepoReader())
|
}, reqAnyRepoReader())
|
||||||
m.Combo("/metadata", reqRepoReader(unit.TypeCode)).
|
m.Get("/metadata", repo.GetRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
m.Get("/manifest", repo.GetRepoMetadata) // backward compat
|
||||||
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat
|
m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
|
||||||
// MokoGitea badge engine
|
// MokoGitea badge engine
|
||||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||||
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||||
@@ -1860,9 +1859,39 @@ func Routes() *web.Router {
|
|||||||
m.Get("/search", repo.TopicSearch)
|
m.Get("/search", repo.TopicSearch)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
||||||
|
|
||||||
// Licensing endpoints — DLID-gated, no token required
|
// Licensing endpoints
|
||||||
m.Group("/licensing", func() {
|
m.Group("/licensing", func() {
|
||||||
|
// Public (no auth)
|
||||||
m.Get("/updates/{product}", licensing.ServeUpdates)
|
m.Get("/updates/{product}", licensing.ServeUpdates)
|
||||||
|
m.Get("/validate", licensing.Validate)
|
||||||
|
m.Get("/download/{product}/{version}", licensing.ServeDownload)
|
||||||
|
|
||||||
|
// User self-service (authenticated)
|
||||||
|
m.Group("/my", func() {
|
||||||
|
m.Get("/licenses", licensing.MyLicenses)
|
||||||
|
m.Get("/licenses/{id}/domains", licensing.MyLicenseDomains)
|
||||||
|
m.Delete("/licenses/{id}/domains/{domain}", licensing.MyDeactivateDomain)
|
||||||
|
}, reqToken())
|
||||||
|
|
||||||
|
// Admin license management
|
||||||
|
m.Group("/licenses", func() {
|
||||||
|
m.Get("", licensing.ListLicenses)
|
||||||
|
m.Post("", licensing.CreateLicense)
|
||||||
|
m.Get("/{id}", licensing.GetLicense)
|
||||||
|
m.Patch("/{id}", licensing.UpdateLicense)
|
||||||
|
m.Delete("/{id}", licensing.DeleteLicense)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Admin tier management
|
||||||
|
m.Group("/tiers", func() {
|
||||||
|
m.Get("", licensing.ListTiers)
|
||||||
|
m.Post("", licensing.CreateTier)
|
||||||
|
m.Patch("/{id}", licensing.UpdateTier)
|
||||||
|
m.Delete("/{id}", licensing.DeleteTier)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Authenticated license detail
|
||||||
|
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
||||||
})
|
})
|
||||||
}, sudo())
|
}, sudo())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeDownload handles GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ
|
||||||
|
func ServeDownload(ctx *context.APIContext) {
|
||||||
|
product := ctx.PathParam("product")
|
||||||
|
versionFile := ctx.PathParam("version")
|
||||||
|
token := ctx.FormString("token")
|
||||||
|
expiresStr := ctx.FormString("expires")
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
|
||||||
|
version, ok := licensing_service.ParseDownloadParams(versionFile)
|
||||||
|
if !ok {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid version format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expires, ok := licensing_service.ParseExpires(expiresStr)
|
||||||
|
if !ok || token == "" || dlid == "" {
|
||||||
|
ctx.APIError(http.StatusForbidden, "missing or invalid download parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signed token
|
||||||
|
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
|
||||||
|
ctx.APIError(http.StatusForbidden, "invalid or expired download token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DLID is still valid
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil || license == nil || !license.IsActive() {
|
||||||
|
ctx.APIError(http.StatusForbidden, "license invalid or expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entitlement
|
||||||
|
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if !has {
|
||||||
|
ctx.APIError(http.StatusForbidden, "no entitlement for product")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve repo from entitlement
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to get entitlements")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var repoOwner, repoName string
|
||||||
|
for _, ent := range ents {
|
||||||
|
if ent.ProductCode == product {
|
||||||
|
repoOwner = ent.RepoOwner
|
||||||
|
repoName = ent.RepoName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repoName == "" {
|
||||||
|
ctx.APIError(http.StatusNotFound, "product repo not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find repo
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
|
||||||
|
if err != nil || repo == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, "repository not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the release with matching version
|
||||||
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
ListOptions: db.ListOptionsAll,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
IncludeTags: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list releases")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRelease *repo_model.Release
|
||||||
|
for _, rel := range releases {
|
||||||
|
relVersion := extractVersion(rel.TagName)
|
||||||
|
if relVersion == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rel.Title != "" && extractVersion(rel.Title) == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetRelease == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, fmt.Sprintf("release version %s not found", version))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ZIP attachment
|
||||||
|
var attachments []*repo_model.Attachment
|
||||||
|
err = db.GetEngine(ctx).Where("release_id = ?", targetRelease.ID).Find(&attachments)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to get attachments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipAttachment *repo_model.Attachment
|
||||||
|
for _, att := range attachments {
|
||||||
|
if att.Name != "" && len(att.Name) > 4 && att.Name[len(att.Name)-4:] == ".zip" {
|
||||||
|
zipAttachment = att
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zipAttachment == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, "no zip attachment found for release")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the download
|
||||||
|
licensing_model.LogLicenseAudit(ctx, license.ID, "download",
|
||||||
|
product, fmt.Sprintf("%s/%s", version, zipAttachment.Name))
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
fr, err := storage.Attachments.Open(zipAttachment.RelativePath())
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to open attachment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fr.Close()
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/zip")
|
||||||
|
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipAttachment.Name))
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if _, err := io.Copy(ctx.Resp, fr); err != nil {
|
||||||
|
log.Error("ServeDownload: io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
mojo_json "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Admin: License CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type createLicenseRequest struct {
|
||||||
|
UserID int64 `json:"user_id" binding:"Required"`
|
||||||
|
Tier string `json:"tier" binding:"Required"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
ExpiresMonths int `json:"expires_months"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicense handles POST /api/v1/licensing/licenses
|
||||||
|
func CreateLicense(ctx *context.APIContext) {
|
||||||
|
var req createLicenseRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve max_domains from tier if not specified
|
||||||
|
maxDomains := req.MaxDomains
|
||||||
|
if maxDomains == 0 {
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, req.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
maxDomains = tier.MaxDomains
|
||||||
|
}
|
||||||
|
if maxDomains == 0 {
|
||||||
|
maxDomains = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt timeutil.TimeStamp
|
||||||
|
if req.ExpiresMonths > 0 {
|
||||||
|
expiresAt = timeutil.TimeStamp(time.Now().AddDate(0, req.ExpiresMonths, 0).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.CreateLicense(ctx, req.UserID, req.Tier, maxDomains, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to create license")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Notes != "" {
|
||||||
|
license.Notes = req.Notes
|
||||||
|
// TODO: update notes field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build entitlements from tier
|
||||||
|
if err := licensing_model.RebuildEntitlements(ctx, license.ID, req.Tier); err != nil {
|
||||||
|
log.Error("CreateLicense: RebuildEntitlements: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenses handles GET /api/v1/licensing/licenses
|
||||||
|
func ListLicenses(ctx *context.APIContext) {
|
||||||
|
page := ctx.FormInt("page")
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, get all licenses (pagination via offset)
|
||||||
|
// TODO: add proper pagination to the model layer
|
||||||
|
var licenses []*licensing_model.License
|
||||||
|
err := db.GetEngine(ctx).Limit(limit, (page-1)*limit).Find(&licenses)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicense handles GET /api/v1/licensing/licenses/{id}
|
||||||
|
func GetLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := licenseToJSON(ctx, license)
|
||||||
|
|
||||||
|
// Include entitlements
|
||||||
|
ents, _ := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
entList := make([]map[string]any, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
entList = append(entList, map[string]any{
|
||||||
|
"product_code": e.ProductCode,
|
||||||
|
"repo_owner": e.RepoOwner,
|
||||||
|
"repo_name": e.RepoName,
|
||||||
|
"is_custom": e.IsCustom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["entitlements"] = entList
|
||||||
|
|
||||||
|
// Include activations
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
actList := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
actList = append(actList, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"ip_address": a.IPAddress,
|
||||||
|
"joomla_ver": a.JoomlaVer,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["activations"] = actList
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateLicenseRequest struct {
|
||||||
|
Tier *string `json:"tier"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLicense handles PATCH /api/v1/licensing/licenses/{id}
|
||||||
|
func UpdateLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateLicenseRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tier != nil && *req.Tier != license.Tier {
|
||||||
|
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to update tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Tier = *req.Tier
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil && *req.Status != license.Status {
|
||||||
|
if err := licensing_model.SetLicenseStatus(ctx, id, *req.Status); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to update status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update simple fields directly
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
license.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.Notes != nil {
|
||||||
|
license.Notes = *req.Notes
|
||||||
|
cols = append(cols, "notes")
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||||
|
if err == nil {
|
||||||
|
license.ExpiresAt = timeutil.TimeStamp(t.Unix())
|
||||||
|
cols = append(cols, "expires_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cols) > 0 {
|
||||||
|
cols = append(cols, "updated_at")
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(license); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicense handles DELETE /api/v1/licensing/licenses/{id}
|
||||||
|
func DeleteLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to revoke license")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User: Self-service ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// MyLicenses handles GET /api/v1/licensing/my/licenses
|
||||||
|
func MyLicenses(ctx *context.APIContext) {
|
||||||
|
licenses, err := licensing_model.GetLicensesByUser(ctx, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyLicenseDomains handles GET /api/v1/licensing/my/licenses/{id}/domains
|
||||||
|
func MyLicenseDomains(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list domains")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyDeactivateDomain handles DELETE /api/v1/licensing/my/licenses/{id}/domains/{domain}
|
||||||
|
func MyDeactivateDomain(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := ctx.PathParam("domain")
|
||||||
|
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to deactivate domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Product Tier CRUD ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListTiers handles GET /api/v1/licensing/tiers
|
||||||
|
func ListTiers(ctx *context.APIContext) {
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
results = append(results, tierToJSON(t))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTierRequest struct {
|
||||||
|
TierKey string `json:"tier_key" binding:"Required"`
|
||||||
|
TierName string `json:"tier_name" binding:"Required"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTier handles POST /api/v1/licensing/tiers
|
||||||
|
func CreateTier(ctx *context.APIContext) {
|
||||||
|
var req createTierRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := mojo_json.Marshal(req.Repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: req.TierKey,
|
||||||
|
TierName: req.TierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: req.MaxDomains,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.GetEngine(ctx).Insert(tier)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to create tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTierRequest struct {
|
||||||
|
TierName *string `json:"tier_name"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTier handles PATCH /api/v1/licensing/tiers/{id}
|
||||||
|
func UpdateTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if err != nil || !has {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateTierRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.TierName != nil {
|
||||||
|
tier.TierName = *req.TierName
|
||||||
|
cols = append(cols, "tier_name")
|
||||||
|
}
|
||||||
|
if req.Repos != nil {
|
||||||
|
reposJSON, _ := mojo_json.Marshal(req.Repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
cols = append(cols, "repos")
|
||||||
|
}
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
tier.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
tier.SortOrder = *req.SortOrder
|
||||||
|
cols = append(cols, "sort_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) > 0 {
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTier handles DELETE /api/v1/licensing/tiers/{id}
|
||||||
|
func DeleteTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any licenses use this tier
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.APIError(http.StatusConflict, "cannot delete tier with active licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier)); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func licenseToJSON(ctx *context.APIContext, l *licensing_model.License) map[string]any {
|
||||||
|
tierName := l.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, l.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, l.ID)
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"id": l.ID,
|
||||||
|
"user_id": l.UserID,
|
||||||
|
"dlid": l.DLID,
|
||||||
|
"tier": l.Tier,
|
||||||
|
"tier_name": tierName,
|
||||||
|
"max_domains": l.MaxDomains,
|
||||||
|
"domains_used": domainCount,
|
||||||
|
"status": l.Status,
|
||||||
|
"notes": l.Notes,
|
||||||
|
"created_at": formatTime(l.CreatedAt),
|
||||||
|
"updated_at": formatTime(l.UpdatedAt),
|
||||||
|
}
|
||||||
|
if l.ExpiresAt > 0 {
|
||||||
|
result["expires_at"] = formatTime(l.ExpiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierToJSON(t *licensing_model.ProductTier) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"tier_key": t.TierKey,
|
||||||
|
"tier_name": t.TierName,
|
||||||
|
"repos": t.RepoList(),
|
||||||
|
"max_domains": t.MaxDomains,
|
||||||
|
"sort_order": t.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(ts timeutil.TimeStamp) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Joomla update XML structures.
|
// Joomla update XML structures.
|
||||||
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
|
|||||||
displayName = manifest.DerivedDisplayName()
|
displayName = manifest.DerivedDisplayName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build download URL
|
// Build signed download URL
|
||||||
baseURL := setting.AppURL
|
baseURL := setting.AppURL
|
||||||
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s",
|
token, expires := licensing_service.SignDownloadToken(productCode, version, dlid)
|
||||||
baseURL, productCode, version, dlid)
|
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s&token=%s&expires=%d",
|
||||||
|
baseURL, productCode, version, dlid, token, expires)
|
||||||
|
|
||||||
updates := xmlUpdates{
|
updates := xmlUpdates{
|
||||||
Updates: []xmlUpdate{
|
Updates: []xmlUpdate{
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateResponse is the public validation result.
|
||||||
|
type validateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
TierName string `json:"tier_name,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
DomainsUsed int `json:"domains_used,omitempty"`
|
||||||
|
DomainsMax int `json:"domains_max,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the full license detail for authenticated callers.
|
||||||
|
type statusResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
DLID string `json:"dlid"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
DomainsUsed int `json:"domains_used"`
|
||||||
|
DomainsMax int `json:"domains_max"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
|
||||||
|
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
|
||||||
|
func Validate(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
product := ctx.FormString("product")
|
||||||
|
domain := ctx.FormString("domain")
|
||||||
|
|
||||||
|
if dlid == "" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.Status == "revoked" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.Status == "suspended" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.IsExpired() {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product entitlement if product is specified
|
||||||
|
if product != "" {
|
||||||
|
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: HasEntitlement: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain limit if domain is specified
|
||||||
|
if domain != "" {
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
|
||||||
|
// Check if this domain is already activated
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
found := false
|
||||||
|
for _, a := range acts {
|
||||||
|
if a.Domain == domain {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := validateResponse{
|
||||||
|
Valid: true,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status handles GET /api/v1/licensing/{dlid}/status
|
||||||
|
// Authenticated endpoint — returns full license detail with entitlement list.
|
||||||
|
func Status(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.PathParam("dlid")
|
||||||
|
|
||||||
|
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entitlements
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetEntitlementsByLicense: %v", err)
|
||||||
|
}
|
||||||
|
products := make([]string, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
products = append(products, e.ProductCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := statusResponse{
|
||||||
|
Valid: license.IsActive(),
|
||||||
|
DLID: license.DLID,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
Products: products,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -11,6 +11,19 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkOrgVisibility returns true if the current user can view org metadata.
|
||||||
|
// Public orgs are visible to everyone. Private/limited orgs require authentication.
|
||||||
|
func checkOrgVisibility(ctx *context.APIContext) bool {
|
||||||
|
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ctx.Doer == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssueStatuses returns active issue status definitions for an org.
|
// ListIssueStatuses returns active issue status definitions for an org.
|
||||||
func ListIssueStatuses(ctx *context.APIContext) {
|
func ListIssueStatuses(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
||||||
@@ -34,6 +47,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -47,6 +64,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
Color: d.Color,
|
Color: d.Color,
|
||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
ClosesIssue: d.ClosesIssue,
|
ClosesIssue: d.ClosesIssue,
|
||||||
|
IsRequired: d.IsRequired,
|
||||||
SortOrder: d.SortOrder,
|
SortOrder: d.SortOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,6 +94,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -118,6 +140,10 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ type apiMetadata struct {
|
|||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
ExtensionType string `json:"extension_type"`
|
ExtensionType string `json:"extension_type"`
|
||||||
EntryPoint string `json:"entry_point"`
|
EntryPoint string `json:"entry_point"`
|
||||||
|
|
||||||
|
// deploy
|
||||||
|
DeployHost string `json:"deploy_host,omitempty"`
|
||||||
|
DeployPort string `json:"deploy_port,omitempty"`
|
||||||
|
DeployUser string `json:"deploy_user,omitempty"`
|
||||||
|
DeployPath string `json:"deploy_path,omitempty"`
|
||||||
|
DockerImage string `json:"docker_image,omitempty"`
|
||||||
|
DockerRegistry string `json:"docker_registry,omitempty"`
|
||||||
|
ContainerName string `json:"container_name,omitempty"`
|
||||||
|
HealthURL string `json:"health_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoMetadata returns the manifest settings for a repository.
|
// GetRepoMetadata returns the manifest settings for a repository.
|
||||||
@@ -81,6 +91,14 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,35 +114,59 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/Manifest"
|
// "$ref": "#/responses/Manifest"
|
||||||
var req apiMetadata
|
// Decode into a map to detect which fields were actually sent.
|
||||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
var raw map[string]any
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
|
||||||
ctx.APIError(http.StatusBadRequest, err)
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &repo_model.RepoMetadata{
|
// Load existing metadata (or create defaults).
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
m, _ := repo_model.GetRepoMetadata(ctx, ctx.Repo.Repository.ID)
|
||||||
Name: req.Name,
|
if m == nil {
|
||||||
Org: req.Org,
|
m = &repo_model.RepoMetadata{
|
||||||
Description: req.Description,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
Name: ctx.Repo.Repository.Name,
|
||||||
LicenseSPDX: req.LicenseSPDX,
|
Org: ctx.Repo.Repository.OwnerName,
|
||||||
LicenseName: req.LicenseName,
|
Description: ctx.Repo.Repository.Description,
|
||||||
VersionPrefix: req.VersionPrefix,
|
}
|
||||||
ElementName: req.ElementName,
|
|
||||||
Platform: req.Platform,
|
|
||||||
StandardsVersion: req.StandardsVersion,
|
|
||||||
StandardsSource: req.StandardsSource,
|
|
||||||
Maintainer: req.Maintainer,
|
|
||||||
MaintainerURL: req.MaintainerURL,
|
|
||||||
InfoURL: req.InfoURL,
|
|
||||||
TargetVersion: req.TargetVersion,
|
|
||||||
PHPMinimum: req.PHPMinimum,
|
|
||||||
Language: req.Language,
|
|
||||||
ExtensionType: req.ExtensionType,
|
|
||||||
EntryPoint: req.EntryPoint,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply only the fields present in the request.
|
||||||
|
setStr := func(key string, target *string) {
|
||||||
|
if v, ok := raw[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
*target = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStr("name", &m.Name)
|
||||||
|
setStr("org", &m.Org)
|
||||||
|
setStr("description", &m.Description)
|
||||||
|
setStr("license_spdx", &m.LicenseSPDX)
|
||||||
|
setStr("license_name", &m.LicenseName)
|
||||||
|
setStr("version_prefix", &m.VersionPrefix)
|
||||||
|
setStr("element_name", &m.ElementName)
|
||||||
|
setStr("platform", &m.Platform)
|
||||||
|
setStr("standards_version", &m.StandardsVersion)
|
||||||
|
setStr("standards_source", &m.StandardsSource)
|
||||||
|
setStr("maintainer", &m.Maintainer)
|
||||||
|
setStr("maintainer_url", &m.MaintainerURL)
|
||||||
|
setStr("info_url", &m.InfoURL)
|
||||||
|
setStr("target_version", &m.TargetVersion)
|
||||||
|
setStr("php_minimum", &m.PHPMinimum)
|
||||||
|
setStr("language", &m.Language)
|
||||||
|
setStr("extension_type", &m.ExtensionType)
|
||||||
|
setStr("entry_point", &m.EntryPoint)
|
||||||
|
setStr("deploy_host", &m.DeployHost)
|
||||||
|
setStr("deploy_port", &m.DeployPort)
|
||||||
|
setStr("deploy_user", &m.DeployUser)
|
||||||
|
setStr("deploy_path", &m.DeployPath)
|
||||||
|
setStr("docker_image", &m.DockerImage)
|
||||||
|
setStr("docker_registry", &m.DockerRegistry)
|
||||||
|
setStr("container_name", &m.ContainerName)
|
||||||
|
setStr("health_url", &m.HealthURL)
|
||||||
|
|
||||||
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -151,5 +193,13 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
@@ -461,10 +462,148 @@ func ListPageRevisions(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchWikiPages searches wiki page titles and content.
|
||||||
|
func SearchWikiPages(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/wiki/search repository repoSearchWikiPages
|
||||||
|
// ---
|
||||||
|
// summary: Search wiki pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: q
|
||||||
|
// in: query
|
||||||
|
// description: search query
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: "SearchResults"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
query := strings.TrimSpace(ctx.FormString("q"))
|
||||||
|
if query == "" {
|
||||||
|
ctx.JSON(http.StatusOK, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiRepo, commit := findWikiRepoCommit(ctx)
|
||||||
|
if wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
}
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
type WikiSearchResult struct {
|
||||||
|
PageName string `json:"page_name"`
|
||||||
|
PageURL string `json:"page_url"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := commit.ListEntriesRecursiveFast()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []WikiSearchResult
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsRegular() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseName := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
// Extract just the filename without path for special file check
|
||||||
|
parts := strings.Split(baseName, "/")
|
||||||
|
shortName := parts[len(parts)-1]
|
||||||
|
if shortName == "_Sidebar" || shortName == "_Footer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
titleMatch := strings.Contains(strings.ToLower(baseName), queryLower)
|
||||||
|
contentMatch := strings.Contains(strings.ToLower(content), queryLower)
|
||||||
|
|
||||||
|
if !titleMatch && !contentMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contextLine := ""
|
||||||
|
if contentMatch {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.Contains(strings.ToLower(line), queryLower) {
|
||||||
|
contextLine = strings.TrimSpace(line)
|
||||||
|
if len(contextLine) > 200 {
|
||||||
|
contextLine = contextLine[:200] + "..."
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
|
||||||
|
|
||||||
|
results = append(results, WikiSearchResult{
|
||||||
|
PageName: displayName,
|
||||||
|
PageURL: string(wikiName),
|
||||||
|
Context: contextLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = setting.API.DefaultPagingNum
|
||||||
|
}
|
||||||
|
total := len(results)
|
||||||
|
start := (page - 1) * limit
|
||||||
|
end := start + limit
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetLinkHeader(int64(total), limit)
|
||||||
|
ctx.SetTotalCountHeader(int64(total))
|
||||||
|
ctx.JSON(http.StatusOK, results[start:end])
|
||||||
|
}
|
||||||
|
|
||||||
// findEntryForFile finds the tree entry for a target filepath.
|
// findEntryForFile finds the tree entry for a target filepath.
|
||||||
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
||||||
entry, err := commit.GetTreeEntryByPath(target)
|
entry, err := commit.GetTreeEntryByPath(target)
|
||||||
if err != nil {
|
if err != nil && !git.IsErrNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplLicenseTiers templates.TplName = "admin/license_tiers"
|
||||||
|
|
||||||
|
// LicenseTiers shows the product tier management page.
|
||||||
|
func LicenseTiers(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = "Product Tiers"
|
||||||
|
ctx.Data["PageIsAdminLicenseTiers"] = true
|
||||||
|
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetAllProductTiers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierView struct {
|
||||||
|
*licensing_model.ProductTier
|
||||||
|
Repos []string
|
||||||
|
LicenseCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]tierView, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", t.TierKey).Count(new(licensing_model.License))
|
||||||
|
views = append(views, tierView{
|
||||||
|
ProductTier: t,
|
||||||
|
Repos: t.RepoList(),
|
||||||
|
LicenseCount: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["Tiers"] = views
|
||||||
|
ctx.HTML(http.StatusOK, tplLicenseTiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierCreate handles POST to create a new tier.
|
||||||
|
func LicenseTierCreate(ctx *context.Context) {
|
||||||
|
tierKey := ctx.FormString("tier_key")
|
||||||
|
tierName := ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
maxDomains, _ := strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if tierKey == "" || tierName == "" {
|
||||||
|
ctx.Flash.Error("Tier key and name are required")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: tierKey,
|
||||||
|
TierName: tierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: maxDomains,
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to create tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tierName + "' created")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierUpdate handles POST to update a tier.
|
||||||
|
func LicenseTierUpdate(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier.TierName = ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
tier.MaxDomains, _ = strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
tier.SortOrder, _ = strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols("tier_name", "repos", "max_domains", "sort_order").Update(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to update tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' updated")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierDelete handles POST to delete a tier.
|
||||||
|
func LicenseTierDelete(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.Flash.Error("Cannot delete tier with active licenses. Reassign licenses first.")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' deleted")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
@@ -103,6 +103,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
||||||
|
if issues_model.IsErrStatusRequired(err) {
|
||||||
|
ctx.Flash.Error("Cannot delete required status: " + def.Name)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("DeleteIssueStatusDef", err)
|
ctx.ServerError("DeleteIssueStatusDef", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
|
|||||||
SubURL string
|
SubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
|
||||||
|
type OrgWikiTreeNode struct {
|
||||||
|
Name string
|
||||||
|
SubURL string
|
||||||
|
IsDir bool
|
||||||
|
Children []*OrgWikiTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
// Wiki renders the org wiki tab.
|
// Wiki renders the org wiki tab.
|
||||||
func Wiki(ctx *context.Context) {
|
func Wiki(ctx *context.Context) {
|
||||||
org := ctx.Org.Organization
|
org := ctx.Org.Organization
|
||||||
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
||||||
|
|
||||||
// Build page list from repo root.
|
// Build folder tree for sidebar navigation.
|
||||||
entries, err := commit.ListEntries()
|
wikiTree := buildOrgWikiTree(commit)
|
||||||
if err != nil {
|
ctx.Data["WikiTree"] = wikiTree
|
||||||
ctx.ServerError("ListEntries", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pages := make([]OrgWikiPage, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if !isMarkdownFile(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
displayName := strings.TrimSuffix(name, path.Ext(name))
|
|
||||||
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages = append(pages, OrgWikiPage{
|
|
||||||
Name: displayName,
|
|
||||||
SubURL: displayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx.Data["Pages"] = pages
|
|
||||||
|
|
||||||
// Determine which page to render.
|
// Determine which page to render.
|
||||||
pageName := ctx.PathParamRaw("*")
|
pageName := ctx.PathParamRaw("*")
|
||||||
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
|
|||||||
ctx.HTML(http.StatusOK, tplOrgWiki)
|
ctx.HTML(http.StatusOK, tplOrgWiki)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
|
||||||
|
// Shows up to 2 levels deep (folders and their immediate children).
|
||||||
|
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevel []*OrgWikiTreeNode
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
node := &OrgWikiTreeNode{
|
||||||
|
Name: name,
|
||||||
|
SubURL: name,
|
||||||
|
IsDir: true,
|
||||||
|
}
|
||||||
|
// List children of this directory (1 level deep).
|
||||||
|
subTree := entry.Tree()
|
||||||
|
if subTree != nil {
|
||||||
|
children, _ := subTree.ListEntries()
|
||||||
|
for _, child := range children {
|
||||||
|
childName := child.Name()
|
||||||
|
if child.IsDir() {
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: childName,
|
||||||
|
SubURL: name + "/" + childName,
|
||||||
|
IsDir: true,
|
||||||
|
})
|
||||||
|
} else if isMarkdownFile(childName) {
|
||||||
|
displayName := strings.TrimSuffix(childName, path.Ext(childName))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: name + "/" + displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, node)
|
||||||
|
} else if isMarkdownFile(name) {
|
||||||
|
displayName := strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topLevel
|
||||||
|
}
|
||||||
|
|
||||||
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
||||||
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
||||||
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ func saveMetadata(ctx *context.Context) {
|
|||||||
manifest.Maintainer = existing.Maintainer
|
manifest.Maintainer = existing.Maintainer
|
||||||
manifest.MaintainerURL = existing.MaintainerURL
|
manifest.MaintainerURL = existing.MaintainerURL
|
||||||
manifest.Language = existing.Language
|
manifest.Language = existing.Language
|
||||||
|
manifest.DeployHost = existing.DeployHost
|
||||||
|
manifest.DeployPort = existing.DeployPort
|
||||||
|
manifest.DeployUser = existing.DeployUser
|
||||||
|
manifest.DeployPath = existing.DeployPath
|
||||||
|
manifest.DockerImage = existing.DockerImage
|
||||||
|
manifest.DockerRegistry = existing.DockerRegistry
|
||||||
|
manifest.ContainerName = existing.ContainerName
|
||||||
|
manifest.HealthURL = existing.HealthURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
|
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
|
||||||
|
|||||||
+1111
-66
File diff suppressed because it is too large
Load Diff
@@ -842,6 +842,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Post("/cleanup", admin.CleanupExpiredData)
|
m.Post("/cleanup", admin.CleanupExpiredData)
|
||||||
}, packagesEnabled)
|
}, packagesEnabled)
|
||||||
|
|
||||||
|
m.Group("/license-tiers", func() {
|
||||||
|
m.Get("", admin.LicenseTiers)
|
||||||
|
m.Post("", admin.LicenseTierCreate)
|
||||||
|
m.Post("/{id}/delete", admin.LicenseTierDelete)
|
||||||
|
})
|
||||||
|
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Get("", admin.DefaultOrSystemWebhooks)
|
m.Get("", admin.DefaultOrSystemWebhooks)
|
||||||
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
|
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyFileName = "licensing_ed25519.key"
|
||||||
|
downloadTTL = 5 * time.Minute
|
||||||
|
tokenSeparator = "|"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privateKey ed25519.PrivateKey
|
||||||
|
publicKey ed25519.PublicKey
|
||||||
|
keyOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initKeys loads or generates the ed25519 keypair used for signing download tokens.
|
||||||
|
func initKeys() {
|
||||||
|
keyOnce.Do(func() {
|
||||||
|
keyPath := filepath.Join(setting.AppDataPath, keyFileName)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(keyPath)
|
||||||
|
if err == nil && len(data) == ed25519.SeedSize {
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(data)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
log.Info("Licensing: loaded ed25519 key from %s", keyPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new keypair
|
||||||
|
seed := make([]byte, ed25519.SeedSize)
|
||||||
|
if _, err := rand.Read(seed); err != nil {
|
||||||
|
log.Error("Licensing: failed to generate ed25519 seed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(seed)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
|
||||||
|
if err := os.WriteFile(keyPath, seed, 0600); err != nil {
|
||||||
|
log.Error("Licensing: failed to save ed25519 key to %s: %v", keyPath, err)
|
||||||
|
} else {
|
||||||
|
log.Info("Licensing: generated new ed25519 key at %s", keyPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignDownloadToken creates a signed, time-limited download token.
|
||||||
|
// The message format is: product|version|dlid|expires
|
||||||
|
func SignDownloadToken(product, version, dlid string) (token string, expires int64) {
|
||||||
|
initKeys()
|
||||||
|
if privateKey == nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
expires = time.Now().Add(downloadTTL).Unix()
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig := ed25519.Sign(privateKey, []byte(message))
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return token, expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDownloadToken validates a signed download token.
|
||||||
|
// Returns the parsed product, version, dlid, and any error.
|
||||||
|
func VerifyDownloadToken(token string, product, version, dlid string, expires int64) bool {
|
||||||
|
initKeys()
|
||||||
|
if publicKey == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if time.Now().Unix() > expires {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct message
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ed25519.Verify(publicKey, []byte(message), sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDownloadParams extracts product and version from the URL path segment.
|
||||||
|
// Expects format: "{version}.zip" with product as a separate path param.
|
||||||
|
func ParseDownloadParams(versionFile string) (version string, ok bool) {
|
||||||
|
if !strings.HasSuffix(versionFile, ".zip") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
version = strings.TrimSuffix(versionFile, ".zip")
|
||||||
|
if version == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpires converts the expires query parameter to int64.
|
||||||
|
func ParseExpires(s string) (int64, bool) {
|
||||||
|
v, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}
|
||||||
|
<div class="admin-setting-content">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
Product Tiers
|
||||||
|
<div class="ui right">
|
||||||
|
<button class="ui primary tiny button" id="btn-new-tier">New Tier</button>
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .Tiers}}
|
||||||
|
<table class="ui very basic striped table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Repos</th>
|
||||||
|
<th>Max Domains</th>
|
||||||
|
<th>Licenses</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tiers}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.TierKey}}</code></td>
|
||||||
|
<td>{{.TierName}}</td>
|
||||||
|
<td>
|
||||||
|
{{range .Repos}}
|
||||||
|
<span class="ui label">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}</td>
|
||||||
|
<td>{{.LicenseCount}}</td>
|
||||||
|
<td>{{.SortOrder}}</td>
|
||||||
|
<td class="right aligned">
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers/{{.ID}}/delete" style="display:inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="ui tiny red button{{if gt .LicenseCount 0}} disabled{{end}}" type="submit"
|
||||||
|
{{if gt .LicenseCount 0}}title="Cannot delete tier with active licenses"{{end}}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No product tiers defined. Create one to get started.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Tier Form (hidden by default) -->
|
||||||
|
<div id="new-tier-form" class="ui attached segment" style="display:none">
|
||||||
|
<h5>Create New Tier</h5>
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers" class="ui form">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Key</label>
|
||||||
|
<input type="text" name="tier_key" placeholder="e.g. pos, suite, enterprise" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Name</label>
|
||||||
|
<input type="text" name="tier_name" placeholder="e.g. MokoSuite POS" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Max Domains (0 = unlimited)</label>
|
||||||
|
<input type="number" name="max_domains" value="3" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<input type="number" name="sort_order" value="50" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Repos (comma-separated)</label>
|
||||||
|
<input type="text" name="repos" placeholder="MokoSuite,MokoSuiteCRM,MokoSuiteERP">
|
||||||
|
<p class="help">Enter repo names separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button" type="submit">Create Tier</button>
|
||||||
|
<button class="ui button" type="button" id="btn-cancel-tier">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('btn-new-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = '';
|
||||||
|
this.style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-cancel-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = 'none';
|
||||||
|
document.getElementById('btn-new-tier').style.display = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{template "admin/layout_footer" .}}
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
|
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
|
||||||
{{svg "octicon-paintbrush" 16}} Branding
|
{{svg "octicon-paintbrush" 16}} Branding
|
||||||
</a>
|
</a>
|
||||||
|
<a class="{{if .PageIsAdminLicenseTiers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/license-tiers">
|
||||||
|
{{svg "octicon-key" 16}} License Tiers
|
||||||
|
</a>
|
||||||
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
||||||
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{.Name}}</strong>
|
<strong>{{.Name}}</strong>
|
||||||
|
{{if .IsRequired}}<span class="ui mini blue label" title="Required status - cannot be deleted">{{svg "octicon-lock" 10}} required</span>{{end}}
|
||||||
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
|
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
|
||||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||||
</td>
|
</td>
|
||||||
@@ -40,10 +41,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{.SortOrder}}</td>
|
<td>{{.SortOrder}}</td>
|
||||||
<td class="tw-text-right">
|
<td class="tw-text-right">
|
||||||
|
{{if .IsRequired}}
|
||||||
|
<span class="ui tiny icon button disabled" title="Required - cannot be deleted">{{svg "octicon-lock" 14}}</span>
|
||||||
|
{{else}}
|
||||||
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
|
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
|
||||||
{{$.CsrfTokenHtml}}
|
{{$.CsrfTokenHtml}}
|
||||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
This organization doesn't have a wiki yet.
|
This organization doesn't have a wiki yet.
|
||||||
</div>
|
</div>
|
||||||
<p class="tw-text-center">
|
<p class="tw-text-center">
|
||||||
Enable the wiki on the <code>.profile</code> (public) or <code>.profile-private</code> (members-only)
|
Enable the wiki on the <code>.mokogitea</code> (public) or <code>.mokogitea-private</code> (members-only)
|
||||||
repository to get started.
|
repository to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,34 +47,59 @@
|
|||||||
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
|
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if .Pages}}
|
{{if .WikiTree}}
|
||||||
<h4>Available pages:</h4>
|
<h4>Available pages:</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Pages}}
|
{{range .WikiTree}}
|
||||||
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
{{if .IsDir}}
|
||||||
|
{{range .Children}}
|
||||||
|
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="wiki-content-parts">
|
<div class="wiki-content-parts">
|
||||||
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .Pages}}with-sidebar{{end}}">
|
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
||||||
{{.WikiContent}}
|
{{.WikiContent}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if or .WikiSidebarHTML .Pages}}
|
{{if or .WikiSidebarHTML .WikiTree}}
|
||||||
<div class="render-content markup wiki-content-sidebar">
|
<div class="render-content markup wiki-content-sidebar">
|
||||||
{{if .WikiSidebarHTML}}
|
{{if .WikiSidebarHTML}}
|
||||||
{{.WikiSidebarHTML}}
|
{{.WikiSidebarHTML}}
|
||||||
<div class="ui divider"></div>
|
{{else if .WikiTree}}
|
||||||
{{end}}
|
|
||||||
{{if .Pages}}
|
|
||||||
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
|
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
|
||||||
<ul class="wiki-tree-list">
|
<ul class="wiki-tree-list">
|
||||||
{{range .Pages}}
|
{{range .WikiTree}}
|
||||||
<li>
|
<li>
|
||||||
{{svg "octicon-file" 14}}
|
{{if .IsDir}}
|
||||||
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
<details open>
|
||||||
|
<summary>{{svg "octicon-file-directory" 14}} <strong>{{.Name}}</strong></summary>
|
||||||
|
{{if .Children}}
|
||||||
|
<ul>
|
||||||
|
{{range .Children}}
|
||||||
|
<li>
|
||||||
|
{{if .IsDir}}
|
||||||
|
{{svg "octicon-file-directory" 14}}
|
||||||
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{else}}
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</details>
|
||||||
|
{{else}}
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -21,11 +21,7 @@
|
|||||||
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
|
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="four fields">
|
<div class="three fields">
|
||||||
<div class="field">
|
|
||||||
<label>Version</label>
|
|
||||||
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Version Prefix</label>
|
<label>Version Prefix</label>
|
||||||
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
|
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row">
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to {{.title}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{svg "octicon-cross-reference" 20}} What links here: {{.title}}</h2>
|
||||||
|
|
||||||
|
{{if .Backlinks}}
|
||||||
|
<div class="ui relaxed divided list">
|
||||||
|
{{range .Backlinks}}
|
||||||
|
<div class="item">
|
||||||
|
<div class="content">
|
||||||
|
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="description">
|
||||||
|
<code class="tw-text-sm">{{.Context}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="tw-mt-4 text grey">{{.BacklinkCount}} {{if eq .BacklinkCount 1}}page{{else}}pages{{end}} linking here.</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-unlink" 48}}
|
||||||
|
<br>
|
||||||
|
No pages link to "{{.title}}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{svg "octicon-tag" 20}} Category: {{.CategoryName}}</h2>
|
||||||
|
|
||||||
|
{{if .CategoryPages}}
|
||||||
|
<div class="ui relaxed divided list">
|
||||||
|
{{range .CategoryPages}}
|
||||||
|
<div class="item">
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="tw-mt-4 text grey">{{.CategoryCount}} {{if eq .CategoryCount 1}}page{{else}}pages{{end}} in this category.</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-tag" 48}}
|
||||||
|
<br>
|
||||||
|
No pages in category "{{.CategoryName}}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<a href="{{.RepoLink}}/wiki/{{.PageURL}}">{{svg "octicon-arrow-left" 14}} {{.title}}</a>
|
||||||
|
·
|
||||||
|
<a href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision">Revision history</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui segment">
|
||||||
|
<h3>{{svg "octicon-diff" 20}} Changes in <code>{{.CommitID}}</code></h3>
|
||||||
|
<p>
|
||||||
|
<strong>{{.CommitAuthor}}</strong> — {{.CommitMessage}}
|
||||||
|
<br>
|
||||||
|
<small class="text grey">{{DateUtils.TimeSince .CommitWhen}}</small>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .IsNewPage}}
|
||||||
|
<div class="ui info message">New page created</div>
|
||||||
|
{{else if .IsDeletedPage}}
|
||||||
|
<div class="ui warning message">Page deleted</div>
|
||||||
|
{{else if not .HasDiff}}
|
||||||
|
<div class="ui info message">No content changes in this revision</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasDiff}}
|
||||||
|
<div class="diff-file-box" style="overflow-x: auto;">
|
||||||
|
<table class="chroma" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 13px;">
|
||||||
|
{{range .DiffLines}}
|
||||||
|
<tr class="{{if eq .Type "add"}}diff-line-add{{else if eq .Type "del"}}diff-line-del{{else}}diff-line-context{{end}}">
|
||||||
|
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
|
||||||
|
{{if .OldNum}}{{.OldNum}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
|
||||||
|
{{if .NewNum}}{{.NewNum}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0 8px; white-space: pre-wrap; word-break: break-all; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #fff;{{end}}">
|
||||||
|
{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}} {{.Content}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%; }
|
||||||
|
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||||
|
th { background: #f6f8fa; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
|
||||||
|
a { color: #0366d6; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
{{.WikiContentHTML}}
|
||||||
|
<hr>
|
||||||
|
<p style="font-size: 12px; color: #999;">
|
||||||
|
Printed from wiki · {{.Title}}
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<h2>{{svg "octicon-history" 20}} Recent changes</h2>
|
||||||
|
</div>
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .RecentChanges}}
|
||||||
|
<table class="ui compact table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Page</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Edit summary</th>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Commit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .RecentChanges}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{if .PageURL}}
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
|
||||||
|
{{else if .PageName}}
|
||||||
|
{{svg "octicon-file" 14}} {{.PageName}}
|
||||||
|
{{else}}
|
||||||
|
<span class="text grey">—</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.Author}}</td>
|
||||||
|
<td class="gt-ellipsis" style="max-width: 400px;">{{.Message}}</td>
|
||||||
|
<td>{{DateUtils.TimeSince .When}}</td>
|
||||||
|
<td><code class="tw-text-xs">{{.SHA}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-justify-between tw-mt-4">
|
||||||
|
{{if .HasPrevPage}}
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "-" 1}}">
|
||||||
|
{{svg "octicon-chevron-left" 14}} Newer
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<span></span>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasNextPage}}
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "+" 1}}">
|
||||||
|
Older {{svg "octicon-chevron-right" 14}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-history" 48}}
|
||||||
|
<br>
|
||||||
|
No recent changes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row">
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{svg "octicon-search" 20}} Wiki search</h2>
|
||||||
|
|
||||||
|
<form class="ui form tw-mb-4" action="{{.RepoLink}}/wiki/" method="get">
|
||||||
|
<input type="hidden" name="action" value="_search">
|
||||||
|
<div class="ui action input tw-w-full">
|
||||||
|
<input type="text" name="q" value="{{.Query}}" placeholder="Search wiki pages..." autofocus>
|
||||||
|
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}} Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .Query}}
|
||||||
|
{{if .Results}}
|
||||||
|
<div class="ui relaxed divided list">
|
||||||
|
{{range .Results}}
|
||||||
|
<div class="item">
|
||||||
|
<div class="content">
|
||||||
|
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="description">
|
||||||
|
<code class="tw-text-sm">{{.Context}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="tw-mt-4 text grey">{{.ResultCount}} {{if eq .ResultCount 1}}result{{else}}results{{end}} for "{{.Query}}"</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-search" 48}}
|
||||||
|
<br>
|
||||||
|
No results for "{{.Query}}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scrolling menu">
|
<div class="scrolling menu">
|
||||||
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
|
||||||
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_search">{{svg "octicon-search" 14}} Search wiki</a>
|
||||||
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_recent">{{svg "octicon-history" 14}} Recent changes</a>
|
||||||
|
t <a class="item muted" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_print" target="_blank">{{svg "octicon-browser" 14}} Print view</a>
|
||||||
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_export&format=zip">{{svg "octicon-download" 14}} Export wiki (ZIP)</a>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
{{range .Pages}}
|
{{range .Pages}}
|
||||||
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
||||||
@@ -34,6 +38,8 @@
|
|||||||
<div class="flex-text-block tw-flex-wrap tw-justify-end">
|
<div class="flex-text-block tw-flex-wrap tw-justify-end">
|
||||||
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
|
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
|
||||||
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
|
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
|
||||||
|
<a class="ui basic button tw-px-3" title="What links here" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_backlinks">{{svg "octicon-cross-reference"}}</a>
|
||||||
|
{{if .LastCommitID}}<a class="ui basic button tw-px-3" title="View last change" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_diff&commit={{.LastCommitID}}">{{svg "octicon-diff"}}</a>{{end}}
|
||||||
<div class="tw-flex-1 gt-ellipsis">
|
<div class="tw-flex-1 gt-ellipsis">
|
||||||
{{$title}}
|
{{$title}}
|
||||||
<div class="ui sub header gt-ellipsis">
|
<div class="ui sub header gt-ellipsis">
|
||||||
@@ -47,7 +53,7 @@
|
|||||||
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
|
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
|
||||||
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
|
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
|
{{if and .CanWriteWiki (not .Repository.IsMirror) (not .WikiFolderProtected)}}
|
||||||
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
|
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
|
||||||
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
|
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
|
||||||
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
|
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
|
||||||
@@ -69,6 +75,12 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .WikiFolderProtected}}
|
||||||
|
<div class="ui warning message">
|
||||||
|
<p>{{svg "octicon-lock" 14}} This page is in a protected folder. Only users with the required role can edit it.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .FormatWarning}}
|
{{if .FormatWarning}}
|
||||||
<div class="ui negative message">
|
<div class="ui negative message">
|
||||||
<p>{{.FormatWarning}}</p>
|
<p>{{.FormatWarning}}</p>
|
||||||
@@ -103,13 +115,30 @@
|
|||||||
<div class="wiki-content-parts">
|
<div class="wiki-content-parts">
|
||||||
{{if .WikiSidebarTocHTML}}
|
{{if .WikiSidebarTocHTML}}
|
||||||
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
|
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
|
||||||
{{.WikiSidebarTocHTML}}
|
<details open>
|
||||||
|
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
|
||||||
|
{{.WikiSidebarTocHTML}}
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
||||||
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
|
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
|
||||||
|
{{if .WikiInlineTocHTML}}
|
||||||
|
<details open class="wiki-toc-inline tw-mb-4">
|
||||||
|
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
|
||||||
|
{{.WikiInlineTocHTML}}
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
{{.WikiContentHTML}}
|
{{.WikiContentHTML}}
|
||||||
|
{{if .WikiCategories}}
|
||||||
|
<div class="tw-mt-4 tw-pt-2" style="border-top: 1px solid var(--color-secondary);">
|
||||||
|
{{svg "octicon-tag" 14}} Categories:
|
||||||
|
{{range .WikiCategories}}
|
||||||
|
<a class="ui small label" href="{{$.RepoLink}}/wiki/?action=_category&name={{.}}">{{.}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .WikiTree}}
|
{{if .WikiTree}}
|
||||||
@@ -121,6 +150,7 @@
|
|||||||
{{if .IsDir}}
|
{{if .IsDir}}
|
||||||
{{svg "octicon-file-directory" 14}}
|
{{svg "octicon-file-directory" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
|
||||||
{{if .Children}}
|
{{if .Children}}
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Children}}
|
{{range .Children}}
|
||||||
@@ -128,6 +158,7 @@
|
|||||||
{{if .IsDir}}
|
{{if .IsDir}}
|
||||||
{{svg "octicon-file-directory" 14}}
|
{{svg "octicon-file-directory" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{svg "octicon-file" 14}}
|
{{svg "octicon-file" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
|||||||
@@ -86,3 +86,34 @@
|
|||||||
max-width: unset;
|
max-width: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wikilinks: red links for non-existent pages */
|
||||||
|
.wiki .wiki-link-new {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki .wiki-link-new:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wiki inline ToC */
|
||||||
|
.wiki .wiki-toc-inline {
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-box-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki .wiki-toc-inline summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky sidebar ToC */
|
||||||
|
.wiki .wiki-content-toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user