Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6ca172f6 | |||
| 5ca1c888c0 | |||
| 8e0388c9d8 | |||
| cd4c701cb6 | |||
| b72f88e78b | |||
| 1935889f6b | |||
| 9ebe1b26b1 | |||
| 3aec6c2cae | |||
| 539619be2f | |||
| 6bd9548b2a | |||
| 5665bc545e | |||
| df58aacc30 | |||
| 6575d3fce2 | |||
| d553c87a9d | |||
| 178e8fffe2 | |||
| 2f767e91cb | |||
| 5c22bb04b5 | |||
| d6d0d5a11f | |||
| c948696488 | |||
| 635a13d277 | |||
| 6be3e5c879 | |||
| a86a9afb1a | |||
| 5a80b8da33 | |||
| 0b21fe859e | |||
| 4ec0db8658 | |||
| 10e76cf033 | |||
| a5bda0f9a6 | |||
| a053126bd9 | |||
| 9fff67ab57 | |||
| 3eb649a1a6 | |||
| f091f4cab3 | |||
| 0de02fdce5 | |||
| 06f8ab3d1a | |||
| 3918e8ef9a | |||
| 480aaa088a | |||
| f0aa2c3034 | |||
| 01d38e13f9 | |||
| 04338fe159 | |||
| 0389410efc | |||
| ddababa6fa | |||
| 3e156e8307 | |||
| 546245c9bb | |||
| 26bb906a96 | |||
| 6509bd1eb7 | |||
| 6ac7c0c774 | |||
| 53c86c9b17 | |||
| 4a687a9438 | |||
| 0187f9814f | |||
| 92ca601aa6 | |||
| e866d16ee6 | |||
| 5a1772b026 | |||
| 969015a87a | |||
| ee20006b15 | |||
| ce38bab2cf | |||
| aba8021344 | |||
| 7cbbfb7505 | |||
| 7f45e98630 | |||
| 74194f4283 | |||
| e4ea1303ea | |||
| 7d6cc3152d | |||
| 42b0ff182c | |||
| 32d5a292c7 | |||
| 33ba1159c3 | |||
| cd45824a0d | |||
| bab1acdfe3 | |||
| e59837b250 | |||
| 5c1e1cc8cc | |||
| 6ea5dd37aa | |||
| 099f30d05b | |||
| cbaf289657 | |||
| 619295f469 | |||
| eced91be74 | |||
| 902e3b5edd | |||
| 95d4259a26 | |||
| 64dc6f28fa | |||
| cea3418894 | |||
| b6671ee1f9 | |||
| a52835b8ee | |||
| c64bafbe80 | |||
| e088589af7 | |||
| c90edc3efc | |||
| dda0f6d4ed | |||
| 21fb789d3c | |||
| 558bf37fce | |||
| 746f1a5a50 | |||
| 9cceb5da0b | |||
| fb5002d317 | |||
| f2482a712c | |||
| 349a326881 | |||
| 7dc598104b | |||
| 402166589b | |||
| 6c9a26ebd3 | |||
| c15582aa64 | |||
| 03dee5af39 | |||
| 2827fa0a4c | |||
| 979d6f5964 | |||
| 396220368f | |||
| 877f39d4f4 | |||
| 0492ea399e | |||
| 53b2d5b754 | |||
| 5db84e3932 | |||
| 02cb4ae1a1 | |||
| 75f05e1c80 | |||
| 23eb52cafb | |||
| 7f2aaa84bd | |||
| 6f16459e13 | |||
| 5cf91a12bc | |||
| c7d8f6066f | |||
| 6b0ec5196a | |||
| 8741096fb4 | |||
| 0ab3b7dbd7 | |||
| 02495327ee | |||
| 6f5c40716d | |||
| e5aa0c343d | |||
| ab3a65abdf | |||
| 1eff03ab21 | |||
| e3e2cb4543 | |||
| a15139f70b | |||
| ba0d180e39 | |||
| abf961dd1e | |||
| b34381e8da | |||
| f9653411a7 | |||
| 44107d6485 | |||
| 82c3c11053 | |||
| cd2e8b4d34 | |||
| acf9b4a4da | |||
| 23af404ae4 | |||
| ff6d1bf3c9 | |||
| a5b4f24b48 | |||
| 9832f8a7bb | |||
| 9506a19ab8 | |||
| f485f14615 | |||
| d0e3b3dfd8 | |||
| 3231ac2707 | |||
| b2d2a3b622 | |||
| 963fa6d384 | |||
| ba9907ba41 | |||
| 48ff05d4b3 | |||
| 70699b4f2a | |||
| 99f5833c25 | |||
| 241596361e | |||
| b1b64a3b4e | |||
| 3cddb46053 | |||
| 0a0cc16528 | |||
| da41d7072f | |||
| 6405163e60 | |||
| cb3817f5bc | |||
| 01011f6115 | |||
| f657f58fbb | |||
| ea10e8500c | |||
| 1709566fa6 | |||
| 92bd3f7dc0 | |||
| 95c136d838 | |||
| 89fcbda623 | |||
| dd6ee750f0 | |||
| 48f32ae961 | |||
| ffb9363e3e | |||
| dce87fcb5d | |||
| a1ceac6396 | |||
| 7004170d64 | |||
| a22fa57ab1 | |||
| c045c6abfc | |||
| 68736c78a1 | |||
| ce35e3a603 | |||
| cfea80d3ca | |||
| 3e4cb4d2e5 | |||
| e2c738a8d8 | |||
| ba361c609f | |||
| 6c7a6e4061 | |||
| 7aaf8dcbb7 | |||
| 95d93da2bc | |||
| 128b120ad9 | |||
| 02424c3f75 | |||
| 3f817babd3 | |||
| 449af83e2b | |||
| 6290ff07e4 | |||
| 3ad37e48e1 | |||
| c4e51ff55c | |||
| 021a054348 | |||
| b707c5aff9 | |||
| ead620daf9 | |||
| 2db1f4eaf6 | |||
| 0add8bda72 | |||
| 25499fb183 | |||
| bd81616432 | |||
| 7c15301228 | |||
| 02f3ed88f1 | |||
| e4718f5036 | |||
| 0fb0aea719 | |||
| 581bfa5f31 | |||
| eca929f680 | |||
| 8ae663e15e | |||
| b65b155446 | |||
| 4bc962adbf | |||
| de52ad0fbc | |||
| ca841716db | |||
| 1dfa5d8079 | |||
| 117daf51c3 | |||
| 70793075fc | |||
| a2e0735a26 | |||
| 2799558040 | |||
| 1a46a8f14f | |||
| d85ae6aa21 | |||
| b18519e8b9 | |||
| 94649efed0 | |||
| a52ac1bf61 | |||
| 5da4b3b314 | |||
| 75e2a21b89 | |||
| e82fe7d021 | |||
| 24a9bfb30d | |||
| 257908e083 | |||
| 2c3aad51af | |||
| 66a6a2afc1 | |||
| 74935e3bed | |||
| bc95ecf4d5 | |||
| a35fb4695c | |||
| 70c31a4953 | |||
| 6c913abbda | |||
| 878671ebc9 | |||
| c7cfcf894b | |||
| bbe3e570fe | |||
| 26bbe690fd | |||
| bfa9043bc8 | |||
| b1a9b09f5b |
@@ -4,7 +4,7 @@
|
||||
<name>MokoGitea</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
|
||||
<version>05.14.00</version>
|
||||
<version>05.31.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
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)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/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" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
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 || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev 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 }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# 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 /tmp/moko-platform-api/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
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 05.14.00
|
||||
# VERSION: 05.31.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+508
-236
@@ -1,236 +1,508 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://code.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://code.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
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
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)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found in source files"
|
||||
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.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."
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
# ============================================================================
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# This file is part of a Moko Consulting project.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .mokogitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
access_check:
|
||||
name: Access control
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
allowed: ${{ steps.perm.outputs.allowed }}
|
||||
permission: ${{ steps.perm.outputs.permission }}
|
||||
|
||||
steps:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ALLOWED=false
|
||||
PERMISSION=unknown
|
||||
METHOD=""
|
||||
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller|gitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
;;
|
||||
*)
|
||||
# Detect platform and check permissions via API
|
||||
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
|
||||
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
|
||||
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
|
||||
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
|
||||
ALLOWED=true
|
||||
fi
|
||||
METHOD="collaborator API"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Access Authorization"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|-------|-------|"
|
||||
echo "| **Actor** | \`${ACTOR}\` |"
|
||||
echo "| **Repository** | \`${REPO}\` |"
|
||||
echo "| **Permission** | \`${PERMISSION}\` |"
|
||||
echo "| **Method** | ${METHOD} |"
|
||||
echo "| **Authorized** | ${ALLOWED} |"
|
||||
echo ""
|
||||
if [ "$ALLOWED" = "true" ]; then
|
||||
echo "${ACTOR} authorized (${METHOD})"
|
||||
else
|
||||
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Deny execution when not permitted
|
||||
if: ${{ steps.perm.outputs.allowed != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scripts folder checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes scripts governance'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${SCRIPT_DIR}" ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' 'Status: OK (advisory)'
|
||||
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
unapproved_dirs=()
|
||||
|
||||
for d in "${required_dirs[@]}"; do
|
||||
req="${d%/}"
|
||||
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
|
||||
done
|
||||
|
||||
while IFS= read -r d; do
|
||||
allowed=false
|
||||
for a in "${allowed_dirs[@]}"; do
|
||||
a_norm="${a%/}"
|
||||
[ "${d%/}" = "${a_norm}" ] && allowed=true
|
||||
done
|
||||
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
|
||||
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
|
||||
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Area | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
|
||||
else
|
||||
printf '%s\n' '| Required directories | OK | All required subfolders present |'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
|
||||
else
|
||||
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
|
||||
fi
|
||||
|
||||
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
|
||||
printf '\n'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Missing required script directories:'
|
||||
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Missing required script directories: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Unapproved script directories detected:'
|
||||
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Unapproved script directories detected: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Scripts governance completed in advisory mode.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
repo_health:
|
||||
name: Repository health
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Repository health checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes repository health'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
[ ! -d "${d}" ] && missing_required+=("${item}")
|
||||
else
|
||||
[ ! -f "${item}" ] && missing_required+=("${item}")
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
[ ! -d "${d}" ] && missing_optional+=("${f}")
|
||||
else
|
||||
[ ! -f "${f}" ] && missing_optional+=("${f}")
|
||||
fi
|
||||
done
|
||||
|
||||
for d in "${disallowed_dirs[@]}"; do
|
||||
d_norm="${d%/}"
|
||||
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
|
||||
done
|
||||
|
||||
for f in "${disallowed_files[@]}"; do
|
||||
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
|
||||
done
|
||||
|
||||
git fetch origin --prune
|
||||
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
dev_branches+=("${name}")
|
||||
else
|
||||
dev_paths+=("${name}")
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev or dev/* branch")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
|
||||
fi
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
|
||||
fi
|
||||
|
||||
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
|
||||
content_warnings+=("LICENSE does not look like a GPL text")
|
||||
fi
|
||||
|
||||
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
|
||||
content_warnings+=("README.md missing expected brand keyword")
|
||||
fi
|
||||
|
||||
export PROFILE_RAW="${profile}"
|
||||
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Metric | Value |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
|
||||
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
|
||||
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
|
||||
printf '\n'
|
||||
|
||||
printf '%s\n' '### Guardrails report (JSON)'
|
||||
printf '%s\n' '```json'
|
||||
printf '%s\n' "${report_json}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_required[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repo artifacts'
|
||||
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repo artifacts'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#content_warnings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Repo content warnings'
|
||||
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# -- Joomla-specific checks --
|
||||
joomla_findings=()
|
||||
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
if [ ! -f 'updates.xml' ]; then
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCE_DIR}" ]; then
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' '| Check | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
for f in "${joomla_findings[@]}"; do
|
||||
printf '%s\n' "| ${f} | Warning |"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' 'All Joomla-specific checks passed.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
extended_enabled="${EXTENDED_CHECKS:-true}"
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
|
||||
{
|
||||
printf '%s\n' '### Workflow pinning advisory'
|
||||
printf '%s\n' 'Found uses: entries pinned to main/master:'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${bad_refs}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links=""
|
||||
while IFS= read -r docline; do
|
||||
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||
linkpath="${link%%#*}"
|
||||
linkpath="${linkpath%%\?*}"
|
||||
[ -z "$linkpath" ] && continue
|
||||
if [ "${linkpath:0:1}" = "/" ]; then
|
||||
testpath="${linkpath#/}"
|
||||
else
|
||||
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||
fi
|
||||
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||
done
|
||||
done < "${DOCS_INDEX}"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
for bl in ${missing_links}; do
|
||||
printf '%s\n' "- ${bl}"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y shellcheck >/dev/null
|
||||
fi
|
||||
|
||||
sc_out=''
|
||||
while IFS= read -r shf; do
|
||||
[ -z "${shf}" ] && continue
|
||||
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
|
||||
if [ -n "${out_one}" ]; then
|
||||
sc_out="${sc_out}${out_one}\n"
|
||||
fi
|
||||
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
|
||||
|
||||
if [ -n "${sc_out}" ]; then
|
||||
extended_findings+=("ShellCheck warnings detected (advisory)")
|
||||
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
|
||||
{
|
||||
printf '%s\n' '### ShellCheck (advisory)'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${sc_head}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "${f}" ] && continue
|
||||
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
|
||||
spdx_missing+=("${f}")
|
||||
fi
|
||||
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
|
||||
|
||||
if [ "${#spdx_missing[@]}" -gt 0 ]; then
|
||||
extended_findings+=("SPDX header missing in some tracked files (advisory)")
|
||||
{
|
||||
printf '%s\n' '### SPDX header advisory'
|
||||
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
|
||||
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
stale_cutoff_days=180
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
printf '%s\n' '### Git hygiene advisory'
|
||||
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
|
||||
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Guardrails coverage matrix'
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
|
||||
else
|
||||
printf '%s\n' '| Extended checks | OK | No findings |'
|
||||
fi
|
||||
else
|
||||
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
|
||||
fi
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Extended findings (advisory)'
|
||||
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
site-health:
|
||||
name: Site Health
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
|
||||
- name: Uptime check
|
||||
if: env.URLS != ''
|
||||
run: |
|
||||
echo "$URLS" > /tmp/urls.txt
|
||||
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
|
||||
rm -f /tmp/urls.txt
|
||||
env:
|
||||
URLS: ${{ vars.MONITORED_URLS }}
|
||||
|
||||
- name: SSL certificate check
|
||||
if: env.DOMAINS != ''
|
||||
run: |
|
||||
echo "$DOMAINS" > /tmp/domains.txt
|
||||
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
|
||||
rm -f /tmp/domains.txt
|
||||
env:
|
||||
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
+95
-496
@@ -1,72 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
## [v1.26.1-moko.06.02.00] - 2026-06-02
|
||||
All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.03`).
|
||||
|
||||
## [v1.26.1-moko.06.03] - 2026-06-04
|
||||
|
||||
* FEATURES
|
||||
* feat(licenses): full commercial license management system
|
||||
* Package archiving with soft-delete and collapsible archived section
|
||||
* Search keys by customer, domain, key number, email, or payment ref
|
||||
* Download gating (none/prerelease/all modes)
|
||||
* Feed visibility (public/no-download/hidden modes)
|
||||
* Domain lock grace period (DomainLockHours)
|
||||
* Domain restriction on packages and keys (comma-separated allowed domains)
|
||||
* RepoScope enforcement — packages scoped to specific repos
|
||||
* Joomla changelog XML endpoint (/changelog.xml)
|
||||
* WordPress PUC-compatible update feed (/updates/wordpress.json)
|
||||
* SHA256 checksums from sidecar files in Joomla updates.xml
|
||||
* Joomla-standard tag values (dev/alpha/beta/rc/stable)
|
||||
* Configurable license key prefix per organization
|
||||
* Master key auto-generates, sorts first in key list
|
||||
* License package creation at repo level via modal
|
||||
* Key generation modal with licensee name, email, and domain fields
|
||||
* Manual release-to-stream mapping with UI selector
|
||||
* Double confirmation modals for permanent deletion
|
||||
* Combolist channel picker (replaces checkboxes)
|
||||
* Extension metadata in repo settings (per-repo override)
|
||||
* API: package CRUD, key revoke, key renew, settings GET/PUT
|
||||
* API: purchase webhook with PaymentRef idempotency
|
||||
* API: public validation endpoint (no auth)
|
||||
* Migration v340: all new columns synced
|
||||
* feat(updates): infourl defaults to release listing page
|
||||
* feat(updates): downloadkey prefix matches Akeeba pattern (dlid=)
|
||||
* fix(licenses): expanded delete permissions to org owners + site admins
|
||||
* fix(licenses): no-download mode shows release notes but hides files
|
||||
* fix(licenses): releases require login in hidden feed visibility mode
|
||||
* Migration v340-v344: all new columns synced
|
||||
* feat(updates): Update Server system (renamed from "Licensing")
|
||||
* Joomla XML with SHA256, changelog URL, version from asset filename
|
||||
* Dolibarr JSON with channel filtering
|
||||
* WordPress PUC-compatible JSON (plugin-update-checker)
|
||||
* Composer packages.json
|
||||
* PrestaShop module update XML
|
||||
* Drupal update status XML
|
||||
* WHMCS module update JSON
|
||||
* Feed always public — downloads gated separately
|
||||
* Stream-name tags supported alongside version tags
|
||||
* Omit `<client>` for package extension types
|
||||
* `<downloadkey>` only when download_gating is prerelease or all
|
||||
* Version extracted from asset filename (matches actual download)
|
||||
* Joomla tag values verified: dev, alpha, beta, rc, stable
|
||||
* feat(orgs): enterprise sub-org hierarchy with parent-child relationships
|
||||
* feat(repos): three-level visibility — Public (200), Private (403), Hidden (404)
|
||||
* feat(settings): Update Server settings page with enable toggle in Advanced Settings
|
||||
* feat(settings): advanced settings on dedicated page with dividing headers
|
||||
* feat(settings): icons on all settings navbars (repo, org, user, admin)
|
||||
* feat(ui): styled 403 Access Denied page with inline login form
|
||||
* feat(issues): custom fields with inline editing in issue sidebar
|
||||
* feat(issues): pre-fill custom fields from issue template YAML frontmatter (#493)
|
||||
* Templates specify `custom_fields:` map (field name → default value)
|
||||
* New issue sidebar shows org-level fields with template defaults pre-selected
|
||||
* API create issue accepts `custom_fields` map by name
|
||||
* feat(updateserver): resolve extension metadata from org-level custom fields (#492)
|
||||
* Cascading fallback: custom fields → config table → repo-derived defaults
|
||||
* All six generators updated (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS)
|
||||
* Repos can be migrated to custom fields gradually
|
||||
* feat(ui): two-in-one Update Server / Licenses tab
|
||||
* No gating: shows "Update Server" tab with feed URLs only
|
||||
* Gated: shows "Licenses" tab with full key management
|
||||
* `<downloadkey>` only appears when downloads are gated
|
||||
* SECURITY
|
||||
* fix(security): ownership guards on all API handlers (cross-org prevention)
|
||||
* fix(security): RepoScope JSON parsing (substring matching bug)
|
||||
* fix(security): CSRF tokens in delete confirmation modals
|
||||
* fix(security): XSS escaping in WordPress changelog HTML
|
||||
* fix(security): require login for licenses and actions pages
|
||||
* fix(security): 403 for all users on private repos (not 404)
|
||||
* fix(security): licensed private repos allow release viewing for signed-in users
|
||||
* fix(security): anonymous download access respects download_gating setting
|
||||
* FIXES
|
||||
* fix(licenses): explicit xorm column names for UpdateStreamConfig fields
|
||||
* fix(licenses): feed always public when licensing enabled
|
||||
* fix(settings): prevent double-highlight on Advanced Settings nav item
|
||||
* fix(settings): redirect back to /settings/advanced after save
|
||||
* fix(build): remove stale custom field API routes and dead code
|
||||
* fix(build): replace invalid UTF-8 character in API comment
|
||||
* fix(build): permanent fixes for AI migration, feed/file.go, unused imports
|
||||
* fix(updateserver): version extracted from asset filename (not release title)
|
||||
* fix(updateserver): omit `<client>` for package types per Joomla spec
|
||||
* fix(updateserver): `<downloadkey>` only shown when downloads are gated
|
||||
* fix(updateserver): prevent stream name tag from overriding asset-derived version
|
||||
* fix(build): restore build/ directory after accidental deletion
|
||||
* fix(licenses): master key banner removed, master keys sort first in table
|
||||
* fix(issues): issue sidebar loads org-level fields instead of legacy repo-level fields
|
||||
|
||||
## [v1.26.1-moko.05.15.00] - 2026-05-31
|
||||
## [v1.26.1-moko.05] - 2026-05-31
|
||||
|
||||
* BREAKING CHANGES
|
||||
* Deprecated Issue.Ref branch selector UI (#307)
|
||||
* Removed branch/tag selector from issue sidebar and new issue form
|
||||
* Removed ref badge from issue lists
|
||||
* Removed POST /ref web route and UpdateIssueRef handler
|
||||
* DB column and commit-close logic preserved for backward compatibility
|
||||
* API create/edit still accept `ref` field (no-op) for backward compat
|
||||
* FEATURES
|
||||
* feat(ui): add generic combo-multiselect component (#361)
|
||||
* feat(ui): generic combo-multiselect component (#361)
|
||||
* Reusable dropdown with search, checkable items, and selected-items display
|
||||
* Template: `shared/combolist.tmpl` — accepts Items, Name, Title, SelectedValues
|
||||
* Decoupled from issue sidebar — works in any form context
|
||||
* Template: `shared/combolist.tmpl`
|
||||
* feat(updates): extension metadata settings for update feed generation
|
||||
* feat(licenses): platform enforcement, key deletion, expired key cleanup
|
||||
* feat(licenses): store keys in plaintext, show full key with copy button
|
||||
* feat(actions): rebrand actions bot user to mokogitea-actions (#233, #234)
|
||||
* Backward-compatible: recognizes github-actions[bot], gitea-actions[bot]
|
||||
* feat(actions): actions bot user in branch protection whitelist (#233, #234)
|
||||
* WhitelistActionsUser, MergeWhitelistActionsUser, ForcePushAllowlistActionsUser
|
||||
* TECH DEBT
|
||||
* chore: full namespace migration from git.mokoconsulting.tech to code.mokoconsulting.tech (#336, #337, #344)
|
||||
* Go module path, all imports, template URLs, workflow configs (2,276 files)
|
||||
* chore: full namespace migration to code.mokoconsulting.tech (#336, #337, #344)
|
||||
* fix(blame): set HasSourceRenderedToggle for renderable files (#344)
|
||||
* fix(settings): translate team permission strings via data-locale attributes (#344)
|
||||
* fix(settings): translate team permission strings via data-locale (#344)
|
||||
* fix(dropzone): use relative path for non-image attachment markdown links (#344)
|
||||
* fix(templates): add required validation to issue dropdown fields (#350)
|
||||
* refactor(ts): remove redundant `handled` field from MarkdownHandleIndentionResult (#350)
|
||||
* refactor(go): rename HasOrgOrUserVisible to IsOwnerVisibleToDoer (#350)
|
||||
* refactor(go): replace ValuesRepository with maps.Values (Go 1.21+) (#357)
|
||||
* refactor(go): remove CanEnableEditor wrapper, use CanContentChange directly (#357)
|
||||
* fix(ts): parseIssueHref now uses URL pathname and trims appSubUrl (#360)
|
||||
* fix(actions): enforce MaxJobNumPerRun (256) limit when creating jobs (#360)
|
||||
* refactor(go): remove CanEnableEditor wrapper (#357)
|
||||
* fix(ts): parseIssueHref uses URL pathname and trims appSubUrl (#360)
|
||||
* fix(actions): enforce MaxJobNumPerRun (256) limit (#360)
|
||||
* fix(css): use calc(infinity * 1px) for --border-radius-full (#361)
|
||||
* fix(css): remove legacy .center class from 2015, replace with tw-text-center (#361)
|
||||
* chore: remove stale TODO from OAuth2 regenerate secret (already implemented) (#332)
|
||||
* chore: remove stale pull request test stub TODOs (#328)
|
||||
* chore: remove stale GetProjectsMode TODO
|
||||
* chore: remove stale mustNotBeArchived/mustEnableEditor FIXME from API
|
||||
* fix(routes): remove dead legacy /cherry-pick/{sha} route (replaced by /_cherrypick/)
|
||||
* fix(css): remove legacy .center class, replace with tw-text-center (#361)
|
||||
* fix(routes): remove dead legacy /cherry-pick/{sha} route
|
||||
* fix(feed): use full ref name instead of ShortName for file feed revision
|
||||
* BUGFIXES
|
||||
* fix(build): use slices.Collect for maps.Values (Go 1.23+ compat)
|
||||
@@ -74,25 +119,10 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* fix(licenses): only show licenses tab when licensing is enabled
|
||||
* fix(licenses): show feed URLs based on repo update platform setting
|
||||
* fix(updates): correct dlid prefix and align XML with Joomla standard
|
||||
|
||||
## [v1.26.1-moko.05.06.00] - 2026-05-30
|
||||
|
||||
* FEATURES
|
||||
* feat(actions): rebrand actions bot user to mokogitea-actions (#233, #234)
|
||||
* Name: gitea-actions → mokogitea-actions, FullName: MokoGitea Actions
|
||||
* Email: mokogitea-actions[bot]@mokoconsulting.tech
|
||||
* Backward-compatible: recognizes github-actions[bot], gitea-actions[bot], mokogitea-actions[bot]
|
||||
* feat(actions): add actions bot user to branch protection whitelist (#233, #234)
|
||||
* New toggles: WhitelistActionsUser, MergeWhitelistActionsUser, ForcePushAllowlistActionsUser
|
||||
* Allows CI/CD workflows to push/merge/force-push to protected branches when enabled
|
||||
* DB migration v334 adds the three boolean columns
|
||||
* Exposed in API (create/edit branch protection) and web UI settings
|
||||
* INFRASTRUCTURE
|
||||
* fix(ci): auto-deploy to production on merge to main (#235)
|
||||
* Deploy workflow now triggers on push to main, not just manual dispatch
|
||||
* Version derived from git describe for auto-deploys
|
||||
|
||||
## [v1.26.1-moko.04.00.00] - 2026-05-24
|
||||
## [v1.26.1-moko.04] - 2026-05-24
|
||||
|
||||
* SECURITY
|
||||
* Backport 12 upstream v1.26.2 security fixes:
|
||||
@@ -101,468 +131,37 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* OAuth PKCE hardening and refresh token replay protection (#142)
|
||||
* Wiki git write and LFS token access enforcement (#143)
|
||||
* Public-only token filtering in API queries (#144)
|
||||
* Reading permission fix (#145)
|
||||
* Artifact signature payload hardening (#146)
|
||||
* AWS credentials encryption (#161)
|
||||
* Mermaid v11.15.0 security update (#162)
|
||||
* Composer package permission check (#164)
|
||||
* BUGFIXES
|
||||
* fix(actions): nil pointer dereference in concurrency during PR creation (#136)
|
||||
* fix(ui): actions runs list broken row layout — CSS class mismatch (#138)
|
||||
* fix: scheduled action panic with null event payload (upstream #37459)
|
||||
* fix: treat email addresses case-insensitively (upstream #37600)
|
||||
* fix(ui): actions runs list broken row layout (#138)
|
||||
* fix: scheduled action panic with null event payload
|
||||
* fix: treat email addresses case-insensitively
|
||||
* fix: .mod lexer panic — removed invalid AMPL mapping
|
||||
* fix: remove unused setting import in action.go
|
||||
* fix: restore Permission field access in context middleware
|
||||
* FEATURES
|
||||
* Joomla-style updates.xml with channel selection (stable/dev/security/rc)
|
||||
* Update checker reads from updates.xml with configurable CHANNEL setting
|
||||
* Admin dashboard shows update banner with channel name and docker pull command
|
||||
* Upstream bug sync workflow — daily automated issue creation from release/v1.26
|
||||
* Joomla-style updates.xml with channel selection
|
||||
* Update checker with configurable CHANNEL setting
|
||||
* Admin dashboard update banner with docker pull command
|
||||
* Upstream bug sync workflow — daily automated issue creation
|
||||
* PR RC release workflow — auto-build RC on PR to main
|
||||
* INFRASTRUCTURE
|
||||
* New 3-part versioning: v{upstream}-moko.{major}.{minor}.{patch}
|
||||
* Branding updates: error pages, home page, settings link to MokoGitea
|
||||
* Branding updates: error pages, home page, settings link
|
||||
* Deploy workflow updated for new version format
|
||||
* PROCESS
|
||||
* Created `type: bug` and `upstream` labels for automated issue tracking
|
||||
* Deduplicated 19 duplicate feature request issues
|
||||
* Closed 24 upstream bug/security issues after backporting
|
||||
|
||||
## [MokoGitea Unreleased]
|
||||
## [v1.26.1-moko.03] - 2026-05-15
|
||||
|
||||
* FEATURES
|
||||
* feat(api): Bulk issue operations — add/remove/replace labels, close/reopen, set milestone, and set assignees across multiple issues in a single request (#21)
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/labels`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/state`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/milestone`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/assignees`
|
||||
* Partial-failure support: returns per-issue success/failure map
|
||||
* feat(api): Bulk issue operations — add/remove/replace labels, close/reopen, set milestone, assignees (#21)
|
||||
* INFRASTRUCTURE
|
||||
* Grafana: Standardized kiosk header across all 14 playlist dashboards — each now shows dashboard name, kiosk link, terminal/exit/switch instructions
|
||||
* Grafana: Standardized kiosk header across all 14 playlist dashboards
|
||||
* PROCESS
|
||||
* Reopened 9 closed issues lacking documented testing proof (#3, #5, #38, #41, #70, #74, #75, #76, #78)
|
||||
* Reopened 9 closed issues lacking documented testing proof
|
||||
* Created `pending: testing` label for features awaiting verification
|
||||
* Established policy: issues must not be closed without documented testing proof
|
||||
|
||||
## [1.26.1](https://github.com/go-gitea/gitea/releases/tag/v1.26.1) - 2026-04-21
|
||||
|
||||
* BUGFIXES
|
||||
* Add event.schedule context for schedule actions task (#37320) (#37348)
|
||||
* Fix an issue where changing an organization's visibility caused problems when users had forked its repositories. (#37324) (#37344)
|
||||
* Use modern "git update-index --cacheinfo" syntax to support more file names (#37338) (#37343)
|
||||
* Fix URL related escaping for oauth2 (#37334) (#37340)
|
||||
* When the requested arch rpm is missing fall back to noarch (#37236) (#37339)
|
||||
* Fix actions concurrency groups cross-branch leak (#37311) (#37331)
|
||||
* Fix bug when accessing user badges (#37321) (#37329)
|
||||
* Fix AppFullLink (#37325) (#37328)
|
||||
* Fix container auth for public instance (#37290) (#37294)
|
||||
* Enhance GetActionWorkflow to support fallback references (#37189) (#37283)
|
||||
* Fix vite manifest update masking build errors (#37279) (#37310)
|
||||
* Fix Mermaid diagrams failing when node labels contain line breaks (#37296) (#37299)
|
||||
* Use TriggerEvent instead of Event in workflow runs API response for scheduled runs (#37288) #37360
|
||||
* Add URL to Learn more about blocking a user. (#37355) #37367
|
||||
* Fix button layout shift when collapsing file tree in editor (#37363) #37375
|
||||
* Fix org team assignee/reviewer lookups for team member permissions (#37365) #37391
|
||||
* Fix repo init README EOL (#37388) #37399
|
||||
* Fix: dump with default zip type produces uncompressed zip (#37401)#37402
|
||||
|
||||
## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17
|
||||
|
||||
* BREAKING
|
||||
* Correct swagger annotations for enums, status codes, and notification state (#37030)
|
||||
* Remove GET API registration-token (#36801)
|
||||
* Support Actions `concurrency` syntax (#32751)
|
||||
* Make PUBLIC_URL_DETECTION default to "auto" (#36955)
|
||||
* SECURITY
|
||||
* Bound PageSize in `ListUnadoptedRepositories` (#36884)
|
||||
* FEATURES
|
||||
* Support Actions `concurrency` syntax (#32751)
|
||||
* Add terraform state registry (#36710)
|
||||
* Instance-wide (global) info banner and maintenance mode (#36571)
|
||||
* Support rendering OpenAPI spec (#36449)
|
||||
* Add keyboard shortcuts for repository file and code search (#36416)
|
||||
* Add support for archive-upload rpc (#36391)
|
||||
* Add ability to download subpath archive (#36371)
|
||||
* Add workflow dependencies visualization (#26062) (#36248) & Restyle Workflow Graph (#36912)
|
||||
* Automatic generation of release notes (#35977)
|
||||
* Add "Go to file", "Delete Directory" to repo file list page (#35911)
|
||||
* Introduce "config edit-ini" sub command to help maintaining INI config file (#35735)
|
||||
* Add button to re-run failed jobs in Actions (#36924)
|
||||
* Support actions and reusable workflows from private repos (#32562)
|
||||
* Add summary to action runs view (#36883)
|
||||
* Add user badges (#36752)
|
||||
* Add configurable permissions for Actions automatic tokens (#36173)
|
||||
* Add per-runner "Disable/Pause" (#36776)
|
||||
* Feature non-zipped actions artifacts (action v7 / nodejs / npm v6.2.0) (#36786)
|
||||
* PERFORMANCE
|
||||
* WorkflowDispatch API optionally return runid (#36706)
|
||||
* Add render cache for SVG icons (#36863)
|
||||
* Load `mentionValues` asynchronously (#36739)
|
||||
* Lazy-load some Vue components, fix heatmap chunk loading on every page (#36719)
|
||||
* Load heatmap data asynchronously (#36622)
|
||||
* Use prev/next pagination for user profile activities page to speed up (#36642)
|
||||
* Refactor cat-file batch operations and support `--batch-command` approach (#35775)
|
||||
* Use merge tree to detect conflicts when possible (#36400)
|
||||
* ENHANCEMENTS
|
||||
* Implement logout redirection for reverse proxy auth setups (#36085) (#37171)
|
||||
* Adds option to force update new branch in contents routes (#35592)
|
||||
* Add viewer controller for mermaid (zoom, drag) (#36557)
|
||||
* Add code editor setting dropdowns (#36534)
|
||||
* Add `elk` layout support to mermaid (#36486)
|
||||
* Add resolve/unresolve review comment API endpoints (#36441)
|
||||
* Allow configuring default PR base branch (fixes #36412) (#36425)
|
||||
* Add support for RPM Errata (updateinfo.xml) (#37125)
|
||||
* Require additional user confirmation for making repo private (#36959)
|
||||
* Add `actions.WORKFLOW_DIRS` setting (#36619)
|
||||
* Avoid opening new tab when downloading actions logs (#36740)
|
||||
* Implements OIDC RP-Initiated Logout (#36724)
|
||||
* Show workflow link (#37070)
|
||||
* Desaturate dark theme background colors (#37056)
|
||||
* Refactor "org teams" page and help new users to "add member" to an org (#37051)
|
||||
* Add webhook name field to improve webhook identification (#37025) (#37040)
|
||||
* Make task list checkboxes clickable in the preview tab (#37010)
|
||||
* Improve severity labels in Actions logs and tweak colors (#36993)
|
||||
* Linkify URLs in Actions workflow logs (#36986)
|
||||
* Allow text selection on checkbox labels (#36970)
|
||||
* Support dark/light theme images in markdown (#36922)
|
||||
* Enable native dark mode for swagger-ui (#36899)
|
||||
* Rework checkbox styling, remove `input` border hover effect (#36870)
|
||||
* Refactor storage content-type handling of ServeDirectURL (#36804)
|
||||
* Use "Enable Gravatar" but not "Disable" (#36771)
|
||||
* Use case-insensitive matching for Git error "Not a valid object name" (#36728)
|
||||
* Add "Copy Source" to markup comment menu (#36726)
|
||||
* Change image transparency grid to CSS (#36711)
|
||||
* Add "Run" prefix for unnamed action steps (#36624)
|
||||
* Persist actions log time display settings in `localStorage` (#36623)
|
||||
* Use first commit title for multi-commit PRs and fix auto-focus title field (#36606)
|
||||
* Improve BuildCaseInsensitiveLike with lowercase (#36598)
|
||||
* Improve diff highlighting (#36583)
|
||||
* Exclude cancelled runs from failure-only email notifications (#36569)
|
||||
* Use full-file highlighting for diff sections (#36561)
|
||||
* Color command/error logs in Actions log (#36538)
|
||||
* Add paging headers (#36521)
|
||||
* Improve timeline entries for WIP prefix changes in pull requests (#36518)
|
||||
* Add FOLDER_ICON_THEME configuration option (#36496)
|
||||
* Normalize guessed languages for code highlighting (#36450)
|
||||
* Add chunked transfer encoding support for LFS uploads (#36380)
|
||||
* Indicate when only optional checks failed (#36367)
|
||||
* Add 'allow_maintainer_edit' API option for creating a pull request (#36283)
|
||||
* Support closing keywords with URL references (#36221)
|
||||
* Improve diff file headers (#36215)
|
||||
* Fix and enhance comment editor monospace toggle (#36181)
|
||||
* Add git.DIFF_RENAME_SIMILARITY_THRESHOLD option (#36164)
|
||||
* Add matching pair insertion to markdown textarea (#36121)
|
||||
* Add sorting/filtering to admin user search API endpoint (#36112)
|
||||
* Allow action user have read permission in public repo like other user (#36095)
|
||||
* Disable matchBrackets in monaco (#36089)
|
||||
* Use GitHub-style commit message for squash merge (#35987)
|
||||
* Make composer registry support tar.gz and tar.bz2 and fix bugs (#35958)
|
||||
* Add GITEA_PR_INDEX env variable to githooks (#35938)
|
||||
* Add proper error message if session provider can not be created (#35520)
|
||||
* Add button to copy file name in PR files (#35509)
|
||||
* Move `X_FRAME_OPTIONS` setting from `cors` to `security` section (#30256)
|
||||
* Add placeholder content for empty content page (#37114)
|
||||
* Add `DEFAULT_DELETE_BRANCH_AFTER_MERGE` setting (#36917)
|
||||
* Redirect to the only OAuth2 provider when no other login methods and fix various problems (#36901)
|
||||
* Add admin badge to navbar avatar (#36790)
|
||||
* Add `never` option to `PUBLIC_URL_DETECTION` configuration (#36785)
|
||||
* Add background and run count to actions list page (#36707)
|
||||
* Add icon to buttons "Close with Comment", "Close Pull Request", "Close Issue" (#36654)
|
||||
* Add support for in_progress event in workflow_run webhook (#36979)
|
||||
* Report commit status for pull_request_review events (#36589)
|
||||
* Render merged pull request title as such in dashboard feed (#36479)
|
||||
* Feature to be able to filter project boards by milestones (#36321)
|
||||
* Use user id in noreply emails (#36550)
|
||||
* Enable pagination on GiteaDownloader.getIssueReactions() (#36549)
|
||||
* Remove striped tables in UI (#36509)
|
||||
* Improve control char rendering and escape button styling (#37094)
|
||||
* Support legacy run/job index-based URLs and refactor migration 326 (#37008)
|
||||
* Add date to "No Contributions" tooltip (#36190)
|
||||
* Show edit page confirmation dialog on tree view file change (#36130)
|
||||
* Mention proc-receive in text for dashboard.resync_all_hooks func (#35991)
|
||||
* Reuse selectable style for wiki (#35990)
|
||||
* Support blue yellow colorblind theme (#35910)
|
||||
* Support selecting theme on the footer (#35741)
|
||||
* Improve online runner check (#35722)
|
||||
* Add quick approve button on PR page (#35678)
|
||||
* Enable commenting on expanded lines in PR diffs (#35662)
|
||||
* Print PR-Title into tooltip for actions (#35579)
|
||||
* Use explicit, stronger defaults for newly generated repo signing keys for Debian (#36236)
|
||||
* Improve the compare page (#36261)
|
||||
* Unify repo names in system notices (#36491)
|
||||
* Move package settings to package instead of being tied to version (#37026)
|
||||
* Add Actions API rerun endpoints for runs and jobs (#36768)
|
||||
* Add branch_count to repository API (#35351) (#36743)
|
||||
* Add created_by filter to SearchIssues (#36670)
|
||||
* Allow admins to rename non-local users (#35970)
|
||||
* Support updating branch via API (#35951)
|
||||
* Add an option to automatically verify SSH keys from LDAP (#35927)
|
||||
* Make "update file" API can create a new file when SHA is not set (#35738)
|
||||
* Update issue.go with labels documentation (labels content, not ids) (#35522)
|
||||
* Expose content_version for optimistic locking on issue and PR edits (#37035)
|
||||
* Pass ServeHeaderOptions by value instead of pointer, fine tune httplib tests (#36982)
|
||||
* BUGFIXES
|
||||
* Frontend iframe renderer framework: 3D models, OpenAPI (#37233) (#37273)
|
||||
* Fix CODEOWNERS absolute path matching. (#37244) (#37264)
|
||||
* Swift registry metadata: preserve more JSON fields and accept empty metadata (#37254) (#37261)
|
||||
* Fix user ssh key exporting and tests (#37256) (#37258)
|
||||
* Fix team member avatar size and add tooltip (#37253)
|
||||
* Fix commit title rendering in action run and blame (#37243) (#37251)
|
||||
* Fix corrupted JSON caused by goccy library (#37214) (#37220)
|
||||
* Add test for "fetch redirect", add CSS value validation for external render (#37207) (#37216)
|
||||
* Fix incorrect concurrency check (#37205) (#37215)
|
||||
* Fix handle missing base branch in PR commits API (#37193) (#37203)
|
||||
* Fix encoding for Matrix Webhooks (#37190) (#37201)
|
||||
* Fix handle fork-only commits in compare API (#37185) (#37199)
|
||||
* Indicate form field readonly via background, fix RunUser config (#37175, #37180) (#37178)
|
||||
* Report structurally invalid workflows to users (#37116) (#37164)
|
||||
* Fix API not persisting pull request unit config when has_pull_requests is not set (#36718)
|
||||
* Rename CSS variables and improve colorblind themes (#36353)
|
||||
* Hide `add-matcher` and `remove-matcher` from actions job logs (#36520)
|
||||
* Prevent navigation keys from triggering actions during IME composition (#36540)
|
||||
* Fix vertical alignment of `.commit-sign-badge` children (#36570)
|
||||
* Fix duplicate startup warnings in admin panel (#36641)
|
||||
* Fix CODEOWNERS review request attribution using comment metadata (#36348)
|
||||
* Fix HTML tags appearing in wiki table of contents (#36284)
|
||||
* Fix various bugs (#37096)
|
||||
* Fix various legacy problems (#37092)
|
||||
* Fix RPM Registry 404 when package name contains 'package' (#37087)
|
||||
* Merge some standalone Vite entries into index.js (#37085)
|
||||
* Fix various problems (#37077)
|
||||
* Fix issue label deletion with Actions tokens (#37013)
|
||||
* Hide delete branch or tag buttons in mirror or archived repositories. (#37006)
|
||||
* Fix org contact email not clearable once set (#36975)
|
||||
* Fix a bug when forking a repository in an organization (#36950)
|
||||
* Preserve sort order of exclusive labels from template repo (#36931)
|
||||
* Make container registry support Apple Container (basic auth) (#36920)
|
||||
* Fix the wrong push commits in the pull request when force push (#36914)
|
||||
* Add class "list-header-filters" to the div for projects (#36889)
|
||||
* Fix dbfs error handling (#36844)
|
||||
* Fix incorrect viewed files counter if reverted change was viewed (#36819)
|
||||
* Refactor avatar package, support default avatar fallback (#36788)
|
||||
* Fix README symlink resolution in subdirectories like .github (#36775)
|
||||
* Fix CSS stacking context issue in actions log (#36749)
|
||||
* Add gpg signing for merge rebase and update by rebase (#36701)
|
||||
* Delete non-exist branch should return 404 (#36694)
|
||||
* Fix `TestActionsCollaborativeOwner` (#36657)
|
||||
* Fix multi-arch Docker build SIGILL by splitting frontend stage (#36646)
|
||||
* Fix linguist-detectable attribute being ignored for configuration files (#36640)
|
||||
* Fix state desync in ComboMarkdownEditor (#36625)
|
||||
* Unify DEFAULT_SHOW_FULL_NAME output in templates and dropdown (#36597)
|
||||
* Pull Request Pusher should be the author of the merge (#36581)
|
||||
* Fix various version parsing problems (#36553)
|
||||
* Fix highlight diff result (#36539)
|
||||
* Fix mirror sync parser and fix mirror messages (#36504)
|
||||
* Fix bug when list pull request commits (#36485)
|
||||
* Fix various bugs (#36446)
|
||||
* Fix issue filter menu layout (#36426)
|
||||
* Restrict branch naming when new change matches with protection rules (#36405)
|
||||
* Fix link/origin referrer and login redirect (#36279)
|
||||
* Generate IDs for HTML headings without id attribute (#36233)
|
||||
* Use a migration test instead of a wrong test which populated the meta test repositories and fix a migration bug (#36160)
|
||||
* Fix issue close timeline icon (#36138)
|
||||
* Fix diff blob excerpt expansion (#35922)
|
||||
* Fix external render (#35727)
|
||||
* Fix review request webhook bug (#35339) (#35723)
|
||||
* Fix shutdown waitgroup panic (#35676)
|
||||
* Cleanup ActionRun creation (#35624)
|
||||
* Fix possible bug when migrating issues/pull requests (#33487)
|
||||
* Various fixes (#36697)
|
||||
* Apply notify/register mail flags during install load (#37120)
|
||||
* Repair duration display for bad stopped timestamps (#37121)
|
||||
* Fix(upgrade.sh): use HTTPS for GPG key import and restore SELinux context after upgrade (#36930)
|
||||
* Fix various trivial problems (#36921)
|
||||
* Fix various trivial problems (#36953)
|
||||
* Fix NuGet package upload error handling (#37074)
|
||||
* Fix CodeQL code scanning alerts (#36858)
|
||||
* Refactor issue sidebar and fix various problems (#37045)
|
||||
* Fix various problems (#37029)
|
||||
* Fix relative-time RangeError (#37021)
|
||||
* Fix chroma lexer mapping (#36629)
|
||||
* Fix typos and grammar in English locale (#36751)
|
||||
* Fix milestone/project text overflow in issue sidebar (#36741)
|
||||
* Fix `no-content` message not rendering after comment edit (#36733)
|
||||
* Fix theme loading in development (#36605)
|
||||
* Fix workflow run jobs API returning null steps (#36603)
|
||||
* Fix timeline event layout overflow with long content (#36595)
|
||||
* Fix minor UI issues in runner edit page (#36590)
|
||||
* Fix incorrect vendored detections (#36508)
|
||||
* Fix editorconfig not respected in PR Conversation view (#36492)
|
||||
* Don't create self-references in merged PRs (#36490)
|
||||
* Fix potential incorrect runID in run status update (#36437)
|
||||
* Fix file-tree ui error when adding files to repo without commits (#36312)
|
||||
* Improve image captcha contrast for dark mode (#36265)
|
||||
* Fix panic in blame view when a file has only a single commit (#36230)
|
||||
* Fix spelling error in migrate-storage cmd utility (#36226)
|
||||
* Fix code highlighting on blame page (#36157)
|
||||
* Fix nilnil in onedev downloader (#36154)
|
||||
* Fix actions lint (#36029)
|
||||
* Fix oauth2 session gob register (#36017)
|
||||
* Fix Arch repo pacman.conf snippet (#35825)
|
||||
* Fix a number of `strictNullChecks`-related issues (#35795)
|
||||
* Fix URLJoin, markup render link reoslving, sign-in/up/linkaccount page common data (#36861)
|
||||
* Hide delete directory button for mirror or archive repository and disable the menu item if user have no permission (#36384)
|
||||
* Update message severity colors, fix navbar double border (#37019)
|
||||
* Inline and lazy-load EasyMDE CSS, fix border colors (#36714)
|
||||
* Closed milestones with no issues now show as 100% completed (#36220)
|
||||
* Add test for ExtendCommentTreePathLength migration and fix bugs (#35791)
|
||||
* Only turn links to current instance into hash links (#36237)
|
||||
* Fix typos in code comments: doesnt, dont, wont (#36890)
|
||||
* REFACTOR
|
||||
* Clean up and improve non-gitea js error filter (#37148) (#37155)
|
||||
* Always show owner/repo name in compare page dropdowns (#37172) (#37200)
|
||||
* Remove dead CSS rules (#37173) (#37177)
|
||||
* Replace Monaco with CodeMirror (#36764)
|
||||
* Replace CSRF cookie with `CrossOriginProtection` (#36183)
|
||||
* Replace index with id in actions routes (#36842)
|
||||
* Remove unnecessary function parameter (#35765)
|
||||
* Move jobparser from act repository to Gitea (#36699)
|
||||
* Refactor compare router param parse (#36105)
|
||||
* Optimize 'refreshAccesses' to perform update without removing then adding (#35702)
|
||||
* Clean up checkbox cursor styles (#37016)
|
||||
* Remove undocumented support of signing key in the repository git configuration file (#36143)
|
||||
* Switch `cmd/` to use constructor functions. (#36962)
|
||||
* Use `relative-time` to render absolute dates (#36238)
|
||||
* Some refactors about GetMergeBase (#36186)
|
||||
* Some small refactors (#36163)
|
||||
* Use gitRepo as parameter instead of repopath when invoking sign functions (#36162)
|
||||
* Move blame to gitrepo (#36161)
|
||||
* Move some functions to gitrepo package to reduce RepoPath reference directly (#36126)
|
||||
* Use gitrepo's clone and push when possible (#36093)
|
||||
* Remove mermaid margin workaround (#35732)
|
||||
* Move some functions to gitrepo package (#35543)
|
||||
* Move GetDiverging functions to gitrepo (#35524)
|
||||
* Use global lock instead of status pool for cron lock (#35507)
|
||||
* Use explicit mux instead of DefaultServeMux (#36276)
|
||||
* Use gitrepo's push function (#36245)
|
||||
* Pass request context to generateAdditionalHeadersForIssue (#36274)
|
||||
* Move assign project when creating pull request to the same database transaction (#36244)
|
||||
* Move catfile batch to a sub package of git module (#36232)
|
||||
* Use gitrepo.Repository instead of wikipath (#35398)
|
||||
* Use experimental go json v2 library (#35392)
|
||||
* Refactor template render (#36438)
|
||||
* Refactor GetRepoRawDiffForFile to avoid unnecessary pipe or goroutine (#36434)
|
||||
* Refactor text utility classes to Tailwind CSS (#36703)
|
||||
* Refactor git command stdio pipe (#36422)
|
||||
* Refactor git command context & pipeline (#36406)
|
||||
* Refactor git command stdio pipe (#36393)
|
||||
* Remove unused functions (#36672)
|
||||
* Refactor Actions Token Access (#35688)
|
||||
* Move commit related functions to gitrepo package (#35600)
|
||||
* Move archive function to repo_model and gitrepo (#35514)
|
||||
* Move some functions to gitrepo package (#35503)
|
||||
* Use git model to detect whether branch exist instead of gitrepo method (#35459)
|
||||
* Some refactor for repo path (#36251)
|
||||
* Extract helper functions from SearchIssues (#36158)
|
||||
* Refactor merge conan and container auth preserve actions taskID (#36560)
|
||||
* Refactor Nuget Auth to reuse Basic Auth Token Validation (#36558)
|
||||
* Refactor ActionsTaskID (#36503)
|
||||
* Refactor auth middleware (#36848)
|
||||
* Refactor code render and render control chars (#37078)
|
||||
* Clean up AppURL, remove legacy origin-url webcomponent (#37090)
|
||||
* Remove `util.URLJoin` and replace all callers with direct path concatenation (#36867)
|
||||
* Replace legacy tw-flex utility classes with flex-text-block/inline (#36778)
|
||||
* Mark unused&immature activitypub as "not implemented" (#36789)
|
||||
* TESTING
|
||||
* Add e2e tests for server push events (#36879)
|
||||
* Rework e2e tests (#36634)
|
||||
* Add e2e reaction test, improve accessibility, enable parallel testing (#37081)
|
||||
* Increase e2e test timeouts on CI to fix flaky tests (#37053)
|
||||
* BUILD
|
||||
* Upgrade go-git to v5.18.0 (#37269)
|
||||
* Replace rollup-plugin-license with rolldown-license-plugin (#37130) (#37158)
|
||||
* Bump min go version to 1.26.2 (#37139) (#37143)
|
||||
* Convert locale files from ini to json format (#35489)
|
||||
* Bump golangci-lint to 2.7.2, enable modernize stringsbuilder (#36180)
|
||||
* Port away from `flake-utils` (#35675)
|
||||
* Remove nolint (#36252)
|
||||
* Update the Unlicense copy to latest version (#36636)
|
||||
* Update to go 1.26.0 and golangci-lint 2.9.0 (#36588)
|
||||
* Replace `google/go-licenses` with custom generation (#36575)
|
||||
* Update go dependencies (#36548)
|
||||
* Bump appleboy/git-push-action from 1.0.0 to 1.2.0 (#36306)
|
||||
* Remove fomantic form module (#36222)
|
||||
* Bump setup-node to v6, re-enable cache (#36207)
|
||||
* Bump crowdin/github-action from 1 to 2 (#36204)
|
||||
* Revert "Bump alpine to 3.23 (#36185)" (#36202)
|
||||
* Update chroma to v2.21.1 (#36201)
|
||||
* Bump astral-sh/setup-uv from 6 to 7 (#36198)
|
||||
* Bump docker/build-push-action from 5 to 6 (#36197)
|
||||
* Bump aws-actions/configure-aws-credentials from 4 to 5 (#36196)
|
||||
* Bump dev-hanz-ops/install-gh-cli-action from 0.1.0 to 0.2.1 (#36195)
|
||||
* Add JSON linting (#36192)
|
||||
* Enable dependabot for actions (#36191)
|
||||
* Bump alpine to 3.23 (#36185)
|
||||
* Update chroma to v2.21.0 (#36171)
|
||||
* Update JS deps and eslint enhancements (#36147)
|
||||
* Update JS deps (#36091)
|
||||
* update golangci-lint to v2.7.0 (#36079)
|
||||
* Update JS deps, fix deprecations (#36040)
|
||||
* Update JS deps (#35978)
|
||||
* Add toolchain directive to go.mod (#35901)
|
||||
* Move `gitea-vet` to use `go tool` (#35878)
|
||||
* Update to go 1.25.4 (#35877)
|
||||
* Enable TypeScript `strictNullChecks` (#35843)
|
||||
* Enable `vue/require-typed-ref` eslint rule (#35764)
|
||||
* Update JS dependencies (#35759)
|
||||
* Move `codeformat` folder to tools (#35758)
|
||||
* Update dependencies (#35733)
|
||||
* Bump happy-dom from 20.0.0 to 20.0.2 (#35677)
|
||||
* Bump setup-go to v6 (#35660)
|
||||
* Update JS deps, misc tweaks (#35643)
|
||||
* Bump happy-dom from 19.0.2 to 20.0.0 (#35625)
|
||||
* Use bundled version of spectral (#35573)
|
||||
* Update JS and PY deps (#35565)
|
||||
* Bump github.com/wneessen/go-mail from 0.6.2 to 0.7.1 (#35557)
|
||||
* Migrate from webpack to vite (#37002)
|
||||
* Update JS dependencies and misc tweaks (#37064)
|
||||
* Update to eslint 10 (#36925)
|
||||
* Optimize Docker build with dependency layer caching (#36864)
|
||||
* Update JS deps (#36850)
|
||||
* Update tool dependencies and fix new lint issues (#36702)
|
||||
* Remove redundant linter rules (#36658)
|
||||
* Move Fomantic dropdown CSS to custom module (#36530)
|
||||
* Remove and forbid `@ts-expect-error` (#36513)
|
||||
* Refactor git command stderr handling (#36402)
|
||||
* Enable gocheckcompilerdirectives linter (#36156)
|
||||
* Replace `lint-go-gopls` with additional `govet` linters (#36028)
|
||||
* Update golangci-lint to v2.6.0 (#35801)
|
||||
* Misc tool tweaks (#35734)
|
||||
* Add cache to container build (#35697)
|
||||
* Upgrade vite (#37126)
|
||||
* Update `setup-uv` to v8.0.0 (#37101)
|
||||
* Upgrade `go-git` to v5.17.2 and related dependencies (#37060)
|
||||
* Raise minimum Node.js version to 22.18.0 (#37058)
|
||||
* Upgrade `golang.org/x/image` to v0.38.0 (#37054)
|
||||
* Update minimum go version to 1.26.1, golangci-lint to 2.11.2, fix test style (#36876)
|
||||
* Enable eslint concurrency (#36878)
|
||||
* Vendor relative-time-element as local web component (#36853)
|
||||
* Update material-icon-theme v5.32.0 (#36832)
|
||||
* Update Go dependencies (#36781)
|
||||
* Upgrade minimatch (#36760)
|
||||
* Remove i18n backport tool at the moment because of translation format changed (#36643)
|
||||
* Update emoji data for Unicode 16 (#36596)
|
||||
* Update JS dependencies, adjust webpack config, misc fixes (#36431)
|
||||
* Update material-icon-theme to v5.31.0 (#36427)
|
||||
* Update JS and PY deps (#36383)
|
||||
* Bump alpine to 3.23, add platforms to `docker-dryrun` (#36379)
|
||||
* Update JS deps (#36354)
|
||||
* Update goldmark to v1.7.16 (#36343)
|
||||
* Update chroma to v2.22.0 (#36342)
|
||||
* DOCS
|
||||
* Update AI Contribution Policy (#37022)
|
||||
* Update AGENTS.md with additional guidelines (#37018)
|
||||
* Add missing cron tasks to example ini (#37012)
|
||||
* Add AI Contribution Policy to CONTRIBUTING.md (#36651)
|
||||
* Minor punctuation improvement in CONTRIBUTING.md (#36291)
|
||||
* Add documentation for markdown anchor post-processing (#36443)
|
||||
* MISC
|
||||
* Correct spelling (#36783)
|
||||
* Update Nix flake (#37110)
|
||||
* Update Nix flake (#37024)
|
||||
* Add valid github scopes (#36977)
|
||||
* Update Nix flake (#36943)
|
||||
* Update Nix flake (#36902)
|
||||
* Update Nix flake (#36857)
|
||||
* Update Nix flake (#36787)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
+160
-58
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
@@ -10,87 +10,185 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// CustomFieldDefinition defines a custom field for a repository's issues
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL" json:"repo_id"`
|
||||
Name string `xorm:"NOT NULL" json:"name"`
|
||||
FieldType string `xorm:"NOT NULL" json:"field_type"` // text, number, date, dropdown, checkbox
|
||||
Description string `json:"description"`
|
||||
Required bool `xorm:"NOT NULL DEFAULT false" json:"required"`
|
||||
Position int `xorm:"NOT NULL DEFAULT 0" json:"position"`
|
||||
Options string `xorm:"TEXT" json:"options"` // JSON array for dropdown options
|
||||
DefaultVal string `json:"default_value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CustomFieldDefinition))
|
||||
db.RegisterModel(new(CustomFieldDef))
|
||||
db.RegisterModel(new(CustomFieldValue))
|
||||
}
|
||||
|
||||
// CustomFieldValue stores the value of a custom field for a specific issue
|
||||
// CustomFieldType represents the data type of a custom field.
|
||||
type CustomFieldType string
|
||||
|
||||
const (
|
||||
CustomFieldTypeText CustomFieldType = "text"
|
||||
CustomFieldTypeNumber CustomFieldType = "number"
|
||||
CustomFieldTypeDate CustomFieldType = "date"
|
||||
CustomFieldTypeDropdown CustomFieldType = "dropdown"
|
||||
CustomFieldTypeCheckbox CustomFieldType = "checkbox"
|
||||
CustomFieldTypeURL CustomFieldType = "url"
|
||||
)
|
||||
|
||||
// CustomFieldScope determines where the field appears.
|
||||
type CustomFieldScope string
|
||||
|
||||
const (
|
||||
CustomFieldScopeIssue CustomFieldScope = "issue" // appears in issue sidebar
|
||||
CustomFieldScopeRepo CustomFieldScope = "repo" // appears in repo settings metadata
|
||||
)
|
||||
|
||||
// CustomFieldDef defines a custom field at the org level.
|
||||
// owner_id = org ID, scope = issue or repo.
|
||||
// repo_id is kept for backward compat but 0 for org-level definitions.
|
||||
type CustomFieldDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"` // org that owns this field
|
||||
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'repo_id'"` // 0 = org-level (inherited by all repos)
|
||||
Scope CustomFieldScope `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType CustomFieldType `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (CustomFieldDef) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
// CustomFieldValue stores a custom field value for an entity (issue or repo).
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL" json:"issue_id"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL" json:"field_id"`
|
||||
Value string `xorm:"TEXT" json:"value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
EntityID int64 `xorm:"INDEX NOT NULL 'entity_id'"` // issue ID or repo ID
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"` // "issue" or "repo"
|
||||
FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
// GetCustomFieldsByRepoID returns all custom field definitions for a repo
|
||||
func GetCustomFieldsByRepoID(ctx context.Context, repoID int64) ([]*CustomFieldDefinition, error) {
|
||||
fields := make([]*CustomFieldDefinition, 0)
|
||||
return fields, db.GetEngine(ctx).Where("repo_id = ?", repoID).OrderBy("position ASC").Find(&fields)
|
||||
func (CustomFieldValue) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// GetCustomFieldByID returns a custom field definition by ID
|
||||
func GetCustomFieldByID(ctx context.Context, id int64) (*CustomFieldDefinition, error) {
|
||||
field := &CustomFieldDefinition{ID: id}
|
||||
has, err := db.GetEngine(ctx).Get(field)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Queries for org-level field definitions
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByOwner returns all active field definitions for an org with a given scope.
|
||||
func GetCustomFieldsByOwner(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ? AND scope = ? AND is_active = ?", ownerID, scope, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByOwner returns all field definitions for an org (including inactive).
|
||||
func GetAllCustomFieldsByOwner(ctx context.Context, ownerID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ?", ownerID).
|
||||
OrderBy("scope ASC, sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetCustomFieldsByOwnerAndScope returns all fields for an org filtered by scope.
|
||||
func GetCustomFieldsByOwnerAndScope(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ? AND scope = ?", ownerID, scope).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Backward-compatible queries (load by repo's owner)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByRepo returns active issue-scoped fields for a repo's org.
|
||||
// This is the main query used by the issue sidebar.
|
||||
func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
// First try org-level fields (owner_id != 0, repo_id = 0)
|
||||
// Fall back to legacy repo-level fields (repo_id = repoID)
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("((owner_id != 0 AND repo_id = 0) OR repo_id = ?) AND scope = ? AND is_active = ?",
|
||||
repoID, CustomFieldScopeIssue, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByRepo returns all field definitions for a repo (for settings page).
|
||||
func GetAllCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("repo_id = ?", repoID).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field definition CRUD
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldDefByID returns a single field definition.
|
||||
func GetCustomFieldDefByID(ctx context.Context, id int64) (*CustomFieldDef, error) {
|
||||
field := new(CustomFieldDef)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
return nil, db.ErrNotExist{Resource: "CustomFieldDef", ID: id}
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// CreateCustomField creates a new custom field definition
|
||||
func CreateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// CreateCustomFieldDef creates a new custom field definition.
|
||||
func CreateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(field)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomField updates a custom field definition
|
||||
func UpdateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// UpdateCustomFieldDef updates a custom field definition.
|
||||
func UpdateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error {
|
||||
_, err := db.GetEngine(ctx).ID(field.ID).AllCols().Update(field)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCustomField deletes a custom field and all its values
|
||||
func DeleteCustomField(ctx context.Context, id int64) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
if _, err := sess.Where("field_id = ?", id).Delete(&CustomFieldValue{}); err != nil {
|
||||
// DeleteCustomFieldDef deletes a field definition and all its values.
|
||||
func DeleteCustomFieldDef(ctx context.Context, id int64) error {
|
||||
if _, err := db.GetEngine(ctx).Where("field_id = ?", id).Delete(new(CustomFieldValue)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := sess.ID(id).Delete(&CustomFieldDefinition{})
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(CustomFieldDef))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomFieldValues returns all custom field values for an issue
|
||||
func GetCustomFieldValues(ctx context.Context, issueID int64) ([]*CustomFieldValue, error) {
|
||||
values := make([]*CustomFieldValue, 0)
|
||||
return values, db.GetEngine(ctx).Where("issue_id = ?", issueID).Find(&values)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field values — generic entity-based (works for issues and repos)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldValuesMap returns field_id -> value for an entity.
|
||||
func GetCustomFieldValuesMap(ctx context.Context, entityID int64) (map[int64]string, error) {
|
||||
values := make([]*CustomFieldValue, 0, 10)
|
||||
if err := db.GetEngine(ctx).Where("entity_id = ?", entityID).Find(&values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[int64]string, len(values))
|
||||
for _, v := range values {
|
||||
result[v.FieldID] = v.Value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetCustomFieldValue sets or updates a custom field value for an issue
|
||||
func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value string) error {
|
||||
existing := &CustomFieldValue{}
|
||||
has, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Get(existing)
|
||||
// SetCustomFieldValue creates or updates a single custom field value.
|
||||
func SetCustomFieldValue(ctx context.Context, entityID, fieldID int64, value string) error {
|
||||
existing := new(CustomFieldValue)
|
||||
has, err := db.GetEngine(ctx).Where("entity_id = ? AND field_id = ?", entityID, fieldID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,15 +198,19 @@ func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value stri
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Insert(&CustomFieldValue{
|
||||
IssueID: issueID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
EntityID: entityID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCustomFieldValue deletes a specific custom field value
|
||||
func DeleteCustomFieldValue(ctx context.Context, issueID, fieldID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Delete(&CustomFieldValue{})
|
||||
return err
|
||||
// SetCustomFieldValues sets multiple custom field values for an entity.
|
||||
func SetCustomFieldValues(ctx context.Context, entityID int64, values map[int64]string) error {
|
||||
for fieldID, value := range values {
|
||||
if err := SetCustomFieldValue(ctx, entityID, fieldID, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,14 +48,18 @@ func (LicenseKey) TableName() string {
|
||||
return "license_key"
|
||||
}
|
||||
|
||||
// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format.
|
||||
func GenerateKeyString() (string, error) {
|
||||
// GenerateKeyString creates a random license key in PREFIX-XXXX-XXXX-XXXX-XXXX format.
|
||||
// If prefix is empty, defaults to "MOKO".
|
||||
func GenerateKeyString(prefix string) (string, error) {
|
||||
if prefix == "" {
|
||||
prefix = "MOKO"
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hex := strings.ToUpper(hex.EncodeToString(b))
|
||||
return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil
|
||||
h := strings.ToUpper(hex.EncodeToString(b))
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s", prefix, h[0:4], h[4:8], h[8:12], h[12:16]), nil
|
||||
}
|
||||
|
||||
// HashKey returns the SHA-256 hash of a raw key string.
|
||||
@@ -65,8 +69,14 @@ func HashKey(rawKey string) string {
|
||||
}
|
||||
|
||||
// CreateLicenseKey generates a new key, stores it in plaintext and hashed, and returns the raw key.
|
||||
// The prefix is looked up from the org's update stream config.
|
||||
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
|
||||
rawKey, err = GenerateKeyString()
|
||||
prefix := ""
|
||||
cfg := GetEffectiveConfig(ctx, key.OwnerID, 0)
|
||||
if cfg != nil && cfg.KeyPrefix != "" {
|
||||
prefix = cfg.KeyPrefix
|
||||
}
|
||||
rawKey, err = GenerateKeyString(prefix)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GenerateKeyString: %w", err)
|
||||
}
|
||||
@@ -120,10 +130,11 @@ func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ListLicenseKeys returns all keys for the given owner.
|
||||
// ListLicenseKeys returns all keys for the given owner, master keys first.
|
||||
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
|
||||
keys := make([]*LicenseKey, 0, 20)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).
|
||||
OrderBy("is_internal DESC, created_unix DESC").Find(&keys)
|
||||
}
|
||||
|
||||
// SearchLicenseKeys searches keys for an owner by key prefix/raw, licensee, email, or domain.
|
||||
|
||||
@@ -5,8 +5,10 @@ package licenses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
@@ -30,6 +32,10 @@ type LicensePackage struct {
|
||||
// AllowedChannels defines which update streams keys from this package
|
||||
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
|
||||
AllowedChannels string `xorm:"TEXT"`
|
||||
// DomainRestriction is a comma-separated list of allowed domains.
|
||||
// Keys generated from this package inherit this unless overridden.
|
||||
// Empty = no restriction.
|
||||
DomainRestriction string `xorm:"TEXT"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
@@ -79,6 +85,32 @@ func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage,
|
||||
return pkgs, db.GetEngine(ctx).Where("owner_id = ? AND is_archived = ?", ownerID, false).Find(&pkgs)
|
||||
}
|
||||
|
||||
// ListLicensePackagesWithAncestors returns packages from the org and all parent orgs.
|
||||
func ListLicensePackagesWithAncestors(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
|
||||
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
|
||||
pkgs := make([]*LicensePackage, 0, 10)
|
||||
return pkgs, db.GetEngine(ctx).In("owner_id", ancestorIDs).Where("is_archived = ?", false).Find(&pkgs)
|
||||
}
|
||||
|
||||
// ListLicenseKeysWithAncestors returns keys from the org and all parent orgs.
|
||||
func ListLicenseKeysWithAncestors(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
|
||||
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
|
||||
keys := make([]*LicenseKey, 0, 20)
|
||||
return keys, db.GetEngine(ctx).In("owner_id", ancestorIDs).Find(&keys)
|
||||
}
|
||||
|
||||
// SearchLicenseKeysWithAncestors searches keys across the org and all parent orgs.
|
||||
func SearchLicenseKeysWithAncestors(ctx context.Context, ownerID int64, query string) ([]*LicenseKey, error) {
|
||||
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
|
||||
keys := make([]*LicenseKey, 0, 20)
|
||||
like := "%" + strings.ToLower(query) + "%"
|
||||
return keys, db.GetEngine(ctx).
|
||||
In("owner_id", ancestorIDs).
|
||||
And("(LOWER(key_prefix) LIKE ? OR LOWER(key_raw) LIKE ? OR LOWER(licensee_name) LIKE ? OR LOWER(licensee_email) LIKE ? OR LOWER(domain_restriction) LIKE ? OR LOWER(payment_ref) LIKE ?)",
|
||||
like, like, like, like, like, like).
|
||||
Find(&keys)
|
||||
}
|
||||
|
||||
// ListArchivedLicensePackages returns archived packages for the given owner.
|
||||
func ListArchivedLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
|
||||
pkgs := make([]*LicensePackage, 0, 10)
|
||||
|
||||
@@ -23,26 +23,27 @@ type UpdateStreamConfig struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user
|
||||
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default
|
||||
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom
|
||||
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla' 'stream_mode'"` // joomla, custom
|
||||
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both, wordpress, prestashop, drupal
|
||||
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system
|
||||
RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed
|
||||
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public'"` // public, no-download, hidden
|
||||
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none'"` // none, all, prerelease
|
||||
SupportURL string `xorm:"TEXT"` // wiki or external support page URL
|
||||
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false 'licensing_enabled'"` // master toggle
|
||||
RequireKey bool `xorm:"NOT NULL DEFAULT false 'require_key'"` // require license key for update feed
|
||||
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public' 'feed_visibility'"` // public, no-download, hidden
|
||||
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none' 'download_gating'"` // none, all, prerelease
|
||||
SupportURL string `xorm:"TEXT 'support_url'"` // wiki or external support page URL
|
||||
KeyPrefix string `xorm:"VARCHAR(20) 'key_prefix'"` // org-specific license key prefix (e.g. "ACME")
|
||||
// Extension metadata — used in update feed generation.
|
||||
ExtensionName string `xorm:"TEXT"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
|
||||
DisplayName string `xorm:"TEXT"` // human-readable name (e.g. "Package - MokoWaaS")
|
||||
ExtensionName string `xorm:"TEXT 'extension_name'"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
|
||||
DisplayName string `xorm:"TEXT 'display_name'"` // human-readable name (e.g. "Package - MokoWaaS")
|
||||
Description string `xorm:"TEXT"` // short description for update feeds
|
||||
ExtensionType string `xorm:"VARCHAR(50)"` // component, module, plugin, package, template, library
|
||||
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library
|
||||
Maintainer string `xorm:"TEXT"` // maintainer/author name
|
||||
MaintainerURL string `xorm:"TEXT"` // maintainer website
|
||||
InfoURL string `xorm:"TEXT"` // extension info/product page URL
|
||||
TargetVersion string `xorm:"TEXT"` // target platform version regex (e.g. "(5|6)\..*")
|
||||
PHPMinimum string `xorm:"VARCHAR(20)"` // minimum PHP version (e.g. "8.1")
|
||||
MaintainerURL string `xorm:"TEXT 'maintainer_url'"` // maintainer website
|
||||
InfoURL string `xorm:"TEXT 'info_url'"` // extension info/product page URL
|
||||
TargetVersion string `xorm:"TEXT 'target_version'"` // target platform version regex (e.g. "(5|6)\..*")
|
||||
PHPMinimum string `xorm:"VARCHAR(20) 'php_minimum'"` // minimum PHP version (e.g. "8.1")
|
||||
// CustomStreams is a JSON array of stream definitions.
|
||||
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
|
||||
CustomStreams string `xorm:"TEXT"`
|
||||
CustomStreams string `xorm:"TEXT 'custom_streams'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||
}
|
||||
|
||||
@@ -416,8 +416,13 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable),
|
||||
newMigration(337, "Add key_plain column to license_key", v1_27.AddKeyPlainToLicenseKey),
|
||||
newMigration(338, "Add platform and require_key to update_stream_config", v1_27.AddPlatformAndRequireKeyToStreamConfig),
|
||||
newMigration(339, "Add AI assistant tables", v1_27.AddAITables),
|
||||
newMigration(339, "Placeholder for AI tables", noopMigration),
|
||||
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
|
||||
newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser),
|
||||
newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository),
|
||||
newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables),
|
||||
newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage),
|
||||
newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_25
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
func AddCustomFieldTables(x *xorm.Engine) error {
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType string `xorm:"NOT NULL"` // text, number, date, dropdown, checkbox
|
||||
Description string
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Position int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
DefaultVal string
|
||||
CreatedUnix int64 `xorm:"INDEX created"`
|
||||
UpdatedUnix int64 `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix int64 `xorm:"INDEX created"`
|
||||
UpdatedUnix int64 `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
return x.Sync(new(CustomFieldDefinition), new(CustomFieldValue))
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type updateStreamConfig340 struct {
|
||||
InfoURL string `xorm:"TEXT"`
|
||||
TargetVersion string `xorm:"TEXT"`
|
||||
PHPMinimum string `xorm:"VARCHAR(20)"`
|
||||
KeyPrefix string `xorm:"VARCHAR(20)"`
|
||||
}
|
||||
|
||||
func (updateStreamConfig340) TableName() string {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
type userParentOrg341 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"`
|
||||
}
|
||||
|
||||
func (userParentOrg341) TableName() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
// AddParentOrgIDToUser adds the parent_org_id column to the user table
|
||||
// for enterprise sub-org hierarchy support.
|
||||
func AddParentOrgIDToUser(x *xorm.Engine) error {
|
||||
return x.Sync(new(userParentOrg341))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
type repoHidden342 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"`
|
||||
}
|
||||
|
||||
func (repoHidden342) TableName() string {
|
||||
return "repository"
|
||||
}
|
||||
|
||||
// AddIsHiddenToRepository adds the is_hidden column for three-level repo visibility.
|
||||
func AddIsHiddenToRepository(x *xorm.Engine) error {
|
||||
return x.Sync(new(repoHidden342))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type customFieldDef343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType string `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"`
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (customFieldDef343) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
type customFieldValue343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL 'issue_id'"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (customFieldValue343) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// AddCustomFieldTables creates the custom_field_def and custom_field_value tables.
|
||||
func AddCustomFieldTables(x *xorm.Engine) error {
|
||||
return x.Sync(new(customFieldDef343), new(customFieldValue343))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddDomainRestrictionToLicensePackage(x *xorm.Engine) error {
|
||||
type LicensePackage struct {
|
||||
DomainRestriction string `xorm:"TEXT"`
|
||||
}
|
||||
return x.Sync(new(LicensePackage))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// MigrateCustomFieldsToOrgLevel adds owner_id, scope to custom_field_def
|
||||
// and renames issue_id to entity_id + adds entity_type in custom_field_value.
|
||||
func MigrateCustomFieldsToOrgLevel(x *xorm.Engine) error {
|
||||
// Add new columns to custom_field_def
|
||||
type CustomFieldDef struct {
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"`
|
||||
Scope string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add entity_type and entity_id to custom_field_value
|
||||
type CustomFieldValue struct {
|
||||
EntityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'entity_id'"`
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldValue)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate existing data: copy issue_id to entity_id where entity_id is 0
|
||||
if _, err := x.Exec("UPDATE custom_field_value SET entity_id = issue_id WHERE entity_id = 0 AND issue_id != 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set issue_id default to 0 so new inserts don't require it
|
||||
_, err := x.Exec("ALTER TABLE custom_field_value MODIFY COLUMN issue_id bigint NOT NULL DEFAULT 0")
|
||||
return err
|
||||
}
|
||||
@@ -97,6 +97,48 @@ func (org *Organization) IsOrgAdmin(ctx context.Context, uid int64) (bool, error
|
||||
return IsOrganizationAdmin(ctx, org.ID, uid)
|
||||
}
|
||||
|
||||
// HasParentOrg returns true if this org has a parent.
|
||||
func (org *Organization) HasParentOrg() bool {
|
||||
return org.ParentOrgID > 0
|
||||
}
|
||||
|
||||
// GetParentOrg returns the parent organization, or nil if none.
|
||||
func (org *Organization) GetParentOrg(ctx context.Context) (*Organization, error) {
|
||||
if org.ParentOrgID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return GetOrgByID(ctx, org.ParentOrgID)
|
||||
}
|
||||
|
||||
// GetChildOrgs returns all direct child organizations.
|
||||
func GetChildOrgs(ctx context.Context, parentOrgID int64) ([]*Organization, error) {
|
||||
var orgs []*Organization
|
||||
return orgs, db.GetEngine(ctx).
|
||||
Where("type = ? AND parent_org_id = ?", user_model.UserTypeOrganization, parentOrgID).
|
||||
Find(&orgs)
|
||||
}
|
||||
|
||||
// GetAncestorOrgIDs returns all org IDs in the parent chain (including self).
|
||||
// Used for license validation — a key from any ancestor org is valid.
|
||||
func GetAncestorOrgIDs(ctx context.Context, orgID int64) []int64 {
|
||||
ids := []int64{orgID}
|
||||
currentID := orgID
|
||||
seen := map[int64]bool{orgID: true}
|
||||
for i := 0; i < 10; i++ { // max 10 levels to prevent infinite loops
|
||||
org, err := GetOrgByID(ctx, currentID)
|
||||
if err != nil || org.ParentOrgID == 0 {
|
||||
break
|
||||
}
|
||||
if seen[org.ParentOrgID] {
|
||||
break // cycle detected
|
||||
}
|
||||
seen[org.ParentOrgID] = true
|
||||
ids = append(ids, org.ParentOrgID)
|
||||
currentID = org.ParentOrgID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// IsOrgMember returns true if given user is member of organization.
|
||||
func (org *Organization) IsOrgMember(ctx context.Context, uid int64) (bool, error) {
|
||||
return IsOrganizationMember(ctx, org.ID, uid)
|
||||
|
||||
@@ -185,6 +185,7 @@ type Repository struct {
|
||||
NumOpenActionRuns int `xorm:"-"`
|
||||
|
||||
IsPrivate bool `xorm:"INDEX"`
|
||||
IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"` // hidden repos return 404, private repos return 403
|
||||
IsEmpty bool `xorm:"INDEX"`
|
||||
IsArchived bool `xorm:"INDEX"`
|
||||
IsMirror bool `xorm:"INDEX"`
|
||||
|
||||
@@ -152,6 +152,7 @@ type User struct {
|
||||
NumMembers int
|
||||
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"`
|
||||
RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"` // 0 = no parent (top-level org)
|
||||
|
||||
// Preferences
|
||||
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// CustomFieldDefinition represents a custom field definition for a repository
|
||||
// swagger:model
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `json:"id"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Name string `json:"name"`
|
||||
FieldType string `json:"field_type"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Position int `json:"position"`
|
||||
Options string `json:"options"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateCustomFieldOption options for creating a custom field
|
||||
// swagger:model
|
||||
type CreateCustomFieldOption struct {
|
||||
// required: true
|
||||
Name string `json:"name" binding:"Required"`
|
||||
// required: true
|
||||
FieldType string `json:"field_type" binding:"Required"` // text, number, date, dropdown, checkbox
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Position int `json:"position"`
|
||||
Options string `json:"options"` // JSON array for dropdown
|
||||
DefaultValue string `json:"default_value"`
|
||||
}
|
||||
|
||||
// EditCustomFieldOption options for editing a custom field
|
||||
// swagger:model
|
||||
type EditCustomFieldOption struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Required *bool `json:"required"`
|
||||
Position *int `json:"position"`
|
||||
Options *string `json:"options"`
|
||||
DefaultValue *string `json:"default_value"`
|
||||
}
|
||||
|
||||
// CustomFieldValue represents a custom field value for an issue
|
||||
// swagger:model
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `json:"id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
FieldID int64 `json:"field_id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SetCustomFieldValueOption options for setting a custom field value
|
||||
// swagger:model
|
||||
type SetCustomFieldValueOption struct {
|
||||
// required: true
|
||||
Value string `json:"value" binding:"Required"`
|
||||
}
|
||||
@@ -104,6 +104,8 @@ type CreateIssueOption struct {
|
||||
// list of project ids
|
||||
Projects []int64 `json:"projects"`
|
||||
Closed bool `json:"closed"`
|
||||
// custom field values keyed by field name
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty"`
|
||||
}
|
||||
|
||||
// EditIssueOption options for editing an issue
|
||||
@@ -190,15 +192,16 @@ const (
|
||||
// IssueTemplate represents an issue template for a repository
|
||||
// swagger:model
|
||||
type IssueTemplate struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty" yaml:"custom_fields"`
|
||||
}
|
||||
|
||||
type IssueTemplateStringSlice []string
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"loading": "Loading…",
|
||||
"files": "Files",
|
||||
"error_title": "Error",
|
||||
"error403": "You do not have permission to access this resource. If you believe this is an error, contact the repository owner.",
|
||||
"error404": "The page you are trying to reach either <strong>does not exist</strong> or <strong>you are not authorized</strong> to view it.",
|
||||
"error503": "The server could not complete your request. Please try again later.",
|
||||
"go_back": "Go Back",
|
||||
@@ -1411,6 +1412,7 @@
|
||||
"repo.issues.new.open_projects": "Open Projects",
|
||||
"repo.issues.new.closed_projects": "Closed Projects",
|
||||
"repo.issues.new.no_items": "No items",
|
||||
"repo.issues.custom_fields": "Custom Fields",
|
||||
"repo.issues.new.milestone": "Milestone",
|
||||
"repo.issues.new.no_milestone": "No Milestone",
|
||||
"repo.issues.new.clear_milestone": "Clear milestone",
|
||||
@@ -2148,15 +2150,15 @@
|
||||
"repo.settings.unit_visibility_private": "Private (follow repo visibility)",
|
||||
"repo.settings.unit_visibility_public": "Public (anyone can read)",
|
||||
"repo.settings.unit_visibility_releases_help": "Controls whether the releases page is visible to anonymous visitors.",
|
||||
"repo.settings.licensing_section": "Licensing & Updates",
|
||||
"repo.settings.licensing_section_desc": "Manage commercial license keys and gated update feeds for this repository. When enabled, the Licenses tab appears and release tags must follow update stream naming.",
|
||||
"repo.settings.licensing_section": "Update Server",
|
||||
"repo.settings.licensing_section_desc": "Manage update feeds and optional license key gating for this repository. When enabled, the Licenses tab appears and release tags are served via update feeds.",
|
||||
"repo.settings.update_platform": "Update Feed Format",
|
||||
"repo.settings.update_platform_both": "Both (Joomla + Dolibarr)",
|
||||
"repo.settings.update_platform_help": "Choose which update feed format to generate. All formats support license key validation.",
|
||||
"repo.settings.require_update_key": "Require license key for update feeds",
|
||||
"repo.settings.require_update_key_help": "When enabled, update feeds return empty results unless a valid license key is provided. Joomla clients will see a Download Key field in Update Sites.",
|
||||
"repo.settings.enable_licensing": "Enable licensing for this repository",
|
||||
"repo.settings.enable_licensing_help": "Show the Licenses tab and enable license key management for this repository.",
|
||||
"repo.settings.enable_licensing": "Enable Update Server for this repository",
|
||||
"repo.settings.enable_licensing_help": "Serve update feeds from releases and show the Licenses tab for optional key management.",
|
||||
"repo.settings.packages_desc": "Enable Repository Packages Registry",
|
||||
"repo.settings.projects_desc": "Enable Projects",
|
||||
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
|
||||
@@ -2659,12 +2661,16 @@
|
||||
"repo.licenses.key_revoked": "License key revoked.",
|
||||
"repo.licenses.master_key_created": "Master License Key Created",
|
||||
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
|
||||
"repo.licenses.regenerate_master_key": "Regenerate",
|
||||
"repo.licenses.regenerate_master_key_help": "Deactivates the current master key and generates a new one. The new key will be shown once.",
|
||||
"repo.licenses.master_key_regenerated": "Master key regenerated. Copy the new key below — it will not be shown again.",
|
||||
"repo.licenses.update_feeds": "Update Feed URLs",
|
||||
"repo.licenses.edit_key": "Edit License Key",
|
||||
"repo.licenses.licensee_name": "Licensee Name",
|
||||
"repo.licenses.licensee_email": "Licensee Email",
|
||||
"repo.licenses.domain_restriction": "Domain Restriction",
|
||||
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty for no restriction.",
|
||||
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty to inherit from the package default.",
|
||||
"repo.licenses.domain_restriction_package_help": "Default domain restriction for keys generated from this package. Comma-separated. Keys can override this.",
|
||||
"repo.licenses.use_package_default": "use package default",
|
||||
"repo.licenses.expires_at": "Expires At",
|
||||
"repo.licenses.expires_at_help": "Leave empty for no expiry (lifetime).",
|
||||
@@ -2677,6 +2683,7 @@
|
||||
"repo.licenses.feed_joomla_updates": "Joomla updates.xml",
|
||||
"repo.licenses.feed_dolibarr_updates": "Dolibarr JSON",
|
||||
"repo.licenses.feed_wordpress_updates": "WordPress (PUC JSON)",
|
||||
"repo.licenses.open_feed": "Open in new tab",
|
||||
"repo.licenses.feed_changelog_xml": "Changelog XML (Joomla)",
|
||||
"repo.licenses.master_label": "Master",
|
||||
"repo.licenses.unlimited": "unlimited",
|
||||
@@ -2714,6 +2721,35 @@
|
||||
"repo.settings.download_gating": "Download Gating",
|
||||
"repo.settings.support_url": "Support / Product Page URL",
|
||||
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
|
||||
"repo.settings.custom_fields": "Custom Fields",
|
||||
"repo.settings.metadata": "Metadata",
|
||||
"repo.settings.metadata_saved": "Repository metadata saved.",
|
||||
"repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.",
|
||||
"repo.settings.custom_field_new": "New Field",
|
||||
"repo.settings.custom_field_create": "Create Field",
|
||||
"repo.settings.custom_field_name": "Field Name",
|
||||
"repo.settings.custom_field_type": "Type",
|
||||
"repo.settings.custom_field_description": "Description",
|
||||
"repo.settings.custom_field_options": "Options (JSON)",
|
||||
"repo.settings.custom_field_options_help": "JSON array for dropdown fields. e.g. [\"Low\",\"Medium\",\"High\"]",
|
||||
"repo.settings.custom_field_required": "Required",
|
||||
"repo.settings.custom_field_sort_order": "Sort Order",
|
||||
"repo.settings.custom_field_created": "Custom field created.",
|
||||
"repo.settings.custom_field_updated": "Custom field updated.",
|
||||
"repo.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"repo.settings.custom_field_confirm_delete": "Delete this custom field? All values stored for this field will be lost.",
|
||||
"repo.settings.custom_fields_none": "No Custom Fields",
|
||||
"repo.settings.custom_fields_none_desc": "Define custom fields to add structured metadata to issues.",
|
||||
"repo.settings.features": "Features",
|
||||
"repo.settings.features_units": "Units",
|
||||
"repo.settings.change_visibility": "Change Visibility",
|
||||
"repo.settings.visibility.warning": "Changing repository visibility affects who can access code, releases, and update feeds.",
|
||||
"repo.settings.visibility.public.label": "Public",
|
||||
"repo.settings.visibility.public.desc": "Visible to everyone. Anyone can clone and view.",
|
||||
"repo.settings.visibility.private.label": "Private",
|
||||
"repo.settings.visibility.private.desc": "Members only. Non-members see Access Denied (403). Licensed update feeds still work.",
|
||||
"repo.settings.visibility.hidden.label": "Hidden",
|
||||
"repo.settings.visibility.hidden.desc": "Members only. Non-members see Not Found (404). Hides the repo's existence entirely.",
|
||||
"repo.release.update_stream": "Update Stream",
|
||||
"repo.release.update_stream_auto": "(auto-detect from tag name)",
|
||||
"repo.release.update_stream_help": "Assign this release to an update stream. The update feed will serve the latest release per stream.",
|
||||
@@ -2862,11 +2898,24 @@
|
||||
"org.form.create_org_not_allowed": "You are not allowed to create an organization.",
|
||||
"org.settings": "Settings",
|
||||
"org.settings.options": "Organization",
|
||||
"org.settings.update_streams": "Licensing & Update Streams",
|
||||
"org.settings.licensing": "Licensing",
|
||||
"org.settings.licensing_desc": "Control commercial license key management and gated update feeds across all repositories in this organization.",
|
||||
"org.settings.enable_licensing": "Enable licensing for this organization",
|
||||
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
|
||||
"org.settings.custom_fields": "Custom Fields",
|
||||
"org.settings.custom_fields_desc": "Define custom fields that appear across all repositories in this organization. Issue fields show in issue sidebars. Repo fields show in repo settings metadata.",
|
||||
"org.settings.custom_fields_empty": "No custom fields defined yet.",
|
||||
"org.settings.custom_field_add": "Add Custom Field",
|
||||
"org.settings.custom_field_name": "Field Name",
|
||||
"org.settings.custom_field_scope": "Scope",
|
||||
"org.settings.custom_field_type": "Type",
|
||||
"org.settings.custom_field_options": "Options (JSON)",
|
||||
"org.settings.custom_field_options_help": "For dropdown fields, enter options as a JSON array.",
|
||||
"org.settings.custom_field_description": "Description",
|
||||
"org.settings.custom_field_created": "Custom field created.",
|
||||
"org.settings.custom_field_updated": "Custom field updated.",
|
||||
"org.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"org.settings.update_streams": "Update Server",
|
||||
"org.settings.licensing": "Update Server",
|
||||
"org.settings.licensing_desc": "Manage update feeds and optional license key gating across all repositories in this organization.",
|
||||
"org.settings.enable_licensing": "Enable Update Server for this organization",
|
||||
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and serve update feeds. Individual repos can also enable this independently.",
|
||||
"org.settings.require_key": "Require license key for all update feeds",
|
||||
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
|
||||
"org.settings.feed_visibility": "Update Feed Visibility",
|
||||
@@ -2908,6 +2957,11 @@
|
||||
"org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).",
|
||||
"org.settings.custom_streams": "Custom Stream Definitions (JSON)",
|
||||
"org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]",
|
||||
"org.settings.key_prefix": "License Key Prefix",
|
||||
"org.settings.key_prefix_help": "Custom prefix for license keys generated in this org (e.g. ACME, CLIENT). Leave empty for default (MOKO). Max 20 chars, auto-uppercased.",
|
||||
"org.settings.parent_org": "Parent Organization",
|
||||
"org.settings.parent_org_none": "(none — top-level organization)",
|
||||
"org.settings.parent_org_help": "Set a parent org for enterprise hierarchy. Child orgs inherit license packages and master keys from parent orgs.",
|
||||
"org.settings.update_streams_saved": "Settings saved.",
|
||||
"org.settings.full_name": "Full Name",
|
||||
"org.settings.email": "Contact Email Address",
|
||||
|
||||
+11
-12
@@ -1655,21 +1655,15 @@ func Routes() *web.Router {
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Combo("").Get(repo.ListCustomFields).
|
||||
Post(reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.CreateCustomFieldOption{}), repo.CreateCustomField)
|
||||
m.Group("/{fieldId}", func() {
|
||||
m.Combo("").Get(repo.GetCustomField).
|
||||
Patch(reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.EditCustomFieldOption{}), repo.EditCustomField).
|
||||
Delete(reqToken(), reqRepoWriter(unit.TypeIssues), repo.DeleteCustomField)
|
||||
})
|
||||
// Repo metadata (repo-scoped custom fields)
|
||||
m.Group("/metadata", func() {
|
||||
m.Get("", repo.GetRepoMetadata)
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeCode), repo.SetRepoMetadata)
|
||||
})
|
||||
// Issue custom fields
|
||||
m.Group("/issues/{index}/custom-fields", func() {
|
||||
m.Get("", repo.GetIssueCustomFields)
|
||||
m.Group("/{fieldId}", func() {
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.SetCustomFieldValueOption{}), repo.SetIssueCustomField)
|
||||
m.Delete("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.DeleteIssueCustomField)
|
||||
})
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.SetIssueCustomFields)
|
||||
})
|
||||
}, repoAssignment(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
|
||||
@@ -1771,6 +1765,11 @@ func Routes() *web.Router {
|
||||
m.Delete("", org.UnblockUser)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.ListOrgCustomFields)
|
||||
m.Post("", reqToken(), reqOrgOwnership(), org.CreateOrgCustomField)
|
||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
type apiCustomFieldDef struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
FieldType string `json:"field_type"`
|
||||
Description string `json:"description"`
|
||||
Options any `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toAPIFieldDef(f *issues_model.CustomFieldDef) apiCustomFieldDef {
|
||||
var opts any
|
||||
if f.Options != "" {
|
||||
var parsed []string
|
||||
if json.Unmarshal([]byte(f.Options), &parsed) == nil {
|
||||
opts = parsed
|
||||
} else {
|
||||
opts = f.Options
|
||||
}
|
||||
}
|
||||
return apiCustomFieldDef{
|
||||
ID: f.ID,
|
||||
OwnerID: f.OwnerID,
|
||||
Scope: string(f.Scope),
|
||||
Name: f.Name,
|
||||
FieldType: string(f.FieldType),
|
||||
Description: f.Description,
|
||||
Options: opts,
|
||||
Required: f.Required,
|
||||
SortOrder: f.SortOrder,
|
||||
IsActive: f.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
// ListOrgCustomFields returns all custom field definitions for an org.
|
||||
func ListOrgCustomFields(ctx *context.APIContext) {
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]apiCustomFieldDef, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
result = append(result, toAPIFieldDef(f))
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateOrgCustomField creates a new custom field definition.
|
||||
func CreateOrgCustomField(ctx *context.APIContext) {
|
||||
var req struct {
|
||||
Scope string `json:"scope" binding:"Required"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
FieldType string `json:"field_type" binding:"Required"`
|
||||
Description string `json:"description"`
|
||||
Options []string `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := ctx.Req.ParseForm(); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Scope == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "name and scope are required")
|
||||
return
|
||||
}
|
||||
|
||||
scope := issues_model.CustomFieldScope(req.Scope)
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
ctx.APIError(http.StatusBadRequest, "scope must be 'issue' or 'repo'")
|
||||
return
|
||||
}
|
||||
|
||||
var optionsJSON string
|
||||
if len(req.Options) > 0 {
|
||||
data, _ := json.Marshal(req.Options)
|
||||
optionsJSON = string(data)
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0,
|
||||
Scope: scope,
|
||||
Name: req.Name,
|
||||
FieldType: issues_model.CustomFieldType(req.FieldType),
|
||||
Description: req.Description,
|
||||
Options: optionsJSON,
|
||||
Required: req.Required,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, toAPIFieldDef(field))
|
||||
}
|
||||
|
||||
// DeleteOrgCustomField deletes a custom field definition.
|
||||
func DeleteOrgCustomField(ctx *context.APIContext) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
func fieldToAPI(f *issues_model.CustomFieldDefinition) *api.CustomFieldDefinition {
|
||||
return &api.CustomFieldDefinition{
|
||||
ID: f.ID,
|
||||
RepoID: f.RepoID,
|
||||
Name: f.Name,
|
||||
FieldType: f.FieldType,
|
||||
Description: f.Description,
|
||||
Required: f.Required,
|
||||
Position: f.Position,
|
||||
Options: f.Options,
|
||||
DefaultValue: f.DefaultVal,
|
||||
Created: f.CreatedUnix.AsTime(),
|
||||
Updated: f.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListCustomFields lists custom field definitions for a repository
|
||||
func ListCustomFields(ctx *context.APIContext) {
|
||||
fields, err := issues_model.GetCustomFieldsByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]*api.CustomFieldDefinition, len(fields))
|
||||
for i, f := range fields {
|
||||
result[i] = fieldToAPI(f)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetCustomField gets a custom field definition by ID
|
||||
func GetCustomField(ctx *context.APIContext) {
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// CreateCustomField creates a new custom field definition
|
||||
func CreateCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.CreateCustomFieldOption)
|
||||
|
||||
validTypes := map[string]bool{"text": true, "number": true, "date": true, "dropdown": true, "checkbox": true}
|
||||
if !validTypes[form.FieldType] {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "field_type must be: text, number, date, dropdown, or checkbox")
|
||||
return
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDefinition{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: form.Name,
|
||||
FieldType: form.FieldType,
|
||||
Description: form.Description,
|
||||
Required: form.Required,
|
||||
Position: form.Position,
|
||||
Options: form.Options,
|
||||
DefaultVal: form.DefaultValue,
|
||||
}
|
||||
if err := issues_model.CreateCustomField(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// EditCustomField updates a custom field definition
|
||||
func EditCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditCustomFieldOption)
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if form.Name != nil {
|
||||
field.Name = *form.Name
|
||||
}
|
||||
if form.Description != nil {
|
||||
field.Description = *form.Description
|
||||
}
|
||||
if form.Required != nil {
|
||||
field.Required = *form.Required
|
||||
}
|
||||
if form.Position != nil {
|
||||
field.Position = *form.Position
|
||||
}
|
||||
if form.Options != nil {
|
||||
field.Options = *form.Options
|
||||
}
|
||||
if form.DefaultValue != nil {
|
||||
field.DefaultVal = *form.DefaultValue
|
||||
}
|
||||
|
||||
if err := issues_model.UpdateCustomField(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// DeleteCustomField deletes a custom field and all its values
|
||||
func DeleteCustomField(ctx *context.APIContext) {
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteCustomField(ctx, field.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetIssueCustomFields gets all custom field values for an issue
|
||||
func GetIssueCustomFields(ctx *context.APIContext) {
|
||||
values, err := issues_model.GetCustomFieldValues(ctx, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]*api.CustomFieldValue, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = &api.CustomFieldValue{
|
||||
ID: v.ID,
|
||||
IssueID: v.IssueID,
|
||||
FieldID: v.FieldID,
|
||||
Value: v.Value,
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetIssueCustomField sets a custom field value on an issue
|
||||
func SetIssueCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.SetCustomFieldValueOption)
|
||||
issueID := ctx.PathParamInt64("index")
|
||||
fieldID := ctx.PathParamInt64("fieldId")
|
||||
|
||||
// Verify field belongs to this repo
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, fieldID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issueID, fieldID, form.Value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteIssueCustomField removes a custom field value from an issue
|
||||
func DeleteIssueCustomField(ctx *context.APIContext) {
|
||||
issueID := ctx.PathParamInt64("index")
|
||||
fieldID := ctx.PathParamInt64("fieldId")
|
||||
|
||||
if err := issues_model.DeleteCustomFieldValue(ctx, issueID, fieldID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// GetRepoMetadata returns all repo-scoped custom field values.
|
||||
func GetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetRepoMetadata sets repo-scoped custom field values.
|
||||
func SetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build name->ID map
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetIssueCustomFields returns custom field values for an issue.
|
||||
func GetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetIssueCustomFields sets custom field values for an issue.
|
||||
func SetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issue.ID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -702,6 +702,29 @@ func CreateIssue(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values if provided (resolve field names to IDs).
|
||||
if len(form.CustomFields) > 0 {
|
||||
defs, defErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if defErr != nil {
|
||||
ctx.APIErrorInternal(defErr)
|
||||
return
|
||||
}
|
||||
if len(defs) > 0 {
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
if v, ok := form.CustomFields[def.Name]; ok {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if setErr := issues_model.SetCustomFieldValues(ctx, issue.ID, vals); setErr != nil {
|
||||
ctx.APIErrorInternal(setErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if form.Closed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
if issues_model.IsErrDependenciesLeft(err) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string
|
||||
}
|
||||
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
|
||||
git.CommitsByFileAndRangeOptions{
|
||||
Revision: ctx.Repo.RefFullName.String()
|
||||
Revision: ctx.Repo.RefFullName.String(),
|
||||
File: fileName,
|
||||
Page: 1,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplOrgCustomFields templates.TplName = "org/settings/custom_fields"
|
||||
|
||||
// SettingsCustomFields shows the org-level custom fields management page.
|
||||
func SettingsCustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.custom_fields")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByOwner", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgCustomFields)
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsCreatePost creates a new org-level custom field.
|
||||
func SettingsCustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
scope := issues_model.CustomFieldScope(ctx.FormString("scope"))
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
scope = issues_model.CustomFieldScopeIssue
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0, // org-level
|
||||
Scope: scope,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsEditPost updates an org-level custom field.
|
||||
func SettingsCustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsDeletePost deletes an org-level custom field.
|
||||
func SettingsCustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func Licenses(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||
pkgs, err := licenses.ListLicensePackagesWithAncestors(ctx, ownerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicensePackages", err)
|
||||
return
|
||||
@@ -112,9 +112,9 @@ func Licenses(ctx *context.Context) {
|
||||
|
||||
var keys []*licenses.LicenseKey
|
||||
if searchQuery != "" {
|
||||
keys, err = licenses.SearchLicenseKeys(ctx, ownerID, searchQuery)
|
||||
keys, err = licenses.SearchLicenseKeysWithAncestors(ctx, ownerID, searchQuery)
|
||||
} else {
|
||||
keys, err = licenses.ListLicenseKeys(ctx, ownerID)
|
||||
keys, err = licenses.ListLicenseKeysWithAncestors(ctx, ownerID)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicenseKeys", err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||
packages_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/packages"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
@@ -47,6 +48,22 @@ func Settings(ctx *context.Context) {
|
||||
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
|
||||
ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
|
||||
ctx.Data["ContextUser"] = ctx.ContextUser
|
||||
ctx.Data["ParentOrgID"] = ctx.Org.Organization.ParentOrgID
|
||||
|
||||
// Load available parent orgs (all orgs the current user belongs to, excluding self).
|
||||
if ctx.Doer.IsAdmin || ctx.Org.IsOwner {
|
||||
orgs, _ := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
|
||||
UserID: ctx.Doer.ID,
|
||||
IncludeVisibility: structs.VisibleTypePrivate,
|
||||
})
|
||||
var parentCandidates []*org_model.Organization
|
||||
for _, o := range orgs {
|
||||
if o.ID != ctx.Org.Organization.ID {
|
||||
parentCandidates = append(parentCandidates, o)
|
||||
}
|
||||
}
|
||||
ctx.Data["ParentOrgCandidates"] = parentCandidates
|
||||
}
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
@@ -98,6 +115,17 @@ func SettingsPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save parent org (enterprise hierarchy).
|
||||
parentOrgID := ctx.FormInt64("parent_org_id")
|
||||
if parentOrgID != org.ParentOrgID {
|
||||
user := org.AsUser()
|
||||
user.ParentOrgID = parentOrgID
|
||||
if err := user_model.UpdateUserCols(ctx, user, "parent_org_id"); err != nil {
|
||||
ctx.ServerError("UpdateUserCols", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace("Organization setting updated: %s", org.Name)
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings")
|
||||
|
||||
@@ -5,6 +5,7 @@ package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
@@ -47,6 +48,7 @@ func SettingsUpdateStreamsPost(ctx *context.Context) {
|
||||
FeedVisibility: ctx.FormString("feed_visibility"),
|
||||
DownloadGating: ctx.FormString("download_gating"),
|
||||
SupportURL: ctx.FormString("support_url"),
|
||||
KeyPrefix: strings.ToUpper(strings.TrimSpace(ctx.FormString("key_prefix"))),
|
||||
ExtensionName: ctx.FormString("extension_name"),
|
||||
DisplayName: ctx.FormString("display_name"),
|
||||
Description: ctx.FormString("feed_description"),
|
||||
|
||||
@@ -182,8 +182,11 @@ func ServeAttachment(ctx *context.Context, uuid string) {
|
||||
}
|
||||
|
||||
if !perm.CanRead(unitType) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
// Allow access for licensed read-only mode (private repo with valid license key).
|
||||
if ctx.Data["LicensedReadOnly"] != true {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if requiredScope, ok := attachmentReadScope(unitType); ok {
|
||||
|
||||
@@ -630,7 +630,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
_, templateErrs, _ := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func CheckDownloadGating(ctx *context.Context, tagName string) bool {
|
||||
return true // no download gating configured
|
||||
}
|
||||
|
||||
// Signed-in users with repo access bypass download gating.
|
||||
// The gate is for anonymous/external clients (Joomla update checker).
|
||||
if ctx.IsSigned && ctx.Repo.Permission.HasAnyUnitAccess() {
|
||||
return true
|
||||
}
|
||||
|
||||
// For prerelease-only gating, check if this is a prerelease tag.
|
||||
if gating == "prerelease" && tagName != "" {
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// UpdateIssueCustomField handles POST to set a custom field value on an issue.
|
||||
func UpdateIssueCustomField(ctx *context.Context) {
|
||||
issueID := ctx.PathParamInt64("id")
|
||||
fieldID := ctx.PathParamInt64("field_id")
|
||||
value := ctx.FormString("value")
|
||||
|
||||
// Look up issue to get the index for redirect.
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issueID, fieldID, value); err != nil {
|
||||
ctx.ServerError("SetCustomFieldValue", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -36,10 +37,11 @@ import (
|
||||
)
|
||||
|
||||
// Tries to load and set an issue template. The first return value indicates if a template was loaded.
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) {
|
||||
// The third return value contains the template's custom_fields map (field name → default value).
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error, map[string]string) {
|
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
templateCandidates := make([]string, 0, 1+len(possibleFiles))
|
||||
@@ -84,9 +86,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
||||
}
|
||||
metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")
|
||||
|
||||
return true, templateErrs
|
||||
return true, templateErrs, template.CustomFields
|
||||
}
|
||||
return false, templateErrs
|
||||
return false, templateErrs, nil
|
||||
}
|
||||
|
||||
// NewIssue render creating issue page
|
||||
@@ -128,7 +130,7 @@ func NewIssue(ctx *context.Context) {
|
||||
ctx.Data["Tags"] = tags
|
||||
|
||||
ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
templateLoaded, errs, templateCustomFields := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
maps.Copy(ret.TemplateErrors, errs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
@@ -140,6 +142,35 @@ func NewIssue(ctx *context.Context) {
|
||||
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues)
|
||||
|
||||
// Load org-level issue-scoped custom fields for the new issue sidebar.
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("NewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
// Resolve template custom_fields (name → value) to field IDs.
|
||||
if len(templateCustomFields) > 0 {
|
||||
for _, def := range customFieldDefs {
|
||||
if val, ok := templateCustomFields[def.Name]; ok {
|
||||
customFieldValues[def.ID] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = customFieldValues
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
|
||||
@@ -377,6 +408,9 @@ func NewIssuePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values submitted from the new issue form.
|
||||
saveCustomFieldsFromForm(ctx, repo.OwnerID, issue.ID)
|
||||
|
||||
log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||
// When issue is in multiple projects, redirect to first project from form order.
|
||||
@@ -392,3 +426,29 @@ func NewIssuePost(ctx *context.Context) {
|
||||
}
|
||||
ctx.JSONRedirect(issue.Link())
|
||||
}
|
||||
|
||||
// saveCustomFieldsFromForm reads custom field values from the form
|
||||
// (submitted as "custom-field-{fieldID}") and persists them for the issue.
|
||||
func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) {
|
||||
defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: GetCustomFieldsByOwner: %v", err)
|
||||
return
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
return
|
||||
}
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
v := ctx.Req.FormValue(fmt.Sprintf("custom-field-%d", def.ID))
|
||||
if v != "" {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: %v", err)
|
||||
ctx.Flash.Error("Failed to save custom field values")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
@@ -337,6 +338,32 @@ func ViewIssue(ctx *context.Context) {
|
||||
|
||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
|
||||
// Load custom fields for the issue sidebar (org-level issue-scoped fields).
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
var cvErr error
|
||||
customFieldValues, cvErr = issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if cvErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldValuesMap: %v", cvErr)
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = customFieldValues
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
if err := issue.LoadAttributes(ctx); err != nil {
|
||||
|
||||
@@ -90,6 +90,10 @@ func Licenses(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always load the master key for display (prefix + status).
|
||||
masterKey, _ := licenses.GetMasterKey(ctx, ownerID)
|
||||
ctx.Data["MasterKey"] = masterKey
|
||||
|
||||
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicensePackages", err)
|
||||
@@ -183,15 +187,16 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
}
|
||||
|
||||
pkg := &licenses.LicensePackage{
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
DomainLockHours: domainLockHours,
|
||||
AllowedChannels: allowedChannels,
|
||||
RepoScope: repoScope,
|
||||
IsActive: true,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
DomainLockHours: domainLockHours,
|
||||
AllowedChannels: allowedChannels,
|
||||
DomainRestriction: strings.TrimSpace(ctx.FormString("domain_restriction")),
|
||||
RepoScope: repoScope,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
|
||||
@@ -203,9 +208,62 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
}
|
||||
|
||||
// LicensesRegenerateMasterKey handles POST to regenerate the master key.
|
||||
func LicensesRegenerateMasterKey(ctx *context.Context) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
// Deactivate the old master key.
|
||||
oldKey, _ := licenses.GetMasterKey(ctx, ownerID)
|
||||
if oldKey != nil {
|
||||
oldKey.IsActive = false
|
||||
_ = licenses.UpdateLicenseKey(ctx, oldKey)
|
||||
}
|
||||
|
||||
// Find the master package.
|
||||
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicensePackages", err)
|
||||
return
|
||||
}
|
||||
var masterPkg *licenses.LicensePackage
|
||||
for _, pkg := range pkgs {
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
masterPkg = pkg
|
||||
break
|
||||
}
|
||||
}
|
||||
if masterPkg == nil {
|
||||
ctx.Flash.Error("Master package not found")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new master key.
|
||||
newKey := &licenses.LicenseKey{
|
||||
PackageID: masterPkg.ID,
|
||||
OwnerID: ownerID,
|
||||
IsInternal: true,
|
||||
IsActive: true,
|
||||
}
|
||||
rawKey, err := licenses.CreateLicenseKey(ctx, newKey)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateLicenseKey", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.master_key_regenerated"))
|
||||
ctx.Data["NewMasterKey"] = rawKey
|
||||
Licenses(ctx)
|
||||
}
|
||||
|
||||
// LicensesGenerateKey handles POST to generate a new key from a package.
|
||||
func LicensesGenerateKey(ctx *context.Context) {
|
||||
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
|
||||
// Accept package_id from form body or query string (modal sets it via form action URL).
|
||||
pkgIDStr := ctx.FormString("package_id")
|
||||
if pkgIDStr == "" {
|
||||
pkgIDStr = ctx.Req.URL.Query().Get("package_id")
|
||||
}
|
||||
packageID, _ := strconv.ParseInt(pkgIDStr, 10, 64)
|
||||
if packageID == 0 {
|
||||
ctx.Flash.Error("Invalid package")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
@@ -218,10 +276,19 @@ func LicensesGenerateKey(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Domain restriction: use form input, fall back to package default.
|
||||
domainRestriction := strings.TrimSpace(ctx.FormString("domain_restriction"))
|
||||
if domainRestriction == "" && pkg.DomainRestriction != "" {
|
||||
domainRestriction = pkg.DomainRestriction
|
||||
}
|
||||
|
||||
key := &licenses.LicenseKey{
|
||||
PackageID: packageID,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
IsActive: true,
|
||||
PackageID: packageID,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
IsActive: true,
|
||||
DomainRestriction: domainRestriction,
|
||||
LicenseeName: strings.TrimSpace(ctx.FormString("licensee_name")),
|
||||
LicenseeEmail: strings.TrimSpace(ctx.FormString("licensee_email")),
|
||||
}
|
||||
|
||||
// Auto-calculate expiry from package duration.
|
||||
@@ -448,6 +515,7 @@ func LicensesEditPackagePost(ctx *context.Context) {
|
||||
pkg.AllowedChannels = ""
|
||||
}
|
||||
|
||||
pkg.DomainRestriction = strings.TrimSpace(ctx.FormString("domain_restriction"))
|
||||
pkg.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsAdvanced templates.TplName = "repo/settings/advanced"
|
||||
|
||||
// AdvancedSettings displays the advanced (feature units) settings page.
|
||||
func AdvancedSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.advanced_settings")
|
||||
ctx.Data["PageIsSettingsOptions"] = false
|
||||
ctx.Data["PageIsSettingsAdvanced"] = true
|
||||
ctx.HTML(http.StatusOK, tplSettingsAdvanced)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsCustomFields templates.TplName = "repo/settings/custom_fields"
|
||||
|
||||
// CustomFields displays the custom fields settings page.
|
||||
func CustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.custom_fields")
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByRepo(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsCustomFields)
|
||||
}
|
||||
|
||||
// CustomFieldsCreatePost creates a new custom field definition.
|
||||
func CustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsEditPost updates a custom field definition.
|
||||
func CustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsDeletePost deletes a custom field definition.
|
||||
func CustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsLicensing templates.TplName = "repo/settings/licensing"
|
||||
|
||||
// LicensingSettings displays the licensing settings page.
|
||||
func LicensingSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.licensing_section")
|
||||
ctx.Data["PageIsSettingsLicensing"] = true
|
||||
|
||||
repoCfg, _ := licenses_model.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
|
||||
ctx.Data["RepoUpdateConfig"] = repoCfg
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsLicensing)
|
||||
}
|
||||
|
||||
// LicensingSettingsPost saves the licensing settings.
|
||||
func LicensingSettingsPost(ctx *context.Context) {
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
updatePlatform := ctx.FormString("update_platform")
|
||||
if updatePlatform == "" {
|
||||
updatePlatform = "joomla"
|
||||
}
|
||||
|
||||
updateCfg := &licenses_model.UpdateStreamConfig{
|
||||
OwnerID: repo.OwnerID,
|
||||
RepoID: repo.ID,
|
||||
Platform: updatePlatform,
|
||||
LicensingEnabled: ctx.FormString("enable_licensing") == "on",
|
||||
RequireKey: ctx.FormString("require_update_key") == "on",
|
||||
DownloadGating: ctx.FormString("download_gating"),
|
||||
SupportURL: ctx.FormString("support_url"),
|
||||
ExtensionName: ctx.FormString("extension_name"),
|
||||
DisplayName: ctx.FormString("display_name"),
|
||||
ExtensionType: ctx.FormString("extension_type"),
|
||||
TargetVersion: ctx.FormString("target_version"),
|
||||
Maintainer: ctx.FormString("maintainer"),
|
||||
PHPMinimum: ctx.FormString("php_minimum"),
|
||||
StreamMode: "joomla",
|
||||
}
|
||||
|
||||
if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil {
|
||||
log.Error("SaveConfig: %v", err)
|
||||
ctx.ServerError("SaveConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/licensing")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsMetadata templates.TplName = "repo/settings/metadata"
|
||||
|
||||
// Metadata displays the repo metadata page (repo-scoped custom field values).
|
||||
func Metadata(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.metadata")
|
||||
ctx.Data["PageIsSettingsMetadata"] = true
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
ctx.Data["CustomFieldDefs"] = fields
|
||||
|
||||
values := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(fields) > 0 {
|
||||
values, _ = issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
for _, f := range fields {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = values
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsMetadata)
|
||||
}
|
||||
|
||||
// MetadataPost saves repo-scoped custom field values.
|
||||
func MetadataPost(ctx *context.Context) {
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
for _, f := range fields {
|
||||
val := ctx.Req.FormValue(fmt.Sprintf("field_%d", f.ID))
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, f.ID, val); err != nil {
|
||||
ctx.ServerError("SetCustomFieldValue", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.metadata_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/metadata")
|
||||
}
|
||||
@@ -703,7 +703,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
||||
log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/advanced")
|
||||
}
|
||||
|
||||
func handleSettingsPostSigning(ctx *context.Context) {
|
||||
@@ -1055,26 +1055,40 @@ func handleSettingsPostVisibility(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
private := ctx.FormOptionalBool("private").ValueOrDefault(true) // default to true for privacy & safety
|
||||
visibility := ctx.FormString("visibility")
|
||||
// Backward compat: if old "private" field is sent instead of "visibility"
|
||||
if visibility == "" {
|
||||
private := ctx.FormOptionalBool("private").ValueOrDefault(true)
|
||||
if private {
|
||||
visibility = "private"
|
||||
} else {
|
||||
visibility = "public"
|
||||
}
|
||||
}
|
||||
|
||||
// System repos (dot-prefixed) cannot be made public, regardless of user role.
|
||||
if !private && repo.IsSystemRepo() {
|
||||
isPrivate := visibility == "private" || visibility == "hidden"
|
||||
isHidden := visibility == "hidden"
|
||||
|
||||
if !isPrivate && repo.IsSystemRepo() {
|
||||
ctx.JSONError(ctx.Tr("repo.settings.visibility.system_repo_private"))
|
||||
return
|
||||
}
|
||||
// when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
|
||||
if !private && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin {
|
||||
if !isPrivate && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin {
|
||||
ctx.JSONError(ctx.Tr("form.repository_force_private"))
|
||||
return
|
||||
}
|
||||
if private && repo.FullName() != ctx.FormString("confirm_repo_name") {
|
||||
ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name"))
|
||||
|
||||
err := repo_service.MakeRepoPrivate(ctx, repo, isPrivate)
|
||||
if err != nil {
|
||||
log.Error("Tried to change the visibility of the repo: %s", err)
|
||||
ctx.JSONError(ctx.Tr("repo.settings.visibility.error"))
|
||||
return
|
||||
}
|
||||
|
||||
err := repo_service.MakeRepoPrivate(ctx, repo, private)
|
||||
if err != nil {
|
||||
log.Error("Tried to change the visibility of the repo: %s", err)
|
||||
// Update IsHidden separately.
|
||||
repo.IsHidden = isHidden
|
||||
if err := repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "is_hidden"); err != nil {
|
||||
log.Error("Failed to update is_hidden: %s", err)
|
||||
ctx.JSONError(ctx.Tr("repo.settings.visibility.error"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,30 +27,8 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
|
||||
}
|
||||
|
||||
if rawKey == "" {
|
||||
cfg := licenses.GetEffectiveConfig(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
|
||||
feedVis := "public"
|
||||
requireKey := false
|
||||
if cfg != nil {
|
||||
requireKey = cfg.RequireKey
|
||||
if cfg.FeedVisibility != "" {
|
||||
feedVis = cfg.FeedVisibility
|
||||
}
|
||||
}
|
||||
|
||||
if requireKey {
|
||||
switch feedVis {
|
||||
case "hidden":
|
||||
// Fully hidden — return empty feed.
|
||||
return nil, false, false
|
||||
case "no-download":
|
||||
// Show versions but strip download URLs.
|
||||
return nil, true, true
|
||||
default:
|
||||
// "public" with RequireKey — still hide feed (backward compat).
|
||||
return nil, false, false
|
||||
}
|
||||
}
|
||||
// No key required — allow public access (all channels).
|
||||
// Feed is always public — shows versions and download URLs.
|
||||
// Actual file downloads are gated by CheckDownloadGating, not the feed.
|
||||
return nil, true, false
|
||||
}
|
||||
|
||||
@@ -116,7 +94,9 @@ func ServeUpdatesXML(ctx *context.Context) {
|
||||
}
|
||||
|
||||
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
|
||||
requireKey := repoCfg != nil && repoCfg.RequireKey
|
||||
// Show <downloadkey> only when downloads are gated (prerelease or all).
|
||||
// No gating = no license keys needed = no downloadkey element.
|
||||
requireKey := repoCfg != nil && repoCfg.DownloadGating != "" && repoCfg.DownloadGating != "none"
|
||||
|
||||
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, stripDownloads, allowedChannels...)
|
||||
if err != nil {
|
||||
@@ -211,3 +191,133 @@ func ServeWordPressJSON(ctx *context.Context) {
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(jsonData)
|
||||
}
|
||||
|
||||
// ServeComposerJSON generates and serves a Composer packages.json feed.
|
||||
func ServeComposerJSON(ctx *context.Context) {
|
||||
platform := ctx.Data["RepoUpdatePlatform"]
|
||||
if platform != "composer" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, ok, _ := validateUpdateKey(ctx)
|
||||
if !ok {
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write([]byte(`{"packages":{}}`))
|
||||
return
|
||||
}
|
||||
|
||||
licenseKey := ctx.FormString("key")
|
||||
if licenseKey == "" {
|
||||
licenseKey = ctx.FormString("dlid")
|
||||
}
|
||||
|
||||
data, err := updateserver.GenerateComposerJSON(ctx, ctx.Repo.Repository, licenseKey)
|
||||
if err != nil {
|
||||
ctx.ServerError("GenerateComposerJSON", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
ctx.ServerError("json.Marshal", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(jsonData)
|
||||
}
|
||||
|
||||
// ServePrestaShopXML generates and serves a PrestaShop module update XML.
|
||||
func ServePrestaShopXML(ctx *context.Context) {
|
||||
platform := ctx.Data["RepoUpdatePlatform"]
|
||||
if platform != "prestashop" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
allowedChannels, ok, _ := validateUpdateKey(ctx)
|
||||
if !ok {
|
||||
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><modules></modules>`))
|
||||
return
|
||||
}
|
||||
|
||||
xmlData, err := updateserver.GeneratePrestaShopXML(ctx, ctx.Repo.Repository, allowedChannels...)
|
||||
if err != nil {
|
||||
ctx.ServerError("GeneratePrestaShopXML", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(xmlData)
|
||||
}
|
||||
|
||||
// ServeDrupalXML generates and serves a Drupal update status XML.
|
||||
func ServeDrupalXML(ctx *context.Context) {
|
||||
platform := ctx.Data["RepoUpdatePlatform"]
|
||||
if platform != "drupal" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
allowedChannels, ok, _ := validateUpdateKey(ctx)
|
||||
if !ok {
|
||||
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><project></project>`))
|
||||
return
|
||||
}
|
||||
|
||||
xmlData, err := updateserver.GenerateDrupalXML(ctx, ctx.Repo.Repository, allowedChannels...)
|
||||
if err != nil {
|
||||
ctx.ServerError("GenerateDrupalXML", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(xmlData)
|
||||
}
|
||||
|
||||
// ServeWHMCSJSON generates and serves a WHMCS module update JSON.
|
||||
func ServeWHMCSJSON(ctx *context.Context) {
|
||||
platform := ctx.Data["RepoUpdatePlatform"]
|
||||
if platform != "whmcs" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, ok, _ := validateUpdateKey(ctx)
|
||||
if !ok {
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write([]byte(`{"name":"","version":"0.0.0"}`))
|
||||
return
|
||||
}
|
||||
|
||||
licenseKey := ctx.FormString("key")
|
||||
if licenseKey == "" {
|
||||
licenseKey = ctx.FormString("dlid")
|
||||
}
|
||||
|
||||
data, err := updateserver.GenerateWHMCSJSON(ctx, ctx.Repo.Repository, licenseKey)
|
||||
if err != nil {
|
||||
ctx.ServerError("GenerateWHMCSJSON", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
ctx.ServerError("json.Marshal", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(jsonData)
|
||||
}
|
||||
|
||||
+20
-3
@@ -1061,6 +1061,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Get("", org.SettingsUpdateStreams)
|
||||
m.Post("", org.SettingsUpdateStreamsPost)
|
||||
})
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.SettingsCustomFields)
|
||||
m.Post("", org.SettingsCustomFieldsCreatePost)
|
||||
m.Post("/{id}/edit", org.SettingsCustomFieldsEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsCustomFieldsDeletePost)
|
||||
})
|
||||
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}))
|
||||
}, reqSignIn)
|
||||
@@ -1183,6 +1189,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/avatar/delete", repo_setting.SettingsDeleteAvatar)
|
||||
|
||||
m.Combo("/public_access").Get(repo_setting.PublicAccess).Post(repo_setting.PublicAccessPost)
|
||||
m.Group("", func() {
|
||||
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
|
||||
}, repo_setting.SettingsCtxData)
|
||||
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
|
||||
m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost)
|
||||
|
||||
m.Group("/collaboration", func() {
|
||||
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
|
||||
@@ -1387,6 +1398,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn)
|
||||
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
|
||||
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
|
||||
m.Post("/{id}/custom-fields/{field_id}", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomField)
|
||||
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
|
||||
m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin)
|
||||
m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove)
|
||||
@@ -1519,8 +1531,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Get("/updates.xml", repo.ServeUpdatesXML)
|
||||
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
|
||||
m.Get("/updates/wordpress.json", repo.ServeWordPressJSON)
|
||||
m.Get("/updates/packages.json", repo.ServeComposerJSON)
|
||||
m.Get("/updates/prestashop.xml", repo.ServePrestaShopXML)
|
||||
m.Get("/updates/drupal.xml", repo.ServeDrupalXML)
|
||||
m.Get("/updates/whmcs.json", repo.ServeWHMCSJSON)
|
||||
m.Get("/changelog.xml", repo.ServeChangelogXML)
|
||||
}, optSignIn, context.RepoAssignment)
|
||||
}, context.RepoAssignmentPublicFeed())
|
||||
// end "/{username}/{reponame}": update server
|
||||
|
||||
// "/{username}/{reponame}": licenses page
|
||||
@@ -1535,12 +1551,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/packages/{id}/archive", repo.LicensesArchivePackage)
|
||||
m.Post("/packages/{id}/unarchive", repo.LicensesUnarchivePackage)
|
||||
m.Post("/keys/generate", repo.LicensesGenerateKey)
|
||||
m.Post("/master-key/regenerate", repo.LicensesRegenerateMasterKey)
|
||||
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
|
||||
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
|
||||
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
|
||||
m.Post("/keys/{id}/renew", repo.LicensesRenewKey)
|
||||
m.Post("/keys/{id}/delete", repo.LicensesDeleteKey)
|
||||
}, optSignIn, context.RepoAssignment)
|
||||
}, reqSignIn, context.RepoAssignment)
|
||||
// end "/{username}/{reponame}": licenses
|
||||
|
||||
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
|
||||
@@ -1620,7 +1637,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/workflows/{workflow_name}", func() {
|
||||
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)
|
||||
})
|
||||
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
|
||||
}, reqSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
|
||||
// end "/{username}/{reponame}/actions"
|
||||
|
||||
m.Group("/{username}/{reponame}/wiki", func() {
|
||||
|
||||
@@ -169,6 +169,27 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
||||
ctx.HTML(http.StatusNotFound, "status/404")
|
||||
}
|
||||
|
||||
// Forbidden displays a styled 403 (Access Denied) page, matching the 404 page layout.
|
||||
func (ctx *Context) Forbidden() {
|
||||
showHTML := false
|
||||
for _, part := range ctx.Req.Header["Accept"] {
|
||||
if strings.Contains(part, "text/html") {
|
||||
showHTML = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !showHTML {
|
||||
ctx.plainTextInternal(3, http.StatusForbidden, []byte("Access denied.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
||||
ctx.Data["Title"] = "Access Denied"
|
||||
ctx.Data["CurrentURL"] = ctx.Req.URL.RequestURI()
|
||||
ctx.HTML(http.StatusForbidden, "status/403")
|
||||
}
|
||||
|
||||
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
|
||||
// If the error is controlled by our error system, a related 404 page can be displayed instead.
|
||||
func (ctx *Context) ServerError(logMsg string, logErr error) {
|
||||
|
||||
@@ -78,6 +78,10 @@ func RequireUnitWriter(unitTypes ...unit.Type) func(ctx *Context) {
|
||||
// RequireUnitReader returns a middleware for requiring repository write to one of the unit permission
|
||||
func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) {
|
||||
return func(ctx *Context) {
|
||||
// Licensed read-only mode grants read access to all units.
|
||||
if ctx.Data["LicensedReadOnly"] == true {
|
||||
return
|
||||
}
|
||||
for _, unitType := range unitTypes {
|
||||
if ctx.Repo.Permission.CanRead(unitType) {
|
||||
return
|
||||
|
||||
@@ -435,8 +435,53 @@ func repoAssignmentLegacy(ctx *Context, data *repoAssignmentPrepareDataStruct) {
|
||||
EarlyResponseForGoGetMeta(ctx)
|
||||
return
|
||||
}
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
|
||||
// Check if licensing is enabled — licensed repos allow access to
|
||||
// releases and downloads via license key, even without membership.
|
||||
orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID)
|
||||
repoCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
|
||||
licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) ||
|
||||
(repoCfg != nil && repoCfg.LicensingEnabled)
|
||||
|
||||
if licensingEnabled {
|
||||
// Check if a license key is provided in query params (for Joomla/WP clients).
|
||||
hasKey := ctx.FormString("dlid") != "" || ctx.FormString("key") != "" || ctx.FormString("download_key") != ""
|
||||
|
||||
// Check if downloads are set to public (download_gating=none means no key needed).
|
||||
// Only apply to release/download paths, not the main repo page.
|
||||
downloadsPublic := false
|
||||
reqPath := ctx.Req.URL.Path
|
||||
isDownloadPath := strings.Contains(reqPath, "/releases/") || strings.Contains(reqPath, "/archive/")
|
||||
if isDownloadPath {
|
||||
// Allow anonymous access to download paths — the actual gating
|
||||
// is done by CheckDownloadGating in the handler, which checks
|
||||
// the stream (stable vs prerelease) and validates the key.
|
||||
// RepoAssignment just needs to let the request through.
|
||||
downloadsPublic = true
|
||||
}
|
||||
|
||||
if ctx.IsSigned || hasKey || downloadsPublic {
|
||||
// Grant read-only access — downloads gated by CheckDownloadGating handler.
|
||||
ctx.Data["LicensingEnabled"] = licensingEnabled
|
||||
ctx.Data["HideReleaseDownloads"] = !hasKey && !ctx.IsSigned
|
||||
ctx.Data["LicensedReadOnly"] = true
|
||||
// Continue — don't block access.
|
||||
} else if repo.IsHidden {
|
||||
// Hidden repo: 404 — pretend it doesn't exist.
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
} else {
|
||||
// Private repo: 403 — access denied with styled page.
|
||||
ctx.Forbidden()
|
||||
return
|
||||
}
|
||||
} else if repo.IsHidden {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
} else {
|
||||
ctx.Forbidden()
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Data["Permission"] = &ctx.Repo.Permission
|
||||
|
||||
@@ -618,6 +663,8 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
|
||||
ctx.Data["NumLicensePackages"] = numLicensePackages
|
||||
ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0
|
||||
ctx.Data["LicensingEnabled"] = licensingEnabled
|
||||
downloadGated := repoUpdateCfg != nil && repoUpdateCfg.DownloadGating != "" && repoUpdateCfg.DownloadGating != "none"
|
||||
ctx.Data["DownloadGated"] = downloadGated
|
||||
|
||||
// Determine release page access based on feed visibility mode.
|
||||
feedVis := "public"
|
||||
@@ -628,10 +675,29 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
|
||||
feedVis = repoUpdateCfg.FeedVisibility
|
||||
}
|
||||
ctx.Data["FeedVisibility"] = feedVis
|
||||
// Only "hidden" mode requires login. "no-download" shows page but hides files.
|
||||
// Only "hidden" mode requires login for the page itself.
|
||||
ctx.Data["ReleasesRequireLogin"] = licensingEnabled && feedVis == "hidden"
|
||||
// Hide download attachments for anonymous users in "no-download" mode.
|
||||
ctx.Data["HideReleaseDownloads"] = licensingEnabled && feedVis == "no-download" && !ctx.IsSigned
|
||||
|
||||
// Determine download gating mode.
|
||||
downloadGating := "none"
|
||||
if orgCfg != nil && orgCfg.DownloadGating != "" {
|
||||
downloadGating = orgCfg.DownloadGating
|
||||
}
|
||||
if repoUpdateCfg != nil && repoUpdateCfg.DownloadGating != "" {
|
||||
downloadGating = repoUpdateCfg.DownloadGating
|
||||
}
|
||||
ctx.Data["DownloadGating"] = downloadGating
|
||||
|
||||
// Hide download links on release page when:
|
||||
// - licensing enabled AND feed visibility is "no-download" (anonymous only), OR
|
||||
// - licensing enabled AND download gating is active AND user not signed in
|
||||
hideDownloads := false
|
||||
if licensingEnabled && !ctx.IsSigned {
|
||||
if feedVis == "no-download" || feedVis == "hidden" || downloadGating != "none" {
|
||||
hideDownloads = true
|
||||
}
|
||||
}
|
||||
ctx.Data["HideReleaseDownloads"] = hideDownloads
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// RepoAssignmentPublicFeed is a lightweight repo loader for update feed endpoints.
|
||||
// It loads the repo and owner without checking user permissions — the feed
|
||||
// handlers gate access via license keys instead of repo membership.
|
||||
// This allows private repos to serve update feeds to anonymous clients.
|
||||
func RepoAssignmentPublicFeed() func(ctx *Context) {
|
||||
return func(ctx *Context) {
|
||||
ownerName := ctx.PathParam("username")
|
||||
repoName := ctx.PathParam("reponame")
|
||||
|
||||
owner, err := user_model.GetUserByName(ctx, ownerName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetUserByName", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetRepositoryByOwnerAndName", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repo.Owner = owner
|
||||
ctx.Repo.Repository = repo
|
||||
|
||||
// Load update config for platform-aware routing.
|
||||
repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
|
||||
if repoUpdateCfg != nil {
|
||||
ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform
|
||||
} else {
|
||||
ctx.Data["RepoUpdatePlatform"] = "joomla"
|
||||
}
|
||||
|
||||
log.Trace("Public feed access: %s/%s", ownerName, repoName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,11 +696,12 @@ func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Erro
|
||||
|
||||
// EditReleaseForm form for changing release
|
||||
type EditReleaseForm struct {
|
||||
Title string `form:"title" binding:"Required;MaxSize(255)"`
|
||||
Content string `form:"content"`
|
||||
Draft string `form:"draft"`
|
||||
Prerelease bool `form:"prerelease"`
|
||||
Files []string
|
||||
Title string `form:"title" binding:"Required;MaxSize(255)"`
|
||||
Content string `form:"content"`
|
||||
Draft string `form:"draft"`
|
||||
Prerelease bool `form:"prerelease"`
|
||||
Files []string
|
||||
UpdateStream string
|
||||
}
|
||||
|
||||
// Validate validates the fields
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package updateserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
|
||||
// ComposerPackage represents the packages.json response for Composer/Packagist.
|
||||
type ComposerPackage struct {
|
||||
Packages map[string]map[string]ComposerVersion `json:"packages"`
|
||||
}
|
||||
|
||||
// ComposerVersion represents a single version entry.
|
||||
type ComposerVersion struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License []string `json:"license,omitempty"`
|
||||
Authors []ComposerAuthor `json:"authors,omitempty"`
|
||||
Dist *ComposerDist `json:"dist,omitempty"`
|
||||
Require map[string]string `json:"require,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
}
|
||||
|
||||
// ComposerAuthor represents a package author.
|
||||
type ComposerAuthor struct {
|
||||
Name string `json:"name"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
}
|
||||
|
||||
// ComposerDist represents a distribution archive.
|
||||
type ComposerDist struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Shasum string `json:"shasum,omitempty"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateComposerJSON builds a Composer packages.json from repo releases.
|
||||
func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*ComposerPackage, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindReleases: %w", err)
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
// Composer package name: vendor/package (override with resolved extension name if set)
|
||||
packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name))
|
||||
if meta.Element != strings.ToLower(repo.Name) {
|
||||
packageName = meta.Element
|
||||
}
|
||||
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg != nil && cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
|
||||
phpMin := ""
|
||||
if meta.PHPMinimum != "" {
|
||||
phpMin = ">=" + meta.PHPMinimum
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
versions := make(map[string]ComposerVersion)
|
||||
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
|
||||
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
||||
if ch != "stable" {
|
||||
continue // Composer only serves stable versions
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
if isStreamName(version, streams) {
|
||||
version = extractVersion(rel.Title)
|
||||
}
|
||||
if version == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := rel.LoadAttributes(ctx); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
var sha256Hash string
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
|
||||
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||
}
|
||||
if licenseKey != "" {
|
||||
downloadURL += "?dlid=" + licenseKey
|
||||
}
|
||||
|
||||
// Look for SHA256 sidecar
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(att.Name, ".sha256") {
|
||||
sha256Hash = readSHA256FromSidecar(ctx, att)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require := make(map[string]string)
|
||||
if phpMin != "" {
|
||||
require["php"] = phpMin
|
||||
}
|
||||
|
||||
v := ComposerVersion{
|
||||
Name: packageName,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Homepage: repoLink,
|
||||
Authors: []ComposerAuthor{
|
||||
{Name: maintainer, Homepage: maintainerURL},
|
||||
},
|
||||
Dist: &ComposerDist{
|
||||
URL: downloadURL,
|
||||
Type: "zip",
|
||||
Shasum: sha256Hash,
|
||||
Reference: rel.TagName,
|
||||
},
|
||||
Require: require,
|
||||
Time: time.Unix(int64(rel.CreatedUnix), 0).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
versions[version] = v
|
||||
}
|
||||
|
||||
return &ComposerPackage{
|
||||
Packages: map[string]map[string]ComposerVersion{
|
||||
packageName: versions,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package updateserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
|
||||
// Drupal update status XML structures.
|
||||
// See: https://www.drupal.org/docs/drupal-apis/update-status-module/update-status-xml-format
|
||||
|
||||
type drupalProject struct {
|
||||
XMLName xml.Name `xml:"project"`
|
||||
Title string `xml:"title"`
|
||||
ShortName string `xml:"short_name"`
|
||||
APIVersion string `xml:"api_version"`
|
||||
RecommendedMaj string `xml:"recommended_major"`
|
||||
DefaultMajor string `xml:"default_major"`
|
||||
ProjectStatus string `xml:"project_status"`
|
||||
Link string `xml:"link"`
|
||||
Releases drupalReleases `xml:"releases"`
|
||||
}
|
||||
|
||||
type drupalReleases struct {
|
||||
Releases []drupalRelease `xml:"release"`
|
||||
}
|
||||
|
||||
type drupalRelease struct {
|
||||
Name string `xml:"name"`
|
||||
Version string `xml:"version"`
|
||||
Tag string `xml:"tag"`
|
||||
Status string `xml:"status"`
|
||||
ReleaseLink string `xml:"release_link"`
|
||||
DownloadURL string `xml:"download_link"`
|
||||
Date string `xml:"date"`
|
||||
FileHash string `xml:"mdhash,omitempty"`
|
||||
SHA256 string `xml:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateDrupalXML builds a Drupal update status-compatible XML feed.
|
||||
func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindReleases: %w", err)
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
shortName := meta.Element
|
||||
title := meta.DisplayName
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
channelAllowed := make(map[string]bool)
|
||||
if len(allowedChannels) > 0 {
|
||||
for _, c := range allowedChannels {
|
||||
channelAllowed[NormalizeChannel(c)] = true
|
||||
}
|
||||
}
|
||||
|
||||
project := drupalProject{
|
||||
Title: title,
|
||||
ShortName: shortName,
|
||||
APIVersion: "7.x", // default API version
|
||||
RecommendedMaj: "1",
|
||||
DefaultMajor: "1",
|
||||
ProjectStatus: "published",
|
||||
Link: repoLink,
|
||||
}
|
||||
|
||||
if cfg != nil && cfg.TargetVersion != "" {
|
||||
project.APIVersion = cfg.TargetVersion
|
||||
}
|
||||
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
||||
if len(channelAllowed) > 0 && !channelAllowed[ch] {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := rel.LoadAttributes(ctx); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var downloadURL, sha256Hash string
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
|
||||
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||
}
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(att.Name, ".sha256") {
|
||||
sha256Hash = readSHA256FromSidecar(ctx, att)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
if isStreamName(version, streams) || version == "" {
|
||||
version = extractVersion(rel.Title)
|
||||
}
|
||||
if version == "" {
|
||||
version = rel.TagName
|
||||
}
|
||||
|
||||
status := "published"
|
||||
if rel.IsPrerelease {
|
||||
status = "insecure" // Drupal uses this for non-stable
|
||||
}
|
||||
|
||||
project.Releases.Releases = append(project.Releases.Releases, drupalRelease{
|
||||
Name: fmt.Sprintf("%s %s", shortName, version),
|
||||
Version: version,
|
||||
Tag: rel.TagName,
|
||||
Status: status,
|
||||
ReleaseLink: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
|
||||
DownloadURL: downloadURL,
|
||||
Date: fmt.Sprintf("%d", rel.CreatedUnix),
|
||||
SHA256: sha256Hash,
|
||||
})
|
||||
}
|
||||
|
||||
output, err := xml.MarshalIndent(project, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
|
||||
}
|
||||
|
||||
return append([]byte(xml.Header), output...), nil
|
||||
}
|
||||
+161
-43
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
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/modules/setting"
|
||||
)
|
||||
@@ -37,7 +39,7 @@ type xmlUpdate struct {
|
||||
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
|
||||
SHA256 string `xml:"sha256,omitempty"`
|
||||
SHA512 string `xml:"sha512,omitempty"`
|
||||
Client string `xml:"client"`
|
||||
Client string `xml:"client,omitempty"`
|
||||
PHPMinimum string `xml:"php_minimum,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
CreationDate string `xml:"creationDate,omitempty"`
|
||||
@@ -120,7 +122,10 @@ func isStreamName(s string, streams []licenses.StreamDef) bool {
|
||||
}
|
||||
|
||||
// joomlaTagName maps internal stream names to Joomla-standard tag values.
|
||||
// Joomla recognizes: dev, alpha, beta, rc, stable.
|
||||
// Joomla's Update.php maps tags via STABILITY_ + strtoupper(tag) constants.
|
||||
// Valid values: dev (0), alpha (1), beta (2), rc (3), stable (4).
|
||||
// Using full names like "development" or "release-candidate" would silently
|
||||
// fall back to STABILITY_STABLE, breaking pre-release channel filtering.
|
||||
func joomlaTagName(channel string) string {
|
||||
switch channel {
|
||||
case ChannelDevelopment:
|
||||
@@ -157,9 +162,121 @@ func NormalizeChannel(ch string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extensionMetadata holds resolved metadata for feed generation.
|
||||
// Fields are resolved with priority: custom field → config table → default.
|
||||
type extensionMetadata struct {
|
||||
Element string
|
||||
DisplayName string
|
||||
ExtType string
|
||||
TargetVersion string
|
||||
PHPMinimum string
|
||||
Description string
|
||||
SupportURL string
|
||||
DownloadGating string
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
// resolveExtensionMetadata loads extension metadata with cascading fallback:
|
||||
// org-level repo-scoped custom fields → update_stream_config → repo-derived defaults.
|
||||
func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *licenses.UpdateStreamConfig) extensionMetadata {
|
||||
m := extensionMetadata{
|
||||
Element: strings.ToLower(repo.Name),
|
||||
DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
|
||||
ExtType: "component",
|
||||
TargetVersion: "(5|6)\\..*",
|
||||
}
|
||||
|
||||
// Apply config table values.
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
m.Element = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
m.DisplayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.ExtensionType != "" {
|
||||
m.ExtType = cfg.ExtensionType
|
||||
}
|
||||
if cfg.TargetVersion != "" {
|
||||
m.TargetVersion = cfg.TargetVersion
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
m.PHPMinimum = cfg.PHPMinimum
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
m.Description = cfg.Description
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
m.SupportURL = cfg.SupportURL
|
||||
}
|
||||
if cfg.DownloadGating != "" {
|
||||
m.DownloadGating = cfg.DownloadGating
|
||||
}
|
||||
if cfg.KeyPrefix != "" {
|
||||
m.KeyPrefix = cfg.KeyPrefix
|
||||
}
|
||||
}
|
||||
|
||||
// Override with custom field values (highest priority).
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldsByOwner for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return m
|
||||
}
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldValuesMap for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
// Build name → value map from field definitions + values.
|
||||
named := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
if v, ok := values[f.ID]; ok && v != "" {
|
||||
named[f.Name] = v
|
||||
}
|
||||
}
|
||||
|
||||
if v := named["Extension Name"]; v != "" {
|
||||
m.Element = v
|
||||
}
|
||||
if v := named["Display Name"]; v != "" {
|
||||
m.DisplayName = v
|
||||
}
|
||||
if v := named["Extension Type"]; v != "" {
|
||||
m.ExtType = v
|
||||
}
|
||||
if v := named["Target Version"]; v != "" {
|
||||
m.TargetVersion = v
|
||||
}
|
||||
if v := named["PHP Minimum"]; v != "" {
|
||||
m.PHPMinimum = v
|
||||
}
|
||||
if v := named["Support URL"]; v != "" {
|
||||
m.SupportURL = v
|
||||
}
|
||||
if v := named["Description"]; v != "" {
|
||||
m.Description = v
|
||||
}
|
||||
if v := named["Download Gating"]; v != "" {
|
||||
m.DownloadGating = v
|
||||
}
|
||||
if v := named["Key Prefix"]; v != "" {
|
||||
m.KeyPrefix = v
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
|
||||
// It returns the raw XML bytes. Extension metadata is read from the update stream config;
|
||||
// falls back to repo name/owner when not configured.
|
||||
// It returns the raw XML bytes. Extension metadata is resolved from custom fields first,
|
||||
// then the update stream config, then repo-derived defaults.
|
||||
// allowedChannels optionally restricts output to specific channels (nil = all).
|
||||
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
@@ -182,40 +299,26 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
}
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata from config (falls back to repo-derived values).
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
element := strings.ToLower(repo.Name)
|
||||
if cfg != nil && cfg.ExtensionName != "" {
|
||||
element = cfg.ExtensionName
|
||||
element := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
extType := meta.ExtType
|
||||
targetVersion := meta.TargetVersion
|
||||
phpMinimum := meta.PHPMinimum
|
||||
feedDescription := meta.Description
|
||||
|
||||
// Maintainer and URL always come from the org profile.
|
||||
maintainer := repo.Owner.FullName
|
||||
if maintainer == "" {
|
||||
maintainer = repo.Owner.Name
|
||||
}
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
if cfg != nil && cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
extType := "component"
|
||||
if cfg != nil && cfg.ExtensionType != "" {
|
||||
extType = cfg.ExtensionType
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
targetVersion := ".*"
|
||||
if cfg != nil && cfg.TargetVersion != "" {
|
||||
targetVersion = cfg.TargetVersion
|
||||
}
|
||||
phpMinimum := ""
|
||||
if cfg != nil && cfg.PHPMinimum != "" {
|
||||
phpMinimum = cfg.PHPMinimum
|
||||
}
|
||||
feedDescription := ""
|
||||
if cfg != nil && cfg.Description != "" {
|
||||
feedDescription = cfg.Description
|
||||
maintainerURL := repo.Owner.Website
|
||||
if maintainerURL == "" {
|
||||
maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
}
|
||||
|
||||
// Resolve effective streams (repo override → org default → Joomla default).
|
||||
@@ -284,9 +387,16 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
// If the tag is a stream name (not a version), try the release title instead.
|
||||
if version == "" || isStreamName(rel.TagName, streams) {
|
||||
// Extract version: prefer asset filename (matches actual download),
|
||||
// then tag name, then release title. Only fall through when empty.
|
||||
version := ""
|
||||
if zipName != "" {
|
||||
version = extractVersion(zipName)
|
||||
}
|
||||
if version == "" {
|
||||
version = extractVersion(rel.TagName)
|
||||
}
|
||||
if version == "" {
|
||||
version = extractVersion(rel.Title)
|
||||
}
|
||||
// Last resort: use the tag name as-is.
|
||||
@@ -306,11 +416,19 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
desc = fmt.Sprintf("%s %s build.", displayName, ch)
|
||||
}
|
||||
|
||||
// Info URL: use support_url (product page), fall back to releases page.
|
||||
infoURL := fmt.Sprintf("%s/releases", repoLink)
|
||||
if cfg != nil && cfg.SupportURL != "" {
|
||||
infoURL = cfg.SupportURL
|
||||
} else if cfg != nil && cfg.InfoURL != "" {
|
||||
infoURL = cfg.InfoURL
|
||||
if meta.SupportURL != "" {
|
||||
infoURL = meta.SupportURL
|
||||
}
|
||||
|
||||
// Joomla <client> element: packages use client_id=0 in #__extensions,
|
||||
// so we must output <client>0</client> for Joomla to match the update
|
||||
// to the installed extension. Other types default to "site" (client_id=0)
|
||||
// or "administrator" (client_id=1).
|
||||
client := "site"
|
||||
if extType == "package" {
|
||||
client = "0"
|
||||
}
|
||||
|
||||
u := xmlUpdate{
|
||||
@@ -318,7 +436,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
Description: desc,
|
||||
Element: element,
|
||||
Type: extType,
|
||||
Client: "site",
|
||||
Client: client,
|
||||
Version: version,
|
||||
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
|
||||
InfoURL: xmlInfoURL{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package updateserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
|
||||
// PrestaShop module update XML structures.
|
||||
|
||||
type psUpdates struct {
|
||||
XMLName xml.Name `xml:"modules"`
|
||||
Modules []psModule `xml:"module"`
|
||||
}
|
||||
|
||||
type psModule struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Version string `xml:"version,attr"`
|
||||
DisplayName string `xml:"displayName"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
Author string `xml:"author"`
|
||||
Tab string `xml:"tab,omitempty"`
|
||||
Download string `xml:"download"`
|
||||
Date string `xml:"date"`
|
||||
SHA256 string `xml:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// GeneratePrestaShopXML builds a PrestaShop-compatible module update XML.
|
||||
func GeneratePrestaShopXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindReleases: %w", err)
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
moduleName := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
// Channel filtering.
|
||||
channelAllowed := make(map[string]bool)
|
||||
if len(allowedChannels) > 0 {
|
||||
for _, c := range allowedChannels {
|
||||
channelAllowed[NormalizeChannel(c)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Track best release per channel.
|
||||
bestByChannel := make(map[string]*repo_model.Release)
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
||||
existing, ok := bestByChannel[ch]
|
||||
if !ok || rel.CreatedUnix > existing.CreatedUnix {
|
||||
bestByChannel[ch] = rel
|
||||
}
|
||||
}
|
||||
|
||||
var result psUpdates
|
||||
for _, stream := range streams {
|
||||
ch := stream.Name
|
||||
if len(channelAllowed) > 0 && !channelAllowed[ch] {
|
||||
continue
|
||||
}
|
||||
rel, ok := bestByChannel[ch]
|
||||
if !ok || ch != "stable" {
|
||||
continue // PrestaShop typically only serves stable
|
||||
}
|
||||
|
||||
if err := rel.LoadAttributes(ctx); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var downloadURL, sha256Hash string
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
|
||||
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||
}
|
||||
for _, att := range rel.Attachments {
|
||||
if strings.HasSuffix(att.Name, ".sha256") {
|
||||
sha256Hash = readSHA256FromSidecar(ctx, att)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
if isStreamName(version, streams) || version == "" {
|
||||
version = extractVersion(rel.Title)
|
||||
}
|
||||
if version == "" {
|
||||
version = rel.TagName
|
||||
}
|
||||
|
||||
result.Modules = append(result.Modules, psModule{
|
||||
Name: moduleName,
|
||||
Version: version,
|
||||
DisplayName: displayName,
|
||||
Description: description,
|
||||
Author: maintainer,
|
||||
Download: downloadURL,
|
||||
Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
|
||||
SHA256: sha256Hash,
|
||||
})
|
||||
}
|
||||
|
||||
output, err := xml.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
|
||||
}
|
||||
|
||||
return append([]byte(xml.Header), output...), nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package updateserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
|
||||
// WHMCS module update JSON structures.
|
||||
// WHMCS marketplace modules check for updates via a simple JSON endpoint.
|
||||
|
||||
type WHMCSUpdate struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Changelog string `json:"changelog,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Author string `json:"author,omitempty"`
|
||||
AuthorURL string `json:"author_url,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateWHMCSJSON builds a WHMCS-compatible module update response.
|
||||
func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WHMCSUpdate, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindReleases: %w", err)
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil {
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
// Find latest stable release.
|
||||
var latestStable *repo_model.Release
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
||||
if ch == "stable" {
|
||||
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
|
||||
latestStable = rel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latestStable == nil {
|
||||
return &WHMCSUpdate{
|
||||
Name: displayName,
|
||||
Version: "0.0.0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := latestStable.LoadAttributes(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
var downloadURL, sha256Hash string
|
||||
for _, att := range latestStable.Attachments {
|
||||
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
|
||||
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName)
|
||||
}
|
||||
if licenseKey != "" {
|
||||
downloadURL += "?dlid=" + licenseKey
|
||||
}
|
||||
for _, att := range latestStable.Attachments {
|
||||
if strings.HasSuffix(att.Name, ".sha256") {
|
||||
sha256Hash = readSHA256FromSidecar(ctx, att)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
version := extractVersion(latestStable.TagName)
|
||||
if isStreamName(version, streams) || version == "" {
|
||||
version = extractVersion(latestStable.Title)
|
||||
}
|
||||
if version == "" {
|
||||
version = latestStable.TagName
|
||||
}
|
||||
|
||||
changelog := ""
|
||||
if latestStable.Note != "" {
|
||||
changelog = latestStable.Note
|
||||
}
|
||||
|
||||
return &WHMCSUpdate{
|
||||
Name: displayName,
|
||||
Version: version,
|
||||
Description: description,
|
||||
DownloadURL: downloadURL,
|
||||
Changelog: changelog,
|
||||
ReleaseDate: time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02"),
|
||||
Author: maintainer,
|
||||
AuthorURL: maintainerURL,
|
||||
SHA256: sha256Hash,
|
||||
}, nil
|
||||
}
|
||||
@@ -57,36 +57,30 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata.
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
slug := strings.ToLower(repo.Name)
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
slug := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
requiresPHP := meta.PHPMinimum
|
||||
homepage := repoLink
|
||||
if meta.SupportURL != "" {
|
||||
homepage = meta.SupportURL
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
homepage := repoLink
|
||||
requiresPHP := ""
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
slug = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
homepage = cfg.SupportURL
|
||||
} else if cfg.InfoURL != "" {
|
||||
if homepage == repoLink && cfg.InfoURL != "" {
|
||||
homepage = cfg.InfoURL
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
requiresPHP = cfg.PHPMinimum
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve streams and find the latest stable release.
|
||||
|
||||
@@ -25,14 +25,16 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}
|
||||
<details id="new-package-details">
|
||||
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
|
||||
{{if .IsRepoAdmin}}
|
||||
<summary class="ui primary small button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .IsRepoAdmin}}
|
||||
<details class="tw-mb-4">
|
||||
<summary class="ui primary button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
|
||||
<div class="tw-mt-4">
|
||||
<div class="tw-mb-4">
|
||||
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="two fields">
|
||||
@@ -80,9 +82,9 @@
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
</details>
|
||||
{{if .LicensePackages}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings custom-fields")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.custom_fields_desc"}}</p>
|
||||
|
||||
{{if .CustomFields}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CustomFields}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{if eq .Scope "issue"}}{{svg "octicon-issue-opened" 14}} Issue{{else}}{{svg "octicon-repo" 14}} Repo{{end}}</td>
|
||||
<td><code>{{.FieldType}}</code></td>
|
||||
<td>{{if .Options}}<code class="tw-text-xs">{{.Options}}</code>{{else}}<span class="text grey">-</span>{{end}}</td>
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.OrgLink}}/settings/custom-fields/{{.ID}}/delete" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
<p>{{ctx.Locale.Tr "org.settings.custom_fields_empty"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.custom_field_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/custom-fields">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="three fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Status, Platform, Priority">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</label>
|
||||
<select name="scope" class="ui dropdown">
|
||||
<option value="issue">{{svg "octicon-issue-opened" 14}} Issue (sidebar)</option>
|
||||
<option value="repo">{{svg "octicon-repo" 14}} Repo (metadata)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</label>
|
||||
<select name="field_type" class="ui dropdown">
|
||||
<option value="dropdown">Dropdown</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</label>
|
||||
<input name="options" placeholder='["Option 1","Option 2","Option 3"]'>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.custom_field_options_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown to users">
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "org.settings.custom_field_add"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@@ -1,36 +1,39 @@
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<div class="header item">{{ctx.Locale.Tr "org.settings"}}</div>
|
||||
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "org.settings"}}</div>
|
||||
<a class="{{if .PageIsSettingsOptions}}active {{end}}item" href="{{.OrgLink}}/settings">
|
||||
{{ctx.Locale.Tr "org.settings.options"}}
|
||||
{{svg "octicon-gear"}} {{ctx.Locale.Tr "org.settings.options"}}
|
||||
</a>
|
||||
{{if not DisableWebhooks}}
|
||||
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{.OrgLink}}/settings/hooks">
|
||||
{{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsOrgSettingsLabels}}active {{end}}item" href="{{.OrgLink}}/settings/labels">
|
||||
{{ctx.Locale.Tr "repo.labels"}}
|
||||
{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.labels"}}
|
||||
</a>
|
||||
{{if .EnableOAuth2}}
|
||||
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{.OrgLink}}/settings/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
{{svg "octicon-apps"}} {{ctx.Locale.Tr "settings.applications"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
|
||||
{{ctx.Locale.Tr "user.block.list"}}
|
||||
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block.list"}}
|
||||
</a>
|
||||
{{if .EnablePackages}}
|
||||
<a class="{{if .PageIsSettingsPackages}}active {{end}}item" href="{{.OrgLink}}/settings/packages">
|
||||
{{ctx.Locale.Tr "packages.title"}}
|
||||
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsUpdateStreams}}active {{end}}item" href="{{.OrgLink}}/settings/update-streams">
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsCustomFields}}active {{end}}item" href="{{.OrgLink}}/settings/custom-fields">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</a>
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsOrgSettingsActionsGeneral}}active {{end}}item" href="{{.OrgLink}}/settings/actions">
|
||||
{{ctx.Locale.Tr "settings.general"}}
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
<input id="location" name="location" value="{{.Org.Location}}" maxlength="50">
|
||||
</div>
|
||||
|
||||
{{if .ParentOrgCandidates}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.parent_org"}}</label>
|
||||
<select name="parent_org_id" class="ui dropdown">
|
||||
<option value="0">{{ctx.Locale.Tr "org.settings.parent_org_none"}}</option>
|
||||
{{range .ParentOrgCandidates}}
|
||||
<option value="{{.ID}}" {{if eq $.ParentOrgID .ID}}selected{{end}}>{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.parent_org_help"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field" id="permission_box">
|
||||
<label>{{ctx.Locale.Tr "org.settings.permission"}}</label>
|
||||
<div class="field">
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.feed_visibility_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.key_prefix"}}</label>
|
||||
<input name="key_prefix" value="{{.StreamConfig.KeyPrefix}}" placeholder="MOKO" maxlength="20">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.key_prefix_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.download_gating"}}</label>
|
||||
<select name="download_gating" class="ui dropdown">
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}}
|
||||
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo) .IsSigned}}
|
||||
<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
|
||||
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
|
||||
{{if .Repository.NumOpenActionRuns}}
|
||||
@@ -128,11 +128,12 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if .LicensingEnabled}}
|
||||
{{if and .LicensingEnabled .IsSigned}}
|
||||
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||
{{if .NumLicensePackages}}
|
||||
<span class="ui small label">{{CountFmt .NumLicensePackages}}</span>
|
||||
{{if .DownloadGated}}
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||
{{else}}
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@@ -59,6 +59,31 @@
|
||||
{{end}}
|
||||
{{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}}
|
||||
|
||||
{{if .CustomFieldDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
{{$values := .CustomFieldValues}}
|
||||
{{$fieldOptions := .CustomFieldOptions}}
|
||||
{{range .CustomFieldDefs}}
|
||||
{{$currentVal := index $values .ID}}
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm" {{if .Description}}title="{{.Description}}"{{end}}>{{.Name}}</span>
|
||||
{{if ne .Options ""}}
|
||||
{{$opts := index $fieldOptions .ID}}
|
||||
<select name="custom-field-{{.ID}}" class="ui compact mini dropdown tw-max-w-48">
|
||||
<option value="">—</option>
|
||||
{{range $opts}}
|
||||
<option value="{{.}}" {{if eq . $currentVal}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input name="custom-field-{{.ID}}" type="text" class="tw-max-w-48 tw-text-sm" value="{{$currentVal}}" placeholder="—">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
|
||||
<div class="divider"></div>
|
||||
<div class="ui checkbox">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{if .CustomFieldDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
{{$issueID := .Issue.ID}}
|
||||
{{$repoLink := .RepoLink}}
|
||||
{{$canModify := .HasIssuesOrPullsWritePermission}}
|
||||
{{$values := .CustomFieldValues}}
|
||||
{{$fieldOptions := .CustomFieldOptions}}
|
||||
{{range .CustomFieldDefs}}
|
||||
{{$currentVal := index $values .ID}}
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm" {{if .Description}}title="{{.Description}}"{{end}}>{{.Name}}</span>
|
||||
{{if and $canModify (eq .Options "")}}
|
||||
{{/* Non-dropdown: just display the value */}}
|
||||
<span class="tw-text-sm">{{if $currentVal}}{{$currentVal}}{{else}}<span class="text grey">—</span>{{end}}</span>
|
||||
{{else if $canModify}}
|
||||
<form method="post" action="{{$repoLink}}/issues/{{$issueID}}/custom-fields/{{.ID}}" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<select name="value" class="ui compact mini dropdown tw-max-w-48" onchange="this.form.submit()">
|
||||
<option value="">—</option>
|
||||
{{$opts := index $fieldOptions .ID}}
|
||||
{{range $opts}}
|
||||
<option value="{{.}}" {{if eq . $currentVal}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="tw-text-sm">{{if $currentVal}}{{$currentVal}}{{else}}<span class="text grey">—</span>{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
{{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}}
|
||||
|
||||
{{template "repo/issue/sidebar/custom_fields" $}}
|
||||
|
||||
{{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}}
|
||||
{{if .IsProjectsEnabled}}
|
||||
{{template "repo/issue/sidebar/project_list" $.IssuePageMetaData}}
|
||||
|
||||
+113
-71
@@ -3,6 +3,7 @@
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
|
||||
{{if .DownloadGated}}
|
||||
{{if .NewMasterKey}}
|
||||
<div class="ui info message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||
@@ -26,8 +27,11 @@
|
||||
{{end}}
|
||||
|
||||
{{/* ── License Packages ── */}}
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}
|
||||
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
|
||||
{{if .IsRepoAdmin}}
|
||||
<button class="ui primary small button show-modal" data-modal="#new-package-modal">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</button>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .LicensePackages}}
|
||||
@@ -52,16 +56,12 @@
|
||||
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||
{{if $.IsRepoAdmin}}
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline tw-flex tw-gap-1 tw-items-center">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="package_id" value="{{.ID}}">
|
||||
{{if $.IsSiteAdmin}}
|
||||
<input type="text" name="custom_key" placeholder="{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}" class="tw-w-32 tw-text-xs" title="{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}">
|
||||
{{end}}
|
||||
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||
{{svg "octicon-plus" 14}}
|
||||
</button>
|
||||
</form>
|
||||
<button class="ui tiny primary button show-modal"
|
||||
data-modal="#generate-key-modal"
|
||||
data-modal-form.action="{{$.RepoLink}}/licenses/keys/generate?package_id={{.ID}}"
|
||||
title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||
{{svg "octicon-plus" 14}}
|
||||
</button>
|
||||
{{if ne .Name "Master (Internal)"}}
|
||||
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
@@ -90,64 +90,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ── Create New License Package ── */}}
|
||||
{{if .IsRepoAdmin}}
|
||||
<div class="tw-mt-4">
|
||||
<details>
|
||||
<summary class="ui primary button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
|
||||
<div class="ui segment tw-mt-2">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="two fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Pro Annual, Basic Monthly">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
|
||||
<input name="description" placeholder="e.g. Annual pro subscription with all channels">
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<div class="four wide field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
|
||||
<input name="duration_days" type="number" value="0" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
|
||||
</div>
|
||||
<div class="four wide field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="0" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="four wide field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
|
||||
<input name="domain_lock_hours" type="number" value="0" min="0">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" "" "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.repo_scope"}}</label>
|
||||
<select name="repo_scope" class="ui dropdown">
|
||||
<option value="all">{{ctx.Locale.Tr "repo.licenses.repo_scope_all"}}</option>
|
||||
{{if .OrgRepos}}
|
||||
{{range .OrgRepos}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}</p>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ── Issued Keys ── */}}
|
||||
{{if or .LicenseKeys .SearchQuery}}
|
||||
<h4 class="ui top attached header tw-mt-4">
|
||||
@@ -183,7 +125,7 @@
|
||||
<td>
|
||||
<div class="tw-flex tw-items-center tw-gap-1">
|
||||
<code class="js-license-key-{{.ID}}">{{if .KeyRaw}}{{.KeyRaw}}{{else}}{{.KeyPrefix}}{{end}}</code>
|
||||
{{if .KeyRaw}}<button class="ui tiny icon button" data-clipboard-target=".js-license-key-{{.ID}}" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 12}}</button>{{end}}
|
||||
<button class="ui tiny icon button" data-clipboard-text="{{if .KeyRaw}}{{.KeyRaw}}{{else}}{{.KeyPrefix}}{{end}}" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 12}}</button>
|
||||
{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
@@ -260,6 +202,8 @@
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{end}}{{/* end DownloadGated */}}
|
||||
|
||||
{{/* ── Update Feed URLs ── */}}
|
||||
{{if .LicensingEnabled}}
|
||||
<h4 class="ui top attached header tw-mt-4">
|
||||
@@ -272,6 +216,7 @@
|
||||
<div class="ui action input tw-w-full">
|
||||
<input class="js-feed-url-joomla" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-joomla" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates.xml" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -281,6 +226,7 @@
|
||||
<div class="ui action input tw-w-full">
|
||||
<input class="js-feed-url-dolibarr" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-dolibarr" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -290,6 +236,7 @@
|
||||
<div class="ui action input tw-w-full">
|
||||
<input class="js-feed-url-wordpress" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/wordpress.json" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-wordpress" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates/wordpress.json" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -298,6 +245,7 @@
|
||||
<div class="ui action input tw-w-full">
|
||||
<input class="js-feed-url-changelog" type="text" readonly value="{{.Repository.HTMLURL ctx}}/changelog.xml" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-changelog" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/changelog.xml" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +253,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Create New License Package Modal ── */}}
|
||||
{{if .IsRepoAdmin}}
|
||||
<div class="ui small modal" id="new-package-modal">
|
||||
<div class="header">{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</div>
|
||||
<div class="content">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Pro Annual, Basic Monthly">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
|
||||
<input name="description" placeholder="e.g. Annual pro subscription with all channels">
|
||||
</div>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
|
||||
<input name="duration_days" type="number" value="0" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="0" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
|
||||
<input name="domain_lock_hours" type="number" value="0" min="0">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" "" "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}</label>
|
||||
<input name="domain_restriction" placeholder="example.com, example.org">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_restriction_package_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.repo_scope"}}</label>
|
||||
<select name="repo_scope" class="ui dropdown">
|
||||
<option value="all">{{ctx.Locale.Tr "repo.licenses.repo_scope_all"}}</option>
|
||||
{{if .OrgRepos}}
|
||||
{{range .OrgRepos}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}</p>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.create_package"))}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ── Generate Key Modal ── */}}
|
||||
{{if and .DownloadGated .IsRepoAdmin}}
|
||||
<div class="ui small modal" id="generate-key-modal">
|
||||
<div class="header">{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}</div>
|
||||
<div class="content">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/keys/generate">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.licensee_name"}}</label>
|
||||
<input name="licensee_name" placeholder="Customer name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.licensee_email"}}</label>
|
||||
<input name="licensee_email" type="email" placeholder="customer@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}</label>
|
||||
<input name="domain_restriction" placeholder="example.com, example.org">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_restriction_help"}}</p>
|
||||
</div>
|
||||
{{if .IsSiteAdmin}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}</label>
|
||||
<input name="custom_key" placeholder="MOKO-XXXX-XXXX-XXXX">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.generate_key"))}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ── Delete Package Confirmation Modal ── */}}
|
||||
{{if .DownloadGated}}
|
||||
<div class="ui small modal" id="license-delete-package-modal">
|
||||
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_package"}}</div>
|
||||
<div class="content">
|
||||
@@ -336,5 +377,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{/* end DownloadGated for modals */}}
|
||||
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}</label>
|
||||
<input name="domain_restriction" value="{{.Package.DomainRestriction}}" placeholder="example.com, example.org">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_restriction_package_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="is_active" type="checkbox" {{if .Package.IsActive}}checked{{end}}>
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings advanced")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.advanced_settings"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post">
|
||||
<input type="hidden" name="action" value="advanced">
|
||||
|
||||
{{/* ── Code ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-code" 16}} {{ctx.Locale.Tr "repo.code"}}</h5>
|
||||
{{$isCodeEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
|
||||
{{$isCodeGlobalDisabled := ctx.Consts.RepoUnitTypeCode.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isCodeGlobalDisabled}} disabled{{end}}"{{if $isCodeGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_code" type="checkbox"{{if $isCodeEnabled}} checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.code.desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Wiki ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-book" 16}} {{ctx.Locale.Tr "repo.wiki"}}</h5>
|
||||
{{$isInternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeWiki}}
|
||||
{{$isExternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalWiki}}
|
||||
{{$isWikiEnabled := or $isInternalWikiEnabled $isExternalWikiEnabled}}
|
||||
{{$isWikiGlobalDisabled := ctx.Consts.RepoUnitTypeWiki.UnitGlobalDisabled}}
|
||||
{{$isExternalWikiGlobalDisabled := ctx.Consts.RepoUnitTypeExternalWiki.UnitGlobalDisabled}}
|
||||
{{$isBothWikiGlobalDisabled := and $isWikiGlobalDisabled $isExternalWikiGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isBothWikiGlobalDisabled}} disabled{{end}}"{{if $isBothWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_wiki" type="checkbox" data-target="#wiki_box" {{if $isWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.wiki_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field{{if not $isWikiEnabled}} disabled{{end}}" id="wiki_box">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isWikiGlobalDisabled}} disabled{{end}}"{{if $isWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="false" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isInternalWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_internal_wiki"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="internal_wiki_box" class="field tw-pl-4 {{if not $isInternalWikiEnabled}}disabled{{end}}">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
|
||||
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="wiki_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="true" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isExternalWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_external_wiki"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="external_wiki_box" class="field tw-pl-4 {{if not $isExternalWikiEnabled}}disabled{{end}}">
|
||||
<label for="external_wiki_url">{{ctx.Locale.Tr "repo.settings.external_wiki_url"}}</label>
|
||||
<input id="external_wiki_url" name="external_wiki_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.external_wiki_url_desc"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Issues ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-issue-opened" 16}} {{ctx.Locale.Tr "repo.issues"}}</h5>
|
||||
{{$isIssuesEnabled := or (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeIssues) (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
|
||||
{{$isIssuesGlobalDisabled := ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||
{{$isExternalTrackerGlobalDisabled := ctx.Consts.RepoUnitTypeExternalTracker.UnitGlobalDisabled}}
|
||||
{{$isIssuesAndExternalGlobalDisabled := and $isIssuesGlobalDisabled $isExternalTrackerGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isIssuesAndExternalGlobalDisabled}} disabled{{end}}"{{if $isIssuesAndExternalGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_issues" type="checkbox" data-target="#issue_box" {{if $isIssuesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.issues_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isIssuesGlobalDisabled}} disabled{{end}}"{{if $isIssuesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_internal_issue_tracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4 {{if (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
|
||||
{{if .Repository.CanEnableTimetracker}}
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled ctx}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.enable_timetracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not (.Repository.IsTimetrackerEnabled ctx)}}disabled{{end}}" id="only_contributors">
|
||||
<div class="ui checkbox">
|
||||
<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime ctx}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_issue_dependencies" type="checkbox" {{if (.Repository.IsDependenciesEnabled ctx)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.issues.dependency.setting"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
|
||||
</div>
|
||||
<div class="inline field tw-mt-2">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="issues_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_external_issue_tracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4 {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="external_issue_box">
|
||||
<div class="field">
|
||||
<label for="external_tracker_url">{{ctx.Locale.Tr "repo.settings.external_tracker_url"}}</label>
|
||||
<input id="external_tracker_url" name="external_tracker_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerURL}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.external_tracker_url_desc"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tracker_url_format">{{ctx.Locale.Tr "repo.settings.tracker_url_format"}}</label>
|
||||
<input id="tracker_url_format" name="tracker_url_format" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerFormat}}" placeholder="https://github.com/{user}/{repo}/issues/{index}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_url_format_desc"}}</p>
|
||||
</div>
|
||||
<div class="inline fields">
|
||||
<label for="issue_style">{{ctx.Locale.Tr "repo.settings.tracker_issue_style"}}</label>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
{{$externalTracker := (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
|
||||
{{$externalTrackerStyle := $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle}}
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="numeric" {{if eq $externalTrackerStyle "numeric"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">#1234</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="alphanumeric" {{if eq $externalTrackerStyle "alphanumeric"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">ABC-123 , DEFG-234</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="regexp" {{if eq $externalTrackerStyle "regexp"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp"}} <span class="ui light grey text">(ISSUE-\d+) , ISSUE-(\d+)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if ne $externalTrackerStyle "regexp"}}disabled{{end}}" id="tracker-issue-style-regex-box">
|
||||
<label for="external_tracker_regexp_pattern">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern"}}</label>
|
||||
<input id="external_tracker_regexp_pattern" name="external_tracker_regexp_pattern" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerRegexpPattern}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern_desc"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Projects ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-project" 16}} {{ctx.Locale.Tr "repo.projects"}}</h5>
|
||||
{{$isProjectsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
{{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}}
|
||||
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not $isProjectsEnabled}} disabled{{end}} tw-pl-4" id="projects_box">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_desc"}}
|
||||
</p>
|
||||
<div class="ui dropdown selection">
|
||||
<select name="projects_mode">
|
||||
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
|
||||
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
|
||||
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
|
||||
</select>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="default text">
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
|
||||
{{end}}
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
|
||||
{{end}}
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="item" data-value="repo">{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</div>
|
||||
<div class="item" data-value="owner">{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</div>
|
||||
<div class="item" data-value="all">{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Releases ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-tag" 16}} {{ctx.Locale.Tr "repo.releases"}}</h5>
|
||||
{{$isReleasesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeReleases}}
|
||||
{{$isReleasesGlobalDisabled := ctx.Consts.RepoUnitTypeReleases.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="releases_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Licensing ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-broadcast" 16}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}</h5>
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_licensing" type="checkbox" {{if .LicensingEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.enable_licensing_help"}}</p>
|
||||
|
||||
{{/* ── Packages ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-package" 16}} {{ctx.Locale.Tr "repo.packages"}}</h5>
|
||||
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
|
||||
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $isPackagesGlobalDisabled}} disabled{{end}}"{{if $isPackagesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_packages" type="checkbox" {{if $isPackagesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.packages_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .IsMirror}}
|
||||
{{/* ── Pull Requests ── */}}
|
||||
<h5 class="ui dividing header tw-flex tw-items-center tw-gap-2">{{svg "octicon-git-pull-request" 16}} {{ctx.Locale.Tr "repo.pulls"}}</h5>
|
||||
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}}
|
||||
{{$pullRequestGlobalDisabled := ctx.Consts.RepoUnitTypePullRequests.UnitGlobalDisabled}}
|
||||
{{$prUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypePullRequests}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox{{if $pullRequestGlobalDisabled}} disabled{{end}}"{{if $pullRequestGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_pulls" type="checkbox" data-target="#pull_box" {{if $pullRequestEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field{{if not $pullRequestEnabled}} disabled{{end}}" id="pull_box">
|
||||
<div class="field">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.merge_style_desc"}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebase)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_squash" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowSquash)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_fast_forward_only" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowFastForwardOnly)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.merge_manually"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.default_merge_style_desc"}}
|
||||
</p>
|
||||
<div class="ui dropdown selection">
|
||||
<select name="pulls_default_merge_style">
|
||||
<option value="merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</option>
|
||||
<option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option>
|
||||
<option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option>
|
||||
<option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option>
|
||||
<option value="fast-forward-only" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</option>
|
||||
</select>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="default text">
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div>
|
||||
<div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div>
|
||||
<div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div>
|
||||
<div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div>
|
||||
<div class="item" data-value="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
|
||||
<div class="ui search selection dropdown">
|
||||
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
|
||||
<div class="default text"></div>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
|
||||
{{range $branchName := $.Branches}}
|
||||
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_allow_edits_from_maintainers"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase_update" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseUpdate)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.allow_rebase_update"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="default_delete_branch_after_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultDeleteBranchAfterMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_delete_branch_after_merge"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_autodetect_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AutodetectManualMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.enable_autodetect_manual_merge"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_ignore_whitespace" type="checkbox" {{if and $pullRequestEnabled ($prUnit.PullRequestsConfig.IgnoreWhitespaceConflicts)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.ignore_whitespace"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -0,0 +1,116 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings custom-fields")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{svg "octicon-list-unordered" 16}} {{ctx.Locale.Tr "repo.settings.custom_fields"}}</span>
|
||||
<button class="ui primary small button show-modal" data-modal="#custom-field-create-modal">
|
||||
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.settings.custom_field_new"}}
|
||||
</button>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .CustomFields}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_required"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CustomFields}}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||
</td>
|
||||
<td><code>{{.FieldType}}</code></td>
|
||||
<td>{{if .Required}}{{svg "octicon-check" 14}}{{end}}</td>
|
||||
<td>
|
||||
{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>
|
||||
{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}
|
||||
</td>
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<button class="ui tiny button show-modal"
|
||||
data-modal="#custom-field-edit-modal"
|
||||
data-modal-custom-field-edit-modal-field-id="{{.ID}}"
|
||||
data-modal-custom-field-edit-modal-field-name="{{.Name}}"
|
||||
data-modal-custom-field-edit-modal-field-type="{{.FieldType}}"
|
||||
data-modal-custom-field-edit-modal-field-desc="{{.Description}}"
|
||||
data-modal-custom-field-edit-modal-field-options="{{.Options}}"
|
||||
data-modal-custom-field-edit-modal-field-required="{{.Required}}"
|
||||
data-modal-custom-field-edit-modal-field-active="{{.IsActive}}"
|
||||
data-modal-custom-field-edit-modal-field-order="{{.SortOrder}}"
|
||||
title="{{ctx.Locale.Tr "edit"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</button>
|
||||
<button class="ui tiny red button link-action"
|
||||
data-url="{{$.RepoLink}}/settings/custom-fields/{{.ID}}/delete"
|
||||
data-modal-confirm="{{ctx.Locale.Tr "repo.settings.custom_field_confirm_delete"}}"
|
||||
title="{{ctx.Locale.Tr "delete"}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-list-unordered" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.settings.custom_fields_none"}}</h2>
|
||||
<p>{{ctx.Locale.Tr "repo.settings.custom_fields_none_desc"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Create Custom Field Modal ── */}}
|
||||
<div class="ui small modal" id="custom-field-create-modal">
|
||||
<div class="header">{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.settings.custom_field_new"}}</div>
|
||||
<div class="content">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/custom-fields">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Priority, Sprint, Client Name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_type"}}</label>
|
||||
<select name="field_type" class="ui dropdown">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="dropdown">Dropdown</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown below the field">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_options"}}</label>
|
||||
<input name="options" placeholder='["Option A","Option B","Option C"]'>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.custom_field_options_help"}}</p>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="required" type="checkbox">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_required"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_sort_order"}}</label>
|
||||
<input name="sort_order" type="number" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.custom_field_create"))}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -0,0 +1,110 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings licensing")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-broadcast" 16}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post" action="{{.Link}}">
|
||||
<input type="hidden" name="action" value="licensing">
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_licensing" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</strong></label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
|
||||
<select name="update_platform" class="ui dropdown">
|
||||
<option value="joomla" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.Platform "joomla") (eq .RepoUpdateConfig.Platform "")}}selected{{end}}>Joomla (updates.xml)</option>
|
||||
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
|
||||
<option value="wordpress" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "wordpress")}}selected{{end}}>WordPress (JSON)</option>
|
||||
<option value="composer" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "composer")}}selected{{end}}>Composer (packages.json)</option>
|
||||
<option value="prestashop" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "prestashop")}}selected{{end}}>PrestaShop</option>
|
||||
<option value="drupal" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "drupal")}}selected{{end}}>Drupal</option>
|
||||
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
|
||||
<select name="download_gating" class="ui dropdown">
|
||||
<option value="none" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.DownloadGating "") (eq .RepoUpdateConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
|
||||
<option value="prerelease" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "prerelease")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
|
||||
<option value="all" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "all")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.support_url"}}</label>
|
||||
<input name="support_url" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.SupportURL}}{{end}}" placeholder="https://mokoconsulting.tech/support">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.support_url_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
<h5>{{ctx.Locale.Tr "repo.settings.extension_metadata"}}</h5>
|
||||
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}</p>
|
||||
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
|
||||
<input name="extension_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.ExtensionName}}{{end}}" placeholder="pkg_myextension">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
|
||||
<input name="display_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.DisplayName}}{{end}}" placeholder="Package - My Extension">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
|
||||
<select name="extension_type" class="ui dropdown">
|
||||
<option value="">{{ctx.Locale.Tr "repo.settings.inherit_org"}}</option>
|
||||
<option value="package" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "package")}}selected{{end}}>Package</option>
|
||||
<option value="component" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "component")}}selected{{end}}>Component</option>
|
||||
<option value="module" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "module")}}selected{{end}}>Module</option>
|
||||
<option value="plugin" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "plugin")}}selected{{end}}>Plugin</option>
|
||||
<option value="template" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "template")}}selected{{end}}>Template</option>
|
||||
<option value="library" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "library")}}selected{{end}}>Library</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
|
||||
<input name="target_version" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.TargetVersion}}{{end}}" placeholder="(5|6)\..*">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
|
||||
<input name="maintainer" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.Maintainer}}{{end}}" placeholder="Moko Consulting">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
|
||||
<input name="php_minimum" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.PHPMinimum}}{{end}}" placeholder="8.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings metadata")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-list-unordered" 16}} {{ctx.Locale.Tr "repo.settings.metadata"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .CustomFieldDefs}}
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/metadata">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{$values := .CustomFieldValues}}
|
||||
{{$options := .CustomFieldOptions}}
|
||||
{{range .CustomFieldDefs}}
|
||||
{{$currentVal := index $values .ID}}
|
||||
<div class="field">
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
{{if .Options}}
|
||||
{{$opts := index $options .ID}}
|
||||
<select name="field_{{.ID}}" class="ui dropdown">
|
||||
<option value="">—</option>
|
||||
{{range $opts}}
|
||||
<option value="{{.}}" {{if eq . $currentVal}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else if eq (printf "%s" .FieldType) "checkbox"}}
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="field_{{.ID}}" value="true" {{if eq $currentVal "true"}}checked{{end}}>
|
||||
<label></label>
|
||||
</div>
|
||||
{{else if eq (printf "%s" .FieldType) "number"}}
|
||||
<input type="number" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{else if eq (printf "%s" .FieldType) "url"}}
|
||||
<input type="url" name="field_{{.ID}}" value="{{$currentVal}}" placeholder="https://...">
|
||||
{{else if eq (printf "%s" .FieldType) "date"}}
|
||||
<input type="date" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{else}}
|
||||
<input type="text" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text grey">{{ctx.Locale.Tr "repo.settings.metadata_empty"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -1,45 +1,56 @@
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<div class="header item">{{ctx.Locale.Tr "repo.settings"}}</div>
|
||||
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "repo.settings"}}</div>
|
||||
<a class="{{if .PageIsSettingsOptions}}active {{end}}item" href="{{.RepoLink}}/settings">
|
||||
{{ctx.Locale.Tr "repo.settings.options"}}
|
||||
{{svg "octicon-gear"}} {{ctx.Locale.Tr "repo.settings.options"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/advanced">
|
||||
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings.advanced_settings"}}
|
||||
</a>
|
||||
{{if .LicensingEnabled}}
|
||||
<a class="{{if .PageIsSettingsLicensing}}active {{end}}item" href="{{.RepoLink}}/settings/licensing">
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsMetadata}}active {{end}}item" href="{{.RepoLink}}/settings/metadata">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}
|
||||
</a>
|
||||
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
|
||||
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
|
||||
{{ctx.Locale.Tr "repo.settings.public_access"}}
|
||||
{{svg "octicon-eye"}} {{ctx.Locale.Tr "repo.settings.public_access"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsCollaboration}}active {{end}}item" href="{{.RepoLink}}/settings/collaboration">
|
||||
{{ctx.Locale.Tr "repo.settings.collaboration"}}
|
||||
{{svg "octicon-people"}} {{ctx.Locale.Tr "repo.settings.collaboration"}}
|
||||
</a>
|
||||
{{if not DisableWebhooks}}
|
||||
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{.RepoLink}}/settings/hooks">
|
||||
{{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
|
||||
<a class="{{if .PageIsSettingsBranches}}active {{end}}item" href="{{.RepoLink}}/settings/branches">
|
||||
{{ctx.Locale.Tr "repo.settings.branches"}}
|
||||
{{svg "octicon-git-branch"}} {{ctx.Locale.Tr "repo.settings.branches"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsTags}}active {{end}}item" href="{{.RepoLink}}/settings/tags">
|
||||
{{ctx.Locale.Tr "repo.settings.tags"}}
|
||||
{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.settings.tags"}}
|
||||
</a>
|
||||
{{if .SignedUser.CanEditGitHook}}
|
||||
<a class="{{if .PageIsSettingsGitHooks}}active {{end}}item" href="{{.RepoLink}}/settings/hooks/git">
|
||||
{{ctx.Locale.Tr "repo.settings.githooks"}}
|
||||
{{svg "octicon-terminal"}} {{ctx.Locale.Tr "repo.settings.githooks"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{.RepoLink}}/settings/keys">
|
||||
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
|
||||
{{svg "octicon-key-asterisk"}} {{ctx.Locale.Tr "repo.settings.deploy_keys"}}
|
||||
</a>
|
||||
{{if .LFSStartServer}}
|
||||
<a class="{{if .PageIsSettingsLFS}}active {{end}}item" href="{{.RepoLink}}/settings/lfs">
|
||||
{{ctx.Locale.Tr "repo.settings.lfs"}}
|
||||
{{svg "octicon-file-binary"}} {{ctx.Locale.Tr "repo.settings.lfs"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsActionsSettingsGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/actions/general">
|
||||
{{ctx.Locale.Tr "actions.general"}}
|
||||
|
||||
@@ -287,481 +287,6 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* FIXME: need to split the "Advance Settings" by units, there are too many options here */}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.advanced_settings"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post">
|
||||
<input type="hidden" name="action" value="advanced">
|
||||
|
||||
{{$isCodeEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
|
||||
{{$isCodeGlobalDisabled := ctx.Consts.RepoUnitTypeCode.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.code"}}</label>
|
||||
<div class="ui checkbox{{if $isCodeGlobalDisabled}} disabled{{end}}"{{if $isCodeGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_code" type="checkbox"{{if $isCodeEnabled}} checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.code.desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{$isInternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeWiki}}
|
||||
{{$isExternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalWiki}}
|
||||
{{$isWikiEnabled := or $isInternalWikiEnabled $isExternalWikiEnabled}}
|
||||
{{$isWikiGlobalDisabled := ctx.Consts.RepoUnitTypeWiki.UnitGlobalDisabled}}
|
||||
{{$isExternalWikiGlobalDisabled := ctx.Consts.RepoUnitTypeExternalWiki.UnitGlobalDisabled}}
|
||||
{{$isBothWikiGlobalDisabled := and $isWikiGlobalDisabled $isExternalWikiGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.wiki"}}</label>
|
||||
<div class="ui checkbox{{if $isBothWikiGlobalDisabled}} disabled{{end}}"{{if $isBothWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_wiki" type="checkbox" data-target="#wiki_box" {{if $isWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.wiki_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field{{if not $isWikiEnabled}} disabled{{end}}" id="wiki_box">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isWikiGlobalDisabled}} disabled{{end}}"{{if $isWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="false" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isInternalWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_internal_wiki"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="internal_wiki_box" class="field tw-pl-4 {{if not $isInternalWikiEnabled}}disabled{{end}}">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
|
||||
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="wiki_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="true" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isExternalWikiEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_external_wiki"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="external_wiki_box" class="field tw-pl-4 {{if not $isExternalWikiEnabled}}disabled{{end}}">
|
||||
<label for="external_wiki_url">{{ctx.Locale.Tr "repo.settings.external_wiki_url"}}</label>
|
||||
<input id="external_wiki_url" name="external_wiki_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.external_wiki_url_desc"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{{$isIssuesEnabled := or (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeIssues) (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
|
||||
{{$isIssuesGlobalDisabled := ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||
{{$isExternalTrackerGlobalDisabled := ctx.Consts.RepoUnitTypeExternalTracker.UnitGlobalDisabled}}
|
||||
{{$isIssuesAndExternalGlobalDisabled := and $isIssuesGlobalDisabled $isExternalTrackerGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.issues"}}</label>
|
||||
<div class="ui checkbox{{if $isIssuesAndExternalGlobalDisabled}} disabled{{end}}"{{if $isIssuesAndExternalGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_issues" type="checkbox" data-target="#issue_box" {{if $isIssuesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.issues_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isIssuesGlobalDisabled}} disabled{{end}}"{{if $isIssuesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_internal_issue_tracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4 {{if (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
|
||||
{{if .Repository.CanEnableTimetracker}}
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled ctx}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.enable_timetracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not (.Repository.IsTimetrackerEnabled ctx)}}disabled{{end}}" id="only_contributors">
|
||||
<div class="ui checkbox">
|
||||
<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime ctx}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_issue_dependencies" type="checkbox" {{if (.Repository.IsDependenciesEnabled ctx)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.issues.dependency.setting"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
|
||||
</div>
|
||||
<div class="inline field tw-mt-2">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="issues_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.use_external_issue_tracker"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4 {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="external_issue_box">
|
||||
<div class="field">
|
||||
<label for="external_tracker_url">{{ctx.Locale.Tr "repo.settings.external_tracker_url"}}</label>
|
||||
<input id="external_tracker_url" name="external_tracker_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerURL}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.external_tracker_url_desc"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tracker_url_format">{{ctx.Locale.Tr "repo.settings.tracker_url_format"}}</label>
|
||||
<input id="tracker_url_format" name="tracker_url_format" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerFormat}}" placeholder="https://github.com/{user}/{repo}/issues/{index}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_url_format_desc"}}</p>
|
||||
</div>
|
||||
<div class="inline fields">
|
||||
<label for="issue_style">{{ctx.Locale.Tr "repo.settings.tracker_issue_style"}}</label>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
{{$externalTracker := (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
|
||||
{{$externalTrackerStyle := $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle}}
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="numeric" {{if eq $externalTrackerStyle "numeric"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">#1234</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="alphanumeric" {{if eq $externalTrackerStyle "alphanumeric"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">ABC-123 , DEFG-234</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="regexp" {{if eq $externalTrackerStyle "regexp"}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp"}} <span class="ui light grey text">(ISSUE-\d+) , ISSUE-(\d+)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if ne $externalTrackerStyle "regexp"}}disabled{{end}}" id="tracker-issue-style-regex-box">
|
||||
<label for="external_tracker_regexp_pattern">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern"}}</label>
|
||||
<input id="external_tracker_regexp_pattern" name="external_tracker_regexp_pattern" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerRegexpPattern}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern_desc"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{{$isProjectsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
{{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}}
|
||||
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.projects"}}</label>
|
||||
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field {{if not $isProjectsEnabled}} disabled{{end}} tw-pl-4" id="projects_box">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_desc"}}
|
||||
</p>
|
||||
<div class="ui dropdown selection">
|
||||
<select name="projects_mode">
|
||||
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
|
||||
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
|
||||
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
|
||||
</select>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="default text">
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
|
||||
{{end}}
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
|
||||
{{end}}
|
||||
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}
|
||||
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="item" data-value="repo">{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</div>
|
||||
<div class="item" data-value="owner">{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</div>
|
||||
<div class="item" data-value="all">{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{{$isReleasesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeReleases}}
|
||||
{{$isReleasesGlobalDisabled := ctx.Consts.RepoUnitTypeReleases.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.releases"}}</label>
|
||||
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||
<select name="releases_visibility" class="ui dropdown">
|
||||
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{{/* ── Licensing & Update Feeds ── */}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.licensing_section"}}</label>
|
||||
<div class="ui checkbox">
|
||||
<input class="enable-system" name="enable_licensing" type="checkbox" data-target="#licensing_options_box" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4{{if not (and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled)}} disabled{{end}}" id="licensing_options_box">
|
||||
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
|
||||
<select name="update_platform" class="ui dropdown">
|
||||
<option value="joomla" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.Platform "joomla") (eq .RepoUpdateConfig.Platform "")}}selected{{end}}>Joomla (updates.xml)</option>
|
||||
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
|
||||
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
|
||||
<select name="download_gating" class="ui dropdown">
|
||||
<option value="none" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.DownloadGating "") (eq .RepoUpdateConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
|
||||
<option value="prerelease" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "prerelease")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
|
||||
<option value="all" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "all")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.support_url"}}</label>
|
||||
<input name="support_url" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.SupportURL}}{{end}}" placeholder="https://mokoconsulting.tech/support">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.support_url_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
<h6>{{ctx.Locale.Tr "repo.settings.extension_metadata"}}</h6>
|
||||
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}</p>
|
||||
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
|
||||
<input name="extension_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.ExtensionName}}{{end}}" placeholder="pkg_myextension">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
|
||||
<input name="display_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.DisplayName}}{{end}}" placeholder="Package - My Extension">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
|
||||
<select name="extension_type" class="ui dropdown">
|
||||
<option value="">{{ctx.Locale.Tr "repo.settings.inherit_org"}}</option>
|
||||
<option value="package" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "package")}}selected{{end}}>Package</option>
|
||||
<option value="component" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "component")}}selected{{end}}>Component</option>
|
||||
<option value="module" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "module")}}selected{{end}}>Module</option>
|
||||
<option value="plugin" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "plugin")}}selected{{end}}>Plugin</option>
|
||||
<option value="template" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "template")}}selected{{end}}>Template</option>
|
||||
<option value="library" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "library")}}selected{{end}}>Library</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
|
||||
<input name="target_version" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.TargetVersion}}{{end}}" placeholder="(5|6)\..*">
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
|
||||
<input name="maintainer" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.Maintainer}}{{end}}" placeholder="Moko Consulting">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
|
||||
<input name="php_minimum" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.PHPMinimum}}{{end}}" placeholder="8.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
|
||||
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.packages"}}</label>
|
||||
<div class="ui checkbox{{if $isPackagesGlobalDisabled}} disabled{{end}}"{{if $isPackagesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_packages" type="checkbox" {{if $isPackagesEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.packages_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .IsMirror}}
|
||||
<div class="divider"></div>
|
||||
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}}
|
||||
{{$pullRequestGlobalDisabled := ctx.Consts.RepoUnitTypePullRequests.UnitGlobalDisabled}}
|
||||
{{$prUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypePullRequests}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.pulls"}}</label>
|
||||
<div class="ui checkbox{{if $pullRequestGlobalDisabled}} disabled{{end}}"{{if $pullRequestGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||
<input class="enable-system" name="enable_pulls" type="checkbox" data-target="#pull_box" {{if $pullRequestEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls_desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field{{if not $pullRequestEnabled}} disabled{{end}}" id="pull_box">
|
||||
<div class="field">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.merge_style_desc"}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebase)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_squash" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowSquash)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_fast_forward_only" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowFastForwardOnly)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.pulls.merge_manually"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.default_merge_style_desc"}}
|
||||
</p>
|
||||
<div class="ui dropdown selection">
|
||||
<select name="pulls_default_merge_style">
|
||||
<option value="merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</option>
|
||||
<option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option>
|
||||
<option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option>
|
||||
<option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option>
|
||||
<option value="fast-forward-only" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</option>
|
||||
</select>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="default text">
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
|
||||
{{end}}
|
||||
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
|
||||
{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div>
|
||||
<div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div>
|
||||
<div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div>
|
||||
<div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div>
|
||||
<div class="item" data-value="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
|
||||
<div class="ui search selection dropdown">
|
||||
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
|
||||
<div class="default text"></div>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
|
||||
{{range $branchName := $.Branches}}
|
||||
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_allow_edits_from_maintainers"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_allow_rebase_update" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseUpdate)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.allow_rebase_update"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="default_delete_branch_after_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultDeleteBranchAfterMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_delete_branch_after_merge"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="enable_autodetect_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AutodetectManualMerge)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.enable_autodetect_manual_merge"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="pulls_ignore_whitespace" type="checkbox" {{if and $pullRequestEnabled ($prUnit.PullRequestsConfig.IgnoreWhitespaceConflicts)}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.ignore_whitespace"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.signing_settings"}}
|
||||
@@ -879,23 +404,34 @@
|
||||
{{if not .Repository.IsFork}}
|
||||
<div class="item tw-items-center">
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{ctx.Locale.Tr "repo.visibility"}}</div>
|
||||
{{if .IsSystemRepo}}
|
||||
<div class="item-body">This is a system repository (dot-prefixed name). System repositories are always private and cannot be made public.</div>
|
||||
{{else if .Repository.IsPrivate}}
|
||||
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.public.text"}}</div>
|
||||
{{else}}
|
||||
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.private.text"}}</div>
|
||||
{{end}}
|
||||
<div class="item-title tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{ctx.Locale.Tr "repo.visibility"}}</span>
|
||||
{{if .IsSystemRepo}}
|
||||
<span class="ui grey label">System</span>
|
||||
{{else if .Repository.IsHidden}}
|
||||
<span class="ui red label">{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</span>
|
||||
{{else if .Repository.IsPrivate}}
|
||||
<span class="ui orange label">{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</span>
|
||||
{{else}}
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
{{if .IsSystemRepo}}
|
||||
This is a system repository. System repositories are always private.
|
||||
{{else if .Repository.IsHidden}}
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}
|
||||
{{else if .Repository.IsPrivate}}
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if not .IsSystemRepo}}
|
||||
<div class="item-trailing">
|
||||
<button class="ui basic red show-modal button" data-modal="#visibility-repo-modal">
|
||||
{{if .Repository.IsPrivate}}
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.public.button"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.private.button"}}
|
||||
{{end}}
|
||||
{{ctx.Locale.Tr "repo.settings.change_visibility"}}
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1078,43 +614,32 @@
|
||||
{{ctx.Locale.Tr "repo.visibility"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
{{if .Repository.IsPrivate}}
|
||||
<p>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_title"}}</p>
|
||||
<ul>
|
||||
<li>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_one"}}</li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_title"}}</p>
|
||||
<ul>
|
||||
<li>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_one"}}</li>
|
||||
<li>
|
||||
{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_two"}}
|
||||
</li>
|
||||
{{if or .Repository.NumStars .Repository.NumWatches .Repository.NumForks}}
|
||||
<ul class="tw-my-0 tw-pl-4">
|
||||
{{if .Repository.NumStars}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_stars" .Repository.NumStars}}</li>{{end}}
|
||||
{{if .Repository.NumWatches}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_watchers" .Repository.NumWatches}}</li>{{end}}
|
||||
{{if .Repository.NumForks}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_forks" .Repository.NumForks}}</li>{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<form class="ui form tw-mt-5 form-fetch-action" action="{{.Link}}" method="post">
|
||||
<div class="ui warning message">
|
||||
<p>{{ctx.Locale.Tr "repo.settings.visibility.warning"}}</p>
|
||||
</div>
|
||||
<form class="ui form tw-mt-4 form-fetch-action" action="{{.Link}}" method="post">
|
||||
<input type="hidden" name="action" value="visibility">
|
||||
<input type="hidden" name="private" value="{{not .Repository.IsPrivate}}">
|
||||
{{if not .Repository.IsPrivate}}
|
||||
<div class="grouped fields">
|
||||
<div class="field">
|
||||
<label>
|
||||
{{ctx.Locale.Tr "repo.settings.enter_repo_full_name_to_confirm"}}
|
||||
<span class="tw-text-red">{{.Repository.FullName}}</span>
|
||||
</label>
|
||||
<div class="ui radio checkbox">
|
||||
<input name="visibility" type="radio" value="public" {{if and (not .Repository.IsPrivate) (not .Repository.IsHidden)}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.repo_name"}}</label>
|
||||
<input name="confirm_repo_name" required maxlength="200">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input name="visibility" type="radio" value="private" {{if and .Repository.IsPrivate (not .Repository.IsHidden)}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (Iif .Repository.IsPrivate (ctx.Locale.Tr "repo.settings.visibility.public.button") (ctx.Locale.Tr "repo.settings.visibility.private.button")))}}
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input name="visibility" type="radio" value="hidden" {{if .Repository.IsHidden}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.change_visibility"))}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content {{if .IsRepo}}repository{{end}}">
|
||||
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
|
||||
<div class="ui container">
|
||||
{{template "base/alert" .}}
|
||||
<div class="status-page-error">
|
||||
<div class="status-page-error-title">403 Access Denied</div>
|
||||
<div class="tw-text-center">
|
||||
<div class="tw-my-4">{{if .AccessDeniedPrompt}}{{.AccessDeniedPrompt}}{{else}}{{ctx.Locale.Tr "error403"}}{{end}}</div>
|
||||
</div>
|
||||
{{if not .IsSigned}}
|
||||
<div class="tw-max-w-sm tw-mx-auto tw-mt-4">
|
||||
<form class="ui form" action="{{AppSubUrl}}/user/login" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="redirect_to" value="{{.CurrentURL}}">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "home.uname_holder"}}</label>
|
||||
<input type="text" name="user_name" required autofocus>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "password"}}</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button class="ui primary fluid button tw-mt-2" type="submit">{{ctx.Locale.Tr "sign_in"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,41 +1,41 @@
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<div class="header item">{{ctx.Locale.Tr "user.settings"}}</div>
|
||||
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "user.settings"}}</div>
|
||||
<a class="{{if .PageIsSettingsProfile}}active {{end}}item" href="{{AppSubUrl}}/user/settings">
|
||||
{{ctx.Locale.Tr "settings.profile"}}
|
||||
{{svg "octicon-person"}} {{ctx.Locale.Tr "settings.profile"}}
|
||||
</a>
|
||||
{{if not ($.UserDisabledFeatures.Contains "manage_credentials" "deletion")}}
|
||||
<a class="{{if .PageIsSettingsAccount}}active {{end}}item" href="{{AppSubUrl}}/user/settings/account">
|
||||
{{ctx.Locale.Tr "settings.account"}}
|
||||
{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "settings.account"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if $.EnableNotifyMail}}
|
||||
<a class="{{if .PageIsSettingsNotifications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/notifications">
|
||||
{{ctx.Locale.Tr "notifications"}}
|
||||
{{svg "octicon-bell"}} {{ctx.Locale.Tr "notifications"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsAppearance}}active {{end}}item" href="{{AppSubUrl}}/user/settings/appearance">
|
||||
{{ctx.Locale.Tr "settings.appearance"}}
|
||||
{{svg "octicon-paintbrush"}} {{ctx.Locale.Tr "settings.appearance"}}
|
||||
</a>
|
||||
{{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
|
||||
<a class="{{if .PageIsSettingsSecurity}}active {{end}}item" href="{{AppSubUrl}}/user/settings/security">
|
||||
{{ctx.Locale.Tr "settings.security"}}
|
||||
{{svg "octicon-lock"}} {{ctx.Locale.Tr "settings.security"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
|
||||
{{ctx.Locale.Tr "user.block.list"}}
|
||||
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block.list"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
{{svg "octicon-apps"}} {{ctx.Locale.Tr "settings.applications"}}
|
||||
</a>
|
||||
{{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}}
|
||||
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{AppSubUrl}}/user/settings/keys">
|
||||
{{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsUserSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsUserSettingsActionsGeneral}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/general">
|
||||
{{ctx.Locale.Tr "actions.general"}}
|
||||
@@ -54,19 +54,19 @@
|
||||
{{end}}
|
||||
{{if .EnablePackages}}
|
||||
<a class="{{if .PageIsSettingsPackages}}active {{end}}item" href="{{AppSubUrl}}/user/settings/packages">
|
||||
{{ctx.Locale.Tr "packages.title"}}
|
||||
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if not DisableWebhooks}}
|
||||
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{AppSubUrl}}/user/settings/hooks">
|
||||
{{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsOrganization}}active {{end}}item" href="{{AppSubUrl}}/user/settings/organization">
|
||||
{{ctx.Locale.Tr "settings.organization"}}
|
||||
{{svg "octicon-organization"}} {{ctx.Locale.Tr "settings.organization"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
|
||||
{{ctx.Locale.Tr "settings.repos"}}
|
||||
{{svg "octicon-repo"}} {{ctx.Locale.Tr "settings.repos"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: 05.14.00
|
||||
-->
|
||||
|
||||
<updates>
|
||||
<update>
|
||||
<name>MokoGitea</name>
|
||||
<description>MokoGitea dev build.</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<client>site</client>
|
||||
<version>05.05.00-dev</version>
|
||||
<creationDate>2026-05-30</creationDate>
|
||||
<infourl title="MokoGitea">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/development</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/development/mokogitea-05.05.00-dev.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||
<tags><tag>dev</tag></tags>
|
||||
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="go" version=".*"/>
|
||||
</update>
|
||||
<update>
|
||||
<name>MokoGitea</name>
|
||||
<description>MokoGitea alpha build.</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<client>site</client>
|
||||
<version>05.05.00-alpha</version>
|
||||
<creationDate>2026-05-30</creationDate>
|
||||
<infourl title="MokoGitea">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/alpha</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/alpha/mokogitea-05.05.00-alpha.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||
<tags><tag>alpha</tag></tags>
|
||||
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="go" version=".*"/>
|
||||
</update>
|
||||
<update>
|
||||
<name>MokoGitea</name>
|
||||
<description>MokoGitea beta build.</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<client>site</client>
|
||||
<version>05.05.00-beta</version>
|
||||
<creationDate>2026-05-30</creationDate>
|
||||
<infourl title="MokoGitea">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/beta</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/beta/mokogitea-05.05.00-beta.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||
<tags><tag>beta</tag></tags>
|
||||
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="go" version=".*"/>
|
||||
</update>
|
||||
<update>
|
||||
<name>MokoGitea</name>
|
||||
<description>MokoGitea rc build.</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<client>site</client>
|
||||
<version>05.05.00-rc</version>
|
||||
<creationDate>2026-05-30</creationDate>
|
||||
<infourl title="MokoGitea">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/release-candidate</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/release-candidate/mokogitea-05.05.00-rc.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||
<tags><tag>rc</tag></tags>
|
||||
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="go" version=".*"/>
|
||||
</update>
|
||||
<update>
|
||||
<name>MokoGitea</name>
|
||||
<description>MokoGitea stable build.</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<client>site</client>
|
||||
<version>05.14.00</version>
|
||||
<creationDate>2026-05-31</creationDate>
|
||||
<infourl title='MokoGitea'>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.14.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>bec4bf5a1a841f8e72d9826451004db5d8afc70144231dfedc7fb01a6695955c</sha256>
|
||||
<tags><tag>stable</tag></tags>
|
||||
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="go" version=".*" />
|
||||
</update>
|
||||
</updates>
|
||||
@@ -426,10 +426,11 @@ The update feed system currently supports:
|
||||
| **Joomla** | `/{repo}/updates.xml` | XML with `<downloadkey>` | Production |
|
||||
| **Dolibarr** | `/{repo}/updates/dolibarr.json` | JSON | Production |
|
||||
| **WordPress** | `/{repo}/updates/wordpress.json` | PUC-compatible JSON | Production |
|
||||
| **Drupal** | Planned | XML/JSON | Planned (#353) |
|
||||
| **PrestaShop** | Planned | XML | Planned (#352) |
|
||||
| **Composer** | Planned | packages.json | Planned (#354) |
|
||||
| **WHMCS** | Planned | Custom | Planned (#355) |
|
||||
| **Composer** | `/{repo}/updates/packages.json` | packages.json | Production |
|
||||
| **PrestaShop** | `/{repo}/updates/prestashop.xml` | Module update XML | Production |
|
||||
| **Drupal** | `/{repo}/updates/drupal.xml` | Update status XML | Production |
|
||||
| **WHMCS** | `/{repo}/updates/whmcs.json` | Module update JSON | Production |
|
||||
| **Changelog** | `/{repo}/changelog.xml` | Joomla changelog XML | Production |
|
||||
|
||||
All platforms share the same licensing backend — the same keys, packages, and validation work across all feed formats.
|
||||
|
||||
@@ -444,3 +445,4 @@ All platforms share the same licensing backend — the same keys, packages, and
|
||||
| 1.2 | 2026-05-31 | Jonathan Miller (@jmiller) | Add permissions (TypeLicenses unit), renewal, auto-domain, custom keys, UI/UX cleanup |
|
||||
| 1.3 | 2026-06-01 | Jonathan Miller (@jmiller) | Add package archiving, expanded delete permissions, migration v340, API renew, step-by-step guides |
|
||||
| 1.4 | 2026-06-02 | Jonathan Miller (@jmiller) | WordPress feed, feed visibility modes, download gating, RepoScope enforcement, API package CRUD, settings API, combolist channel picker, double confirmation modals, extension metadata in repo settings, domain lock timer, Joomla-standard tags, SHA256 in XML, changelog XML, no-download release page mode |
|
||||
| 1.5 | 2026-06-02 | Jonathan Miller (@jmiller) | All 7 platform feeds (Composer, PrestaShop, Drupal, WHMCS), enterprise sub-org hierarchy, three-level repo visibility (Public/Private/Hidden), styled 403 page with login form, separate licensing/advanced settings pages, icons on all navbars, manual stream mapping, configurable key prefix, feed always public, xorm column name fixes, security hardening |
|
||||
|
||||
Reference in New Issue
Block a user