diff --git a/.gitignore b/.gitignore index 76a7578646..d2f1993ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,6 @@ prime/ # A Makefile for custom make targets Makefile.local + +# Local clone of the MCP server (separate repo, not a submodule of this project) +/mcp-mokogitea-api/ diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 5865324d5a..9b908d02ad 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -7,7 +7,7 @@ # INGROUP: mokocli.Release # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # PATH: /templates/workflows/universal/auto-release.yml.template -# VERSION: 05.00.00 +# VERSION: 05.01.00 # BRIEF: Universal build & release � detects platform from manifest.xml # # +=======================================================================+ @@ -64,10 +64,14 @@ jobs: promote-rc: name: Promote to RC runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - (github.event.action == 'opened' && github.event.pull_request.merged != true) || - (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + !startsWith(github.event.repository.name, 'Template-') && + ( + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + ) steps: - name: Checkout repository @@ -75,6 +79,7 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 1 + submodules: recursive - name: Setup mokocli tools env: @@ -90,7 +95,7 @@ jobs: 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 rm -rf /tmp/mokocli - CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli cd /tmp/mokocli composer install --no-dev --no-interaction --quiet @@ -99,11 +104,39 @@ jobs: - name: Rename branch to rc run: | - php ${MOKO_CLI}/branch_rename.php \ - --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ - --pr "${{ github.event.pull_request.number }}" + API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" + FROM="${{ github.event.pull_request.head.ref || 'dev' }}" + PR="${{ github.event.pull_request.number }}" + + # Resolve the source branch HEAD commit. + SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \ + || { echo "::error::Source branch ${FROM} not found"; exit 1; } + SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true) + [ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; } + + # Point rc at the source commit. If rc already exists (a protected branch that + # cannot be deleted), force-update its ref in place instead of delete+recreate: + # deleting a protected branch fails, which then makes the recreate return HTTP 409. + if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then + echo "rc exists - force-updating to ${FROM} (${SRC_SHA})" + curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \ + || { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; } + else + echo "Creating rc from ${FROM}" + curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \ + || { echo "::error::Failed to create rc from ${FROM}"; exit 1; } + fi + + # Repoint the PR at rc, then delete the old source branch (non-fatal). + if [ -n "$PR" ]; then + curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true + fi + curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true + echo "Renamed ${FROM} -> rc" - name: Checkout rc and configure git run: | @@ -163,9 +196,13 @@ jobs: release: name: Build & Release Pipeline runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + !startsWith(github.event.repository.name, 'Template-') && + ( + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + ) steps: - name: Checkout repository @@ -173,6 +210,7 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 0 + submodules: recursive - name: Configure git for bot pushes run: | @@ -208,7 +246,7 @@ jobs: 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 rm -rf /tmp/mokocli - CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli cd /tmp/mokocli composer install --no-dev --no-interaction --quiet diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml index 9d884e70a3..dd5ff5695e 100644 --- a/.mokogitea/workflows/branch-cleanup.yml +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -33,7 +33,8 @@ jobs: run: | BRANCH="${{ github.event.pull_request.head.ref }}" API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" - ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + # URL-encode the branch name's slashes (no PHP dependency on the runner) + ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g') STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ diff --git a/.mokogitea/workflows/ci-generic.yml b/.mokogitea/workflows/ci-generic.yml index 92d26853d1..72650d2430 100644 --- a/.mokogitea/workflows/ci-generic.yml +++ b/.mokogitea/workflows/ci-generic.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.CI # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.gitea/workflows/ci-generic.yml +# PATH: /.mokogitea/workflows/ci-generic.yml # VERSION: 01.00.00 # BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js) @@ -32,6 +32,8 @@ jobs: lint: name: Lint & Validate runs-on: ubuntu-latest + # Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source. + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -130,6 +132,9 @@ jobs: name: Tests runs-on: ubuntu-latest needs: lint + # Run only when lint succeeded; always() forces evaluation so a skipped + # lint (e.g. template repos) skips this job cleanly instead of hanging. + if: ${{ always() && needs.lint.result == 'success' }} steps: - name: Checkout diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 002386274d..2e6b6a2829 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.gitea/workflows/cleanup.yml +# PATH: /.mokogitea/workflows/cleanup.yml # VERSION: 01.00.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs diff --git a/.mokogitea/workflows/custom/pr-rc-release.yml b/.mokogitea/workflows/custom/pr-rc-release.yml index 998a25a7f7..f6b9f905f6 100644 --- a/.mokogitea/workflows/custom/pr-rc-release.yml +++ b/.mokogitea/workflows/custom/pr-rc-release.yml @@ -47,6 +47,15 @@ jobs: env: PR_NUMBER: ${{ github.event.pull_request.number }} run: | + # This RC flow drives a Joomla-style update stream (updates.xml). Repos that don't ship + # one (e.g. generic Go/TS) have nothing to package here, so no-op cleanly instead of + # aborting under `set -e` when the file is absent. + if [ ! -f updates.xml ]; then + echo "has_updates=false" >> "$GITHUB_OUTPUT" + echo "No updates.xml in this repo — skipping RC update-stream packaging" + exit 0 + fi + echo "has_updates=true" >> "$GITHUB_OUTPUT" BASE_VERSION=$(sed -n 's/.*\(.*\)<\/version>.*/\1/p' updates.xml | head -1) [ -z "$BASE_VERSION" ] && BASE_VERSION="04.00.00" RC_VERSION="${BASE_VERSION}-rc.${PR_NUMBER}" @@ -56,7 +65,7 @@ jobs: echo "RC version: $RC_VERSION (tag: $RC_TAG)" - name: Update updates.xml RC channel - if: steps.guard.outputs.skip != 'true' + if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true' env: RC_VERSION: ${{ steps.version.outputs.version }} RC_TAG: ${{ steps.version.outputs.tag }} @@ -106,7 +115,7 @@ jobs: PYEOF - name: Create RC release - if: steps.guard.outputs.skip != 'true' + if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true' env: GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} RC_TAG: ${{ steps.version.outputs.tag }} @@ -153,7 +162,7 @@ jobs: PYEOF - name: Commit updates.xml - if: steps.guard.outputs.skip != 'true' + if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true' env: GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} HEAD_REF: ${{ github.event.pull_request.head.ref }} diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml deleted file mode 100644 index 1af323c4ea..0000000000 --- a/.mokogitea/workflows/deploy-manual.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Deploy -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API -# PATH: /templates/workflows/joomla/deploy-manual.yml.template -# VERSION: 04.07.00 -# BRIEF: Manual SFTP deploy to dev server for Joomla repos - -name: "Universal: Deploy to Dev (Manual)" - -on: - workflow_dispatch: - inputs: - clear_remote: - description: 'Delete all remote files before uploading' - required: false - default: 'false' - type: boolean - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -permissions: - contents: read - -jobs: - deploy: - name: SFTP Deploy to Dev - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - run: | - php -v && composer --version - - - name: Setup MokoStandards tools - env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}' - run: | - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ - /tmp/mokostandards-api 2>/dev/null || true - if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then - cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true - fi - - - name: Check FTP configuration - id: check - env: - HOST: ${{ vars.DEV_FTP_HOST }} - PATH_VAR: ${{ vars.DEV_FTP_PATH }} - PORT: ${{ vars.DEV_FTP_PORT }} - run: | - if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then - echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "host=$HOST" >> "$GITHUB_OUTPUT" - - REMOTE="${PATH_VAR%/}" - echo "remote=$REMOTE" >> "$GITHUB_OUTPUT" - - [ -z "$PORT" ] && PORT="22" - echo "port=$PORT" >> "$GITHUB_OUTPUT" - - - name: Deploy via SFTP - if: steps.check.outputs.skip != 'true' - env: - SFTP_KEY: ${{ secrets.DEV_FTP_KEY }} - SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }} - SFTP_USER: ${{ vars.DEV_FTP_USERNAME }} - run: | - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; } - - printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ - "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \ - > /tmp/sftp-config.json - - if [ -n "$SFTP_KEY" ]; then - echo "$SFTP_KEY" > /tmp/deploy_key - chmod 600 /tmp/deploy_key - printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json - else - printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json - fi - - DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json) - [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote) - - PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) - if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then - php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}" - else - php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" - fi - - rm -f /tmp/deploy_key /tmp/sftp-config.json - - - name: Summary - if: always() - run: | - if [ "${{ steps.check.outputs.skip }}" = "true" ]; then - echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY - else - echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 51dfcb5dd3..9dfa047cf9 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Notifications # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.gitea/workflows/notify.yml +# PATH: /.mokogitea/workflows/notify.yml # VERSION: 01.00.00 # BRIEF: Push notifications via ntfy on release success or workflow failure diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index 21676fabcf..019371d9e6 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.CI -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# INGROUP: mokocli.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # PATH: /templates/workflows/universal/pr-check.yml.template # VERSION: 09.23.00 # BRIEF: PR gate — branch policy + code validation before merge @@ -47,15 +47,15 @@ jobs: fi ;; fix/*|bugfix/*) - if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then ALLOWED=false - REASON="Fix branches must target 'main' or 'dev', not '${BASE}'" + REASON="Fix branches must target 'dev' or 'main', not '${BASE}'" fi ;; patch/*) - if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then ALLOWED=false - REASON="Patch branches must target 'main', 'dev', or 'rc', not '${BASE}'" + REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'" fi ;; hotfix/*) @@ -86,11 +86,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`main\` or \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`patch/*\` → \`main\`, \`dev\`, or \`rc\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`rc\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY exit 1 fi @@ -127,6 +127,8 @@ jobs: validate: name: Validate PR runs-on: ubuntu-latest + # Skip on template repos (Template-*) — no real manifest/source/changelog to validate. + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -147,18 +149,10 @@ jobs: - name: Detect platform id: platform - env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - REPO: ${{ github.repository }} run: | - # Query metadata API for platform (manifest.xml is deprecated) - PLATFORM="" - if [ -n "$MOKOGITEA_TOKEN" ]; then - PLATFORM=$(curl -sf -H "Authorization: token ${MOKOGITEA_TOKEN}" \ - "${MOKOGITEA_URL}/api/v1/repos/${REPO}/metadata" 2>/dev/null \ - | sed -n 's/.*"platform"\s*:\s*"\([^"]*\)".*/\1/p' | head -1) || true - fi + # Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used. + API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata" + PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)" [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "Detected platform: $PLATFORM" @@ -502,6 +496,9 @@ jobs: name: Build RC Package runs-on: ubuntu-latest needs: [branch-policy, validate] + # Run only when both gates succeeded; always() forces evaluation so a skipped + # validate (e.g. template repos) skips this job cleanly instead of hanging. + if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }} steps: - name: Trigger RC pre-release diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index efb3d1b4f6..cc40025b9e 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -48,9 +48,13 @@ jobs: build: name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' + !startsWith(github.event.repository.name, 'Template-') && + ( + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' + ) steps: - name: Checkout diff --git a/.mokogitea/workflows/sync-on-merge.yml b/.mokogitea/workflows/sync-on-merge.yml new file mode 100644 index 0000000000..1b882bcd41 --- /dev/null +++ b/.mokogitea/workflows/sync-on-merge.yml @@ -0,0 +1,31 @@ +name: Sync Workflows to Repos + +on: + push: + branches: + - main + paths: + - '.mokogitea/workflows/**' + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout mokocli + uses: actions/checkout@v4 + with: + repository: MokoConsulting/mokocli + token: ${{ secrets.MOKOGITEA_TOKEN }} + + - name: Setup PHP + uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1 + with: + php-version: '8.1' + + - name: Install dependencies + run: composer install --no-dev --no-interaction + + - name: Sync workflows to generic repos + run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}" + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aea9e753f..0be02342ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Added +- Org branch protection: repositories now show the inherited organization rules read-only in their Branch Protection settings, with an expandable detail (direct push, force-push, branch deletion, merge restrictions, required approvals, status checks, protected files, and whitelisted teams) — like GitHub surfaces org rulesets in a repo (#727) +- Org branch protection: org-level rules can now also protect against branch deletion (`enable_delete` + delete allowlist teams), mirroring the per-repo delete allowlist (#727) - Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552) - Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460) - Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507) @@ -57,6 +59,14 @@ - Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185) ### Fixed +- Org-level branch protection now **layers** with per-repo rules instead of being ignored whenever a repo rule exists. When both an org rule and a repo rule match a branch, the effective rule is the most-restrictive (fail-closed) combination — the org rule is a mandatory floor a repo cannot weaken: allow flags AND'd, gate/require/block flags OR'd, required approvals max'd, status checks and protected-file patterns unioned, whitelists intersected. Previously a repo rule shadowed the org rule entirely at the enforcement choke point (`GetFirstMatchProtectedBranchRule`), letting a repo opt out of org protection (#727) +- Org Teams page: list now renders — the handler wrote `ctx.Data["OrgListTeams"]` but the template reads `.Teams`, so the page showed header/nav but no teams (#720) +- Issue type: now editable after creation for users with issue write permission — the sidebar gated editing on a `FieldEditFlags` data key that was never populated (always read-only); now uses `HasIssuesOrPullsWritePermission` like the priority field (#721) +- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page +- PR check branch policy: allow `fix/*` → `main` and `patch/*` → `main` to match documented policy (was rejecting fix/patch PRs to main) +- PR check platform detection: guard for missing `.mokogitea/manifest.xml` so the Validate PR job no longer aborts under `set -e` (manifest replaced by metadata API) +- Remove dangling `mcp-mokogitea-api` submodule gitlink (no `.gitmodules` entry) that broke `submodule update --init` at checkout, failing all PR build/release jobs; ignore the local clone path +- PR RC Release workflow: no-op cleanly when `updates.xml` is absent (generic repos) instead of aborting the "Determine RC version" step under `set -e` - PR check: platform detection now queries metadata API instead of removed manifest.xml - Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783) - Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622) diff --git a/mcp-mokogitea-api b/mcp-mokogitea-api deleted file mode 160000 index dbaf91546e..0000000000 --- a/mcp-mokogitea-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dbaf91546e0fb44614cf8d914b7a78e679dc1efe diff --git a/models/git/org_protected_branch.go b/models/git/org_protected_branch.go index 99c09e6e03..881edab6d1 100644 --- a/models/git/org_protected_branch.go +++ b/models/git/org_protected_branch.go @@ -33,6 +33,9 @@ type OrgProtectedBranch struct { CanForcePush bool `xorm:"NOT NULL DEFAULT false"` EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"` ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + CanDelete bool `xorm:"NOT NULL DEFAULT false"` + EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"` StatusCheckContexts []string `xorm:"JSON TEXT"` RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"` @@ -96,6 +99,9 @@ func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch { CanForcePush: o.CanForcePush, EnableForcePushAllowlist: o.EnableForcePushAllowlist, ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs, + CanDelete: o.CanDelete, + EnableDeleteAllowlist: o.EnableDeleteAllowlist, + DeleteAllowlistTeamIDs: o.DeleteAllowlistTeamIDs, EnableStatusCheck: o.EnableStatusCheck, StatusCheckContexts: o.StatusCheckContexts, RequiredApprovals: o.RequiredApprovals, diff --git a/models/git/protected_branch_list.go b/models/git/protected_branch_list.go index 4875857db6..fa3acf37d7 100644 --- a/models/git/protected_branch_list.go +++ b/models/git/protected_branch_list.go @@ -85,19 +85,40 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) return results, nil } -// GetFirstMatchProtectedBranchRule returns the first matched rule. -// It checks repo-level rules first; if none match, it falls back to org-level rules -// (if the repo belongs to an organization). +// GetFirstMatchProtectedBranchRule returns the effective protected-branch rule for a +// branch. It combines the matching repo-level rule with the matching org-level rule +// (when the repo belongs to an organization): if both match they are layered with +// mergeMostRestrictive so the org rule acts as a floor the repo cannot weaken; if +// only one matches that one is returned; if neither matches, nil. func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) { rules, err := FindRepoProtectedBranchRules(ctx, repoID) if err != nil { return nil, err } - if matched := rules.GetFirstMatched(branchName); matched != nil { - return matched, nil + repoRule := rules.GetFirstMatched(branchName) + + orgRule, err := getFirstMatchOrgProtectedBranchRule(ctx, repoID, branchName) + if err != nil { + return nil, err } - // Fall back to org-level rules + switch { + case repoRule == nil && orgRule == nil: + return nil, nil + case orgRule == nil: + return repoRule, nil + case repoRule == nil: + return orgRule, nil + default: + return mergeMostRestrictive(repoRule, orgRule), nil + } +} + +// getFirstMatchOrgProtectedBranchRule returns the matching org-level rule for a +// branch expressed as a repo-scoped ProtectedBranch (RepoID set so downstream +// permission checks work), or nil if the repo's owner is not an organization or no +// org rule matches. +func getFirstMatchOrgProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) { repo, err := repo_model.GetRepositoryByID(ctx, repoID) if err != nil { return nil, err @@ -119,7 +140,7 @@ func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchN return nil, nil } - // Convert org rule to a ProtectedBranch with RepoID set so callers work correctly + // Convert org rule to a ProtectedBranch with RepoID set so callers work correctly. pb := orgRule.ToProtectedBranch() pb.RepoID = repoID return pb, nil diff --git a/models/git/protected_branch_merge.go b/models/git/protected_branch_merge.go new file mode 100644 index 0000000000..d7a0465799 --- /dev/null +++ b/models/git/protected_branch_merge.go @@ -0,0 +1,178 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import "strings" + +// mergeMostRestrictive combines a repo-level and an org-level protected-branch rule +// that both match the same branch into a single effective rule, always applying the +// STRICTER constraint from each side (fail-closed). This makes an org-level rule a +// mandatory floor that a repo rule can only tighten, never weaken. See issue #727. +// +// Combination directions: +// - "Can*" / allow booleans -> AND (an action is allowed only if both allow it) +// - "Enable*/Block*/Require*" -> OR (a gate is on if either side turns it on) +// - RequiredApprovals -> max +// - required-set lists -> union (status contexts, protected files) +// - allow-set lists -> intersection (whitelists, unprotected files) +// +// Identity (ID, RepoID, RuleName, Priority) is taken from the repo rule so that +// downstream permission checks (which LoadRepo via RepoID) keep working. +func mergeMostRestrictive(repoRule, orgRule *ProtectedBranch) *ProtectedBranch { + eff := *repoRule + + // Direct push. + eff.CanPush = repoRule.CanPush && orgRule.CanPush + eff.EnableWhitelist, eff.WhitelistUserIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistUserIDs, orgRule.EnableWhitelist, orgRule.WhitelistUserIDs) + _, eff.WhitelistTeamIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistTeamIDs, orgRule.EnableWhitelist, orgRule.WhitelistTeamIDs) + eff.WhitelistDeployKeys = repoRule.WhitelistDeployKeys && orgRule.WhitelistDeployKeys + eff.WhitelistActionsUser = repoRule.WhitelistActionsUser && orgRule.WhitelistActionsUser + + // Force push. + eff.CanForcePush = repoRule.CanForcePush && orgRule.CanForcePush + eff.EnableForcePushAllowlist, eff.ForcePushAllowlistUserIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistUserIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistUserIDs) + _, eff.ForcePushAllowlistTeamIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistTeamIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistTeamIDs) + eff.ForcePushAllowlistDeployKeys = repoRule.ForcePushAllowlistDeployKeys && orgRule.ForcePushAllowlistDeployKeys + eff.ForcePushAllowlistActionsUser = repoRule.ForcePushAllowlistActionsUser && orgRule.ForcePushAllowlistActionsUser + + // Delete. + eff.CanDelete = repoRule.CanDelete && orgRule.CanDelete + eff.EnableDeleteAllowlist, eff.DeleteAllowlistUserIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistUserIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistUserIDs) + _, eff.DeleteAllowlistTeamIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistTeamIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistTeamIDs) + eff.DeleteAllowlistDeployKeys = repoRule.DeleteAllowlistDeployKeys && orgRule.DeleteAllowlistDeployKeys + eff.DeleteAllowlistActionsUser = repoRule.DeleteAllowlistActionsUser && orgRule.DeleteAllowlistActionsUser + + // Merge whitelist. + eff.EnableMergeWhitelist, eff.MergeWhitelistUserIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistUserIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistUserIDs) + _, eff.MergeWhitelistTeamIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistTeamIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistTeamIDs) + eff.MergeWhitelistActionsUser = repoRule.MergeWhitelistActionsUser && orgRule.MergeWhitelistActionsUser + + // Status checks. + eff.EnableStatusCheck = repoRule.EnableStatusCheck || orgRule.EnableStatusCheck + eff.StatusCheckContexts = unionStrings(repoRule.StatusCheckContexts, orgRule.StatusCheckContexts) + + // Approvals and reviews. + eff.RequiredApprovals = maxInt64(repoRule.RequiredApprovals, orgRule.RequiredApprovals) + eff.EnableApprovalsWhitelist, eff.ApprovalsWhitelistUserIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistUserIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistUserIDs) + _, eff.ApprovalsWhitelistTeamIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistTeamIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistTeamIDs) + eff.BlockOnRejectedReviews = repoRule.BlockOnRejectedReviews || orgRule.BlockOnRejectedReviews + eff.BlockOnOfficialReviewRequests = repoRule.BlockOnOfficialReviewRequests || orgRule.BlockOnOfficialReviewRequests + eff.BlockOnOutdatedBranch = repoRule.BlockOnOutdatedBranch || orgRule.BlockOnOutdatedBranch + eff.DismissStaleApprovals = repoRule.DismissStaleApprovals || orgRule.DismissStaleApprovals + eff.IgnoreStaleApprovals = repoRule.IgnoreStaleApprovals || orgRule.IgnoreStaleApprovals + + // Commits, files, admin override. + eff.RequireSignedCommits = repoRule.RequireSignedCommits || orgRule.RequireSignedCommits + eff.ProtectedFilePatterns = unionPatterns(repoRule.ProtectedFilePatterns, orgRule.ProtectedFilePatterns) + eff.UnprotectedFilePatterns = intersectPatterns(repoRule.UnprotectedFilePatterns, orgRule.UnprotectedFilePatterns) + eff.BlockAdminMergeOverride = repoRule.BlockAdminMergeOverride || orgRule.BlockAdminMergeOverride + + return &eff +} + +// mergeAllowlist combines two allow-lists under most-restrictive semantics. An +// allow-list only narrows access when its Enable flag is set; a disabled list means +// "everyone", so it imposes no constraint. Therefore: if both are enabled the result +// is the intersection (a principal must be allowed by both); if only one is enabled +// its list is used as-is; if neither is enabled the list is irrelevant. +func mergeAllowlist(aEnabled bool, aIDs []int64, bEnabled bool, bIDs []int64) (bool, []int64) { + switch { + case aEnabled && bEnabled: + return true, intersectInt64(aIDs, bIDs) + case aEnabled: + return true, aIDs + case bEnabled: + return true, bIDs + default: + return false, nil + } +} + +func intersectInt64(a, b []int64) []int64 { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[int64]struct{}, len(a)) + for _, x := range a { + set[x] = struct{}{} + } + var out []int64 + for _, x := range b { + if _, ok := set[x]; ok { + out = append(out, x) + } + } + return out +} + +func unionStrings(a, b []string) []string { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + seen := make(map[string]struct{}, len(a)+len(b)) + out := make([]string, 0, len(a)+len(b)) + for _, s := range a { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) + } + } + for _, s := range b { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) + } + } + return out +} + +// unionPatterns unions two ';'-separated file-pattern lists (more patterns protected +// = more restrictive). +func unionPatterns(a, b string) string { + return strings.Join(unionStrings(splitPatterns(a), splitPatterns(b)), ";") +} + +// intersectPatterns intersects two ';'-separated file-pattern lists. Unprotected +// patterns are carve-outs that REDUCE protection, so the restrictive combination +// keeps only the exemptions present in both. +func intersectPatterns(a, b string) string { + as, bs := splitPatterns(a), splitPatterns(b) + set := make(map[string]struct{}, len(as)) + for _, s := range as { + set[s] = struct{}{} + } + seen := make(map[string]struct{}, len(bs)) + var out []string + for _, s := range bs { + if _, ok := set[s]; !ok { + continue + } + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return strings.Join(out, ";") +} + +func splitPatterns(s string) []string { + var out []string + for _, p := range strings.Split(s, ";") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 557a1ede75..2476df640d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -439,6 +439,7 @@ func prepareMigrationTasks() []*migration { newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest), newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch), newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable), + newMigration(362, "Add delete allowlist to org protected branch", v1_27.AddDeleteAllowlistToOrgProtectedBranch), } return preparedMigrations } diff --git a/models/migrations/v1_27/v363.go b/models/migrations/v1_27/v363.go new file mode 100644 index 0000000000..8e2e3faa22 --- /dev/null +++ b/models/migrations/v1_27/v363.go @@ -0,0 +1,18 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import "xorm.io/xorm" + +// AddDeleteAllowlistToOrgProtectedBranch adds branch-deletion protection columns to +// org-level branch protection rules, mirroring the per-repo delete allowlist so an +// org rule can also protect branches from deletion. See issue #727. +func AddDeleteAllowlistToOrgProtectedBranch(x *xorm.Engine) error { + type OrgProtectedBranch struct { + CanDelete bool `xorm:"NOT NULL DEFAULT false"` + EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + } + return x.Sync(new(OrgProtectedBranch)) +} diff --git a/modules/structs/org_branch.go b/modules/structs/org_branch.go index 7333a4192d..0b66b47756 100644 --- a/modules/structs/org_branch.go +++ b/modules/structs/org_branch.go @@ -17,6 +17,9 @@ type OrgBranchProtection struct { EnableForcePush bool `json:"enable_force_push"` EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableDelete bool `json:"enable_delete"` + EnableDeleteAllowlist bool `json:"enable_delete_allowlist"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` EnableStatusCheck bool `json:"enable_status_check"` @@ -49,6 +52,9 @@ type CreateOrgBranchProtectionOption struct { EnableForcePush bool `json:"enable_force_push"` EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableDelete bool `json:"enable_delete"` + EnableDeleteAllowlist bool `json:"enable_delete_allowlist"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` EnableStatusCheck bool `json:"enable_status_check"` @@ -76,6 +82,9 @@ type EditOrgBranchProtectionOption struct { EnableForcePush *bool `json:"enable_force_push"` EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableDelete *bool `json:"enable_delete"` + EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` EnableStatusCheck *bool `json:"enable_status_check"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ea52615095..4b56cde6a6 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2411,6 +2411,28 @@ "repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch.save_rule": "Save Rule", "repo.settings.protected_branch.delete_rule": "Delete Rule", + "repo.settings.org_protected_branch": "Organization Branch Protection", + "repo.settings.org_protected_branch_desc": "These rules are defined by the organization and are enforced on top of this repository's own rules — the stricter of the two applies. They cannot be edited here.", + "repo.settings.org_protected_branch.inherited": "Organization", + "repo.settings.org_protected_branch.read_only": "Read-only", + "repo.settings.org_protected_branch.approvals": "Required approvals", + "repo.settings.org_protected_branch.signed": "Signed commits", + "repo.settings.org_protected_branch.status_check": "Required status checks", + "repo.settings.org_protected_branch.direct_push": "Direct push", + "repo.settings.org_protected_branch.force_push": "Force push", + "repo.settings.org_protected_branch.deletion": "Branch deletion", + "repo.settings.org_protected_branch.merge": "Merge restricted to", + "repo.settings.org_protected_branch.protected_files": "Protected files", + "repo.settings.org_protected_branch.also": "Also enforces", + "repo.settings.org_protected_branch.blocked": "Blocked", + "repo.settings.org_protected_branch.allowed": "Allowed", + "repo.settings.org_protected_branch.restricted": "Restricted to specific teams", + "repo.settings.org_protected_branch.write_access": "Anyone with write access", + "repo.settings.org_protected_branch.teams": "Teams: %s", + "repo.settings.org_protected_branch.any": "Any configured checks", + "repo.settings.org_protected_branch.block_outdated": "Block on outdated branch", + "repo.settings.org_protected_branch.block_rejected": "Block on rejected reviews", + "repo.settings.org_protected_branch.block_admin": "Block admin merge override", "repo.settings.protected_branch_can_push": "Allow push?", "repo.settings.protected_branch_can_push_yes": "You can push", "repo.settings.protected_branch_can_push_no": "You cannot push", diff --git a/routers/api/v1/org/branch_protection.go b/routers/api/v1/org/branch_protection.go index ff188f278d..010904087b 100644 --- a/routers/api/v1/org/branch_protection.go +++ b/routers/api/v1/org/branch_protection.go @@ -47,6 +47,9 @@ func toAPIOrgBranchProtection(ctx *context.APIContext, rule *git_model.OrgProtec EnableForcePush: rule.CanForcePush, EnableForcePushAllowlist: rule.EnableForcePushAllowlist, ForcePushAllowlistTeams: resolveTeamNames(rule.ForcePushAllowlistTeamIDs), + EnableDelete: rule.CanDelete, + EnableDeleteAllowlist: rule.EnableDeleteAllowlist, + DeleteAllowlistTeams: resolveTeamNames(rule.DeleteAllowlistTeamIDs), EnableMergeWhitelist: rule.EnableMergeWhitelist, MergeWhitelistTeams: resolveTeamNames(rule.MergeWhitelistTeamIDs), EnableStatusCheck: rule.EnableStatusCheck, @@ -211,6 +214,10 @@ func CreateOrgBranchProtection(ctx *context.APIContext) { if !ok { return } + deleteTeams, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams) + if !ok { + return + } rule := &git_model.OrgProtectedBranch{ OrgID: orgID, @@ -222,6 +229,9 @@ func CreateOrgBranchProtection(ctx *context.APIContext) { CanForcePush: form.EnablePush && form.EnableForcePush, EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist, ForcePushAllowlistTeamIDs: forcePushTeams, + CanDelete: form.EnableDelete, + EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist, + DeleteAllowlistTeamIDs: deleteTeams, EnableMergeWhitelist: form.EnableMergeWhitelist, MergeWhitelistTeamIDs: mergeTeams, EnableStatusCheck: form.EnableStatusCheck, @@ -323,6 +333,19 @@ func EditOrgBranchProtection(ctx *context.APIContext) { } rule.ForcePushAllowlistTeamIDs = ids } + if form.EnableDelete != nil { + rule.CanDelete = *form.EnableDelete + } + if form.EnableDeleteAllowlist != nil { + rule.EnableDeleteAllowlist = *form.EnableDeleteAllowlist + } + if form.DeleteAllowlistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams) + if !ok { + return + } + rule.DeleteAllowlistTeamIDs = ids + } if form.EnableMergeWhitelist != nil { rule.EnableMergeWhitelist = *form.EnableMergeWhitelist } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index fc6e82231f..819c9a793b 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -98,7 +98,7 @@ func Teams(ctx *context.Context) { } } - ctx.Data["OrgListTeams"] = teams + ctx.Data["Teams"] = teams ctx.Data["Keyword"] = keyword pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5) pager.AddParamFromRequest(ctx.Req) diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index c8881838ab..1b670038ba 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -34,6 +34,64 @@ const ( tplProtectedBranch templates.TplName = "repo/settings/protected_branch" ) +// orgBranchProtectionView is a read-only presentation of an org-level branch +// protection rule for the repo settings page, with team IDs resolved to names. +type orgBranchProtectionView struct { + Rule *git_model.OrgProtectedBranch + PushTeams string + ForcePushTeams string + DeleteTeams string + MergeTeams string + ApprovalTeams string + StatusContexts string +} + +// prepareOrgProtectedBranches loads the owning organization's branch protection +// rules and exposes them (with team IDs resolved to names) as read-only view models +// under the "OrgProtectedBranches" template key. +func prepareOrgProtectedBranches(ctx *context.Context) error { + orgRules, err := git_model.FindOrgProtectedBranchRules(ctx, ctx.Repo.Owner.ID) + if err != nil { + return err + } + if len(orgRules) == 0 { + return nil + } + + teams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID) + if err != nil { + return err + } + teamNames := make(map[int64]string, len(teams)) + for _, t := range teams { + teamNames[t.ID] = t.Name + } + join := func(ids []int64) string { + names := make([]string, 0, len(ids)) + for _, id := range ids { + if n, ok := teamNames[id]; ok { + names = append(names, n) + } + } + return strings.Join(names, ", ") + } + + views := make([]*orgBranchProtectionView, len(orgRules)) + for i, r := range orgRules { + views[i] = &orgBranchProtectionView{ + Rule: r, + PushTeams: join(r.WhitelistTeamIDs), + ForcePushTeams: join(r.ForcePushAllowlistTeamIDs), + DeleteTeams: join(r.DeleteAllowlistTeamIDs), + MergeTeams: join(r.MergeWhitelistTeamIDs), + ApprovalTeams: join(r.ApprovalsWhitelistTeamIDs), + StatusContexts: strings.Join(r.StatusCheckContexts, ", "), + } + } + ctx.Data["OrgProtectedBranches"] = views + return nil +} + // ProtectedBranchRules render the page to protect the repository func ProtectedBranchRules(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.branches") @@ -46,6 +104,16 @@ func ProtectedBranchRules(ctx *context.Context) { } ctx.Data["ProtectedBranches"] = rules + // Surface the organization-level rules that also apply to this repo (read-only), + // so admins can see the org "floor" that is layered on top of the repo's own + // rules at enforcement time. See issue #727. + if ctx.Repo.Owner.IsOrganization() { + if err := prepareOrgProtectedBranches(ctx); err != nil { + ctx.ServerError("prepareOrgProtectedBranches", err) + return + } + } + repo.PrepareBranchList(ctx) if ctx.Written() { return diff --git a/templates/repo/issue/sidebar/issue_type.tmpl b/templates/repo/issue/sidebar/issue_type.tmpl index 42fb18277f..bb41078ff9 100644 --- a/templates/repo/issue/sidebar/issue_type.tmpl +++ b/templates/repo/issue/sidebar/issue_type.tmpl @@ -2,7 +2,7 @@
{{ctx.Locale.Tr "repo.issues.type"}} - {{$canModify := and .FieldEditFlags .FieldEditFlags.CustomFields}} + {{$canModify := .HasIssuesOrPullsWritePermission}} {{if $canModify}}
{{$.CsrfTokenHtml}} diff --git a/templates/repo/settings/branches.tmpl b/templates/repo/settings/branches.tmpl index 6ec80c057f..40947c86b9 100644 --- a/templates/repo/settings/branches.tmpl +++ b/templates/repo/settings/branches.tmpl @@ -62,6 +62,88 @@ {{end}}
+ + {{if .OrgProtectedBranches}} +

+ {{ctx.Locale.Tr "repo.settings.org_protected_branch"}} +

+
+

{{ctx.Locale.Tr "repo.settings.org_protected_branch_desc"}}

+
+ {{range .OrgProtectedBranches}} +
+
+
+ +
{{.Rule.RuleName}}
+ {{svg "octicon-organization" 12}} {{ctx.Locale.Tr "repo.settings.org_protected_branch.inherited"}} + {{svg "octicon-lock" 12}} {{ctx.Locale.Tr "repo.settings.org_protected_branch.read_only"}} +
+ + + + + + + + + + + + + + + {{if gt .Rule.RequiredApprovals 0}} + + + + + {{end}} + {{if .Rule.EnableMergeWhitelist}} + + + + + {{end}} + {{if .Rule.EnableStatusCheck}} + + + + + {{end}} + {{if .Rule.RequireSignedCommits}} + + + + + {{end}} + {{if .Rule.ProtectedFilePatterns}} + + + + + {{end}} + {{if or .Rule.BlockOnOutdatedBranch .Rule.BlockOnRejectedReviews .Rule.BlockAdminMergeOverride}} + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.settings.org_protected_branch.direct_push"}}{{if not .Rule.CanPush}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableWhitelist}}{{if .PushTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .PushTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.write_access"}}{{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.force_push"}}{{if not .Rule.CanForcePush}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableForcePushAllowlist}}{{if .ForcePushTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .ForcePushTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.allowed"}}{{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.deletion"}}{{if not .Rule.CanDelete}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableDeleteAllowlist}}{{if .DeleteTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .DeleteTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.allowed"}}{{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.approvals"}}{{.Rule.RequiredApprovals}}{{if .ApprovalTeams}} ({{.ApprovalTeams}}){{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.merge"}}{{if .MergeTeams}}{{.MergeTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.status_check"}}{{if .StatusContexts}}{{.StatusContexts}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.any"}}{{end}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.signed"}}{{svg "octicon-check" 14}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.protected_files"}}{{.Rule.ProtectedFilePatterns}}
{{ctx.Locale.Tr "repo.settings.org_protected_branch.also"}} +
+ {{if .Rule.BlockOnOutdatedBranch}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_outdated"}}{{end}} + {{if .Rule.BlockOnRejectedReviews}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_rejected"}}{{end}} + {{if .Rule.BlockAdminMergeOverride}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_admin"}}{{end}} +
+
+
+
+
+ {{end}} +
+
+ {{end}} {{end}} diff --git a/web_src/js/features/admin/config.test.ts b/web_src/js/features/admin/config.test.ts index b8468610ca..d633aa9502 100644 --- a/web_src/js/features/admin/config.test.ts +++ b/web_src/js/features/admin/config.test.ts @@ -21,6 +21,17 @@ test('ConfigFormValueMapper', () => { + + + + + + + + + + + `; @@ -44,5 +55,7 @@ test('ConfigFormValueMapper', () => { 'k-flipped-true': 'true', 'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend 'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}', + 'landing': '{"Mode":"explore"}', + 'landingDefault': '{"Mode":"home"}', }); }); diff --git a/web_src/js/features/admin/config.ts b/web_src/js/features/admin/config.ts index 96c7dcf84b..64cba73213 100644 --- a/web_src/js/features/admin/config.ts +++ b/web_src/js/features/admin/config.ts @@ -99,6 +99,10 @@ export class ConfigFormValueMapper { if (el.matches('[type="checkbox"]')) { if (valType !== 'boolean') requireExplicitValueType(el); el.checked = Boolean(val ?? el.checked); + } else if (el.matches('[type="radio"]')) { + // a radio group shares one name; check only the option whose value equals the config value. + // when the value is empty (unset), leave the server-rendered default selection untouched. + if (String(val) !== '') el.checked = el.value === String(val); } else if (el.matches('[type="datetime-local"]')) { if (valType !== 'timestamp') requireExplicitValueType(el); if (val) el.value = toDatetimeLocalValue(val); @@ -120,6 +124,9 @@ export class ConfigFormValueMapper { // it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly, // and set the namedElems[matchedIdx] to null to avoid duplicate processing. val = collectCheckboxBooleanValue(el); + } else if (el.matches('[type="radio"]')) { + // only the checked radio of a group reaches here (callers skip unchecked ones); its value is the selection + val = el.value; } else if (el.matches('[type="datetime-local"]')) { if (valType !== 'timestamp') requireExplicitValueType(el); val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null. @@ -139,6 +146,12 @@ export class ConfigFormValueMapper { if (!el) continue; const subKey = extractElemConfigSubKey(el, dynKey); if (!subKey) continue; // if not match, skip + // a radio group has N elements sharing the same name; only the checked one carries the value. + // drop the unchecked ones so they neither overwrite the selection here nor leak into the fallback loop. + if (el.matches('[type="radio"]') && !el.checked) { + namedElems[idx] = null; + continue; + } cfgVal[subKey] = this.collectConfigValueFromElement(el); namedElems[idx] = null; } @@ -194,6 +207,7 @@ export class ConfigFormValueMapper { // "foo.enabled" => "true" for (const el of namedElems) { if (!el) continue; + if (el.matches('[type="radio"]') && !el.checked) continue; // skip unchecked radios of a top-level group const dynKey = el.name; const newVal = this.collectConfigValueFromElement(el); formData.append('key', dynKey);