Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd7b0ac53 | |||
| 0ca8441d1e | |||
| d07cfd412b | |||
| bd821e2d44 | |||
| aeed197ea5 | |||
| 45fc346d52 | |||
| 02071a23d6 | |||
| 3a5c6a37cf | |||
| 37fb3703c7 | |||
| 6a3db171c1 | |||
| d3134b1c53 | |||
| 3aac1b456c | |||
| b31336d1fe | |||
| 4b68853f08 | |||
| 86bd8a2cad | |||
| 24b3516c1d | |||
| 056eb7d3c4 | |||
| 0492448ab5 | |||
| 377dd019be | |||
| 5ffed39449 | |||
| 0cc569aef6 | |||
| 4ed45a5916 | |||
| ea87b3db97 | |||
| d77b82a8af | |||
| 22b0b14ea8 | |||
| 1256f57d40 | |||
| 05763eb661 | |||
| 0c45a2fda3 | |||
| 8680fd1cab | |||
| adf0e923df | |||
| 296df7792a | |||
| 51332a587d | |||
| 8fc2700c16 | |||
| 844b4b6e81 | |||
| cb9516b79b | |||
| aea4370845 | |||
| fc234bc911 | |||
| b252e9569f | |||
| efb0433412 | |||
| 982e45a56e | |||
| 343cba690e | |||
| 78328146e5 | |||
| 84c6a94333 | |||
| 11b2195bdf | |||
| f8a91ed34e | |||
| 87f92fe1ab | |||
| 66aea89b40 | |||
| 7c75133ef1 | |||
| 108b19dcc8 | |||
| 37a62b5ab7 | |||
| 35ebbef489 | |||
| 193c705c05 | |||
| fa845164bb | |||
| fefdf1a1ec | |||
| 23bb025700 | |||
| 7913a05285 | |||
| 98301bc92b | |||
| c618ec9f87 | |||
| 5a25068d81 | |||
| 57894e25fd | |||
| 2857a1f6a1 | |||
| b9c04e51b4 | |||
| 0b44e92369 | |||
| cf25eef480 | |||
| 5da6a40f10 | |||
| 4e5aa5f3ce | |||
| 9a4aa0fafb | |||
| e947600ea7 | |||
| b0bbaab621 | |||
| 84df5d7932 | |||
| 7b334f94c0 | |||
| 805c566615 | |||
| f53bc895ba | |||
| 2ff0e4aa21 | |||
| c0f89a373d | |||
| 0dc858c15c | |||
| 94590bc834 | |||
| cbf34fb987 | |||
| 26ad4fd03f | |||
| bc578b7eba | |||
| 186ac68f03 | |||
| fedce235d5 | |||
| d3c6998d3e | |||
| e9648f367e | |||
| 725acbe112 | |||
| 884e568ea2 | |||
| e99658ddc0 | |||
| f627219ca8 | |||
| df9305758f | |||
| ebdf59d64f | |||
| 8ffd2ffe18 | |||
| d2a3827202 | |||
| 3cb45ba457 | |||
| 92cf6a8521 | |||
| 81d20e25bf | |||
| 2d0b746b5f | |||
| 035ecb94f8 | |||
| c47013edb0 | |||
| 4178e7f23e | |||
| 46cbf6600a | |||
| afe46361c7 | |||
| e66c75753d | |||
| d6e56460a4 | |||
| 322bd982bd | |||
| a48f44c901 | |||
| 882eb2cce7 | |||
| f962ae575a | |||
| 58074ac860 | |||
| 9db67cd554 | |||
| a063c3b2e4 | |||
| ac48c1d958 | |||
| ad06fa7bec | |||
| ed068f2964 | |||
| 03c8755fe1 | |||
| bd5c435f27 | |||
| a25a673d0c | |||
| 02ff660912 | |||
| bc6bd9bf45 | |||
| 03547194a4 | |||
| 6131739e39 | |||
| 86e1db1dc8 | |||
| 61e8b9deef | |||
| 9d3803a0b5 | |||
| 53fab919cf | |||
| a47c4c7f67 | |||
| 1d29460244 | |||
| 24cd2986ab | |||
| bf8b747ef9 | |||
| 467ea55ee8 | |||
| 1ac0449cde | |||
| d4221bcc9e | |||
| 6ef4a61eab | |||
| ba1427a19f | |||
| 90d9fa9482 | |||
| 699d8540d4 | |||
| 456235faaf | |||
| 5c2321b164 | |||
| 8798ccb478 | |||
| 5c1b4e6509 | |||
| 2a5a2dd845 | |||
| 2708388542 | |||
| f7c2b205c5 | |||
| d2d7c0a762 | |||
| 3eb0dfd011 | |||
| 49f6380fa4 | |||
| b545e7414c | |||
| 024b00a38e | |||
| 8f9a43f32c | |||
| 225ea65881 | |||
| 4c018fac62 | |||
| 41e48945fd | |||
| 7d28fe522d | |||
| 8780ec7c5f | |||
| 9106cfe254 | |||
| f7d70ae95a | |||
| 5c43cf1f02 | |||
| c8c74c7afe | |||
| 7b68963b67 | |||
| 22c2a99f8b | |||
| c974970118 | |||
| c9a8deee0e | |||
| ea05851d0a | |||
| 1178975be3 | |||
| b283fad8bd | |||
| a792772397 | |||
| 4d1be56bad | |||
| 2dc745c5fa | |||
| 9dda78da7c | |||
| 6ceef765eb | |||
| 23d3528676 | |||
| 249b639c70 | |||
| 5c9db551dc | |||
| 408f2329b3 | |||
| 827025bd17 | |||
| 98da1644be | |||
| db596575a0 | |||
| 3c56dc8814 | |||
| dce712fabd | |||
| 78b0ce9650 | |||
| 500a5be6d7 | |||
| 95a747b1d5 | |||
| bb7e99ad40 | |||
| 6c6b7c888e | |||
| 2a1692d599 | |||
| 6984ac108f | |||
| 3fdbe94830 | |||
| e937dd8d8b | |||
| e7b70f54ed | |||
| b161561571 | |||
| b981cf72e3 | |||
| 9964c7e16c | |||
| ff27e77c37 | |||
| 04ce7dc896 | |||
| f87f904a21 | |||
| fc72d8e90a | |||
| 71d52e432e | |||
| 172303b61f | |||
| bfb4b53da3 | |||
| 9149fa100c |
@@ -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/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: '[FEATURE] '
|
||||
title: '(feat) '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Apply protection rules
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
@@ -214,13 +214,13 @@ jobs:
|
||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||
curl -sS -o /dev/null -w "" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||
|
||||
# Create rule
|
||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RULE" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
# 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
|
||||
#
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
@@ -21,15 +21,24 @@
|
||||
# | 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]
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
@@ -43,7 +52,7 @@ on:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_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 }}
|
||||
|
||||
@@ -51,13 +60,18 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
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_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
|
||||
@@ -65,6 +79,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -80,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
|
||||
@@ -89,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 "${GITEA_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: |
|
||||
@@ -111,7 +154,7 @@ jobs:
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
@@ -149,13 +192,17 @@ jobs:
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
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
|
||||
@@ -163,6 +210,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
@@ -198,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
|
||||
@@ -241,14 +289,50 @@ jobs:
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
@@ -317,7 +401,7 @@ jobs:
|
||||
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}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
@@ -346,7 +430,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
@@ -370,7 +454,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_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}"
|
||||
@@ -391,7 +475,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
@@ -417,5 +501,5 @@ jobs:
|
||||
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
|
||||
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -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 }}" \
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
# 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)
|
||||
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -26,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
|
||||
@@ -124,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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||
|
||||
name: "Universal: CI Issue Reporter"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
gate:
|
||||
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||
required: true
|
||||
type: string
|
||||
details:
|
||||
description: "Human-readable failure description"
|
||||
required: true
|
||||
type: string
|
||||
severity:
|
||||
description: "error or warning"
|
||||
required: false
|
||||
type: string
|
||||
default: "error"
|
||||
workflow:
|
||||
description: "Workflow name for the issue title"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
secrets:
|
||||
MOKOGITEA_TOKEN:
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: "Report: ${{ inputs.gate }}"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone MokoCLI
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||
|
||||
- name: Report CI failure
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||
--gate "${{ inputs.gate }}" \
|
||||
--details "${{ inputs.details }}" \
|
||||
--severity "${{ inputs.severity }}" \
|
||||
--workflow "${{ inputs.workflow }}"
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
@@ -33,30 +33,30 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
for BRANCH in $BRANCHES; do
|
||||
# Skip protected branches
|
||||
case "$BRANCH" in
|
||||
main|master|develop|release/*|hotfix/*) continue ;;
|
||||
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
|
||||
esac
|
||||
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,10 @@
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# BRIEF: Build and deploy MokoGitea to dev environment on push to dev branch.
|
||||
# Production deploy (deploy-mokogitea.yml) only succeeds if dev is healthy.
|
||||
|
||||
name: Deploy MokoGitea (Dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency:
|
||||
group: deploy-mokogitea-dev
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: git.mokoconsulting.tech
|
||||
IMAGE: mokoconsulting/mokogitea
|
||||
DEPLOY_HOST: git.mokoconsulting.tech
|
||||
DEPLOY_PORT: 2918
|
||||
DEPLOY_USER: mokoconsulting
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
name: "Build & Deploy to Dev"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version
|
||||
id: config
|
||||
run: |
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev-$(git rev-parse --short HEAD)")
|
||||
echo "tag=${VERSION}-dev" >> $GITHUB_OUTPUT
|
||||
echo "Version: ${VERSION}-dev"
|
||||
|
||||
- name: Write deploy key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
|
||||
- name: Build and deploy to dev via SSH
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
TAG: ${{ steps.config.outputs.tag }}
|
||||
run: |
|
||||
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
|
||||
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
|
||||
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
||||
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
|
||||
set -e
|
||||
echo 'SSH connected to dev environment'
|
||||
|
||||
echo 'Cleaning Docker build cache...'
|
||||
docker builder prune -af 2>/dev/null || true
|
||||
docker image prune -af 2>/dev/null || true
|
||||
|
||||
echo 'Pulling source...'
|
||||
SOURCE_DIR=/opt/gitea-dev/source
|
||||
if [ ! -d \$SOURCE_DIR/.git ]; then
|
||||
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
|
||||
fi
|
||||
cd \$SOURCE_DIR
|
||||
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
|
||||
git fetch origin dev
|
||||
git reset --hard origin/dev
|
||||
|
||||
echo 'Building Docker image...'
|
||||
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
|
||||
-f Dockerfile .
|
||||
|
||||
echo 'Pushing to registry...'
|
||||
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
|
||||
|
||||
echo 'Restarting dev container...'
|
||||
cd /opt/gitea-dev
|
||||
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
|
||||
docker compose up -d mokogitea-dev
|
||||
|
||||
echo 'Health check...'
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
sleep 15
|
||||
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
|
||||
echo 'Dev container healthy!'
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... (attempt \$i/8)"
|
||||
done
|
||||
echo 'Health check failed'
|
||||
docker logs mokogitea-dev --tail 20
|
||||
exit 1
|
||||
DEPLOY_EOF
|
||||
|
||||
- name: Verify dev instance
|
||||
run: |
|
||||
sleep 5
|
||||
curl -sf https://git.dev.mokoconsulting.tech/api/healthz && echo " Dev API healthy"
|
||||
+11
@@ -39,6 +39,17 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify dev environment is healthy
|
||||
run: |
|
||||
echo "Checking git.dev.mokoconsulting.tech health..."
|
||||
if curl -sf --max-time 10 https://git.dev.mokoconsulting.tech/api/healthz; then
|
||||
echo " Dev environment is healthy — proceeding with production deploy"
|
||||
else
|
||||
echo "::error::Dev environment is NOT healthy — blocking production deploy"
|
||||
echo "Deploy to dev first (push to dev branch) and verify it passes before merging to main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout source (for version detection)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
+12
-3
@@ -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>\(.*\)<\/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 }}
|
||||
@@ -1,126 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Universal: Build & Release"
|
||||
- "Joomla: Extension CI"
|
||||
- "Generic: Project CI"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Publish MCP to npm
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.mokogitea/mcp/**'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install and build
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
npm ci
|
||||
npx tsc
|
||||
|
||||
- name: Check version change
|
||||
id: version
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
NPM_VERSION=$(npm view @mokoconsulting/mokogitea-mcp version 2>/dev/null || echo "0.0.0")
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "Version unchanged: $LOCAL_VERSION"
|
||||
fi
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.changed == 'true'
|
||||
working-directory: .mokogitea/mcp
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to Gitea registry
|
||||
if: steps.version.outputs.changed == 'true'
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
npm publish --registry ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ \
|
||||
--//$(echo "${{ github.server_url }}" | sed 's|https://||')/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.MOKOGITEA_TOKEN }}
|
||||
+528
-534
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# VERSION: 05.02.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -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
|
||||
@@ -59,6 +63,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Update submodules to main
|
||||
run: |
|
||||
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -88,8 +97,20 @@ jobs:
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
@@ -166,6 +187,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -176,6 +198,7 @@ jobs:
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -212,6 +235,7 @@ jobs:
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
@@ -225,6 +249,7 @@ jobs:
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -29,12 +29,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
env:
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
set -euo pipefail
|
||||
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
|
||||
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
|
||||
fi
|
||||
SUFFIX="${BRANCH#rc/}"
|
||||
DEV_BRANCH="dev/${SUFFIX}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
@@ -42,25 +50,22 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||
"${API}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "201" ]; then
|
||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
# PATH: /.mokogitea/workflows/version-set.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||
|
||||
name: "Joomla: Set Version"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g. 01.00.00)"
|
||||
required: true
|
||||
type: string
|
||||
branch:
|
||||
description: "Branch to update (default: current)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
set-version:
|
||||
name: Set Version to ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update manifest version
|
||||
run: |
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||
else
|
||||
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||
fi
|
||||
|
||||
- name: Update README.md version
|
||||
run: |
|
||||
if [ -f "README.md" ]; then
|
||||
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||
echo "README.md version updated to ${VERSION}"
|
||||
else
|
||||
echo "::warning::No VERSION line found in README.md — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
# Check if this version already has an entry
|
||||
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||
else
|
||||
# Insert new version entry after [Unreleased] or at the top after header
|
||||
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
else
|
||||
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
fi
|
||||
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||
fi
|
||||
else
|
||||
echo "::warning::No CHANGELOG.md found — skipping"
|
||||
fi
|
||||
|
||||
- name: Update FILE INFORMATION blocks
|
||||
run: |
|
||||
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||
while IFS= read -r -d '' FILE; do
|
||||
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "Moko Consulting [bot]"
|
||||
git config user.email "hello@mokoconsulting.tech"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes detected — nothing to commit"
|
||||
else
|
||||
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||
|
||||
Authored-by: Moko Consulting"
|
||||
git push
|
||||
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -13,6 +13,7 @@
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
@@ -26,8 +27,9 @@ jobs:
|
||||
name: Sync workflows to live repos
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]'))
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
@@ -49,8 +51,14 @@ jobs:
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
|
||||
- name: Install PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
## [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)
|
||||
- Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727)
|
||||
- Org-level push policy: one policy per org, enforced in the pre-receive hook across all its repositories — branch/tag name conventions (glob), a mandatory secret-scanning block-on-push that repos cannot disable, a max pushed-file size, and blocked file-path patterns. API at `/orgs/{org}/push_policy`. Naming is fail-closed; the content checks (blocked paths, max size) fail open on error so a policy bug can never block every push (#727)
|
||||
- Org-level repository defaults: an org can force new/transferred repositories private and set default pull-request settings (allowed merge styles, default merge style, auto-delete branch after merge), applied via a notifier when a repo is created in or transferred into the org (best-effort — never blocks repo creation). API at `/orgs/{org}/repo_defaults` (#727)
|
||||
- Org-level email domain policy: restrict which email domains an organization's members may have — a user can only be added to the org (via any team) if their primary email matches one of the allowed domain globs. Enforced at the single membership-add choke point (`AddTeamMember`); API at `/orgs/{org}/email_domain_policy` (#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)
|
||||
- Cross-org status migration: copy status definitions from one org to another via API (#507)
|
||||
- Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513)
|
||||
- Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696)
|
||||
- Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693)
|
||||
- API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697)
|
||||
- Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697)
|
||||
- Wiki full-text search: case-insensitive search across all wiki page titles and content (#550)
|
||||
- Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550)
|
||||
- Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692)
|
||||
- Security scanning API: REST endpoints for alerts, config, and on-demand scans (GET/PATCH /security/alerts, /security/config, POST /security/scan) (#692)
|
||||
- Pre-receive hook secret blocking: push rejection when block_on_push enabled and secrets detected in commits (#692)
|
||||
- Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all
|
||||
- Wiki revision diff: line-by-line diff view per commit in wiki page history (#667)
|
||||
- Wiki categories: YAML frontmatter `categories:` with category index page (#668)
|
||||
- Wiki template transclusion: `{{template:Name|key=val}}` with `_Template/` folder (#671)
|
||||
- Wiki enhanced ToC: collapsible, inline via frontmatter, sticky sidebar (#673)
|
||||
- Wiki folder ACL: `_access.yml` per-folder write protection (#674)
|
||||
- Wiki print view and ZIP export of all wiki pages (#675)
|
||||
- Wiki features documentation page in org wiki (standards/Wiki-Features)
|
||||
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
||||
- License CRUD with CRC32-checksummed DLID generation and format validation
|
||||
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
||||
@@ -10,6 +38,69 @@
|
||||
- 13 seeded product tiers from base to enterprise
|
||||
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
|
||||
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
|
||||
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
|
||||
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
|
||||
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
|
||||
- Issue status API response includes is_required field
|
||||
- Wiki recent changes page: cross-page edit activity with pagination (#670)
|
||||
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
|
||||
|
||||
### Security
|
||||
- Cherry-pick upstream v1.26.3: LFS reject unknown SSH sub-verbs to prevent auth bypass (#38015)
|
||||
- Cherry-pick upstream v1.26.3: bound CODEOWNERS regex match time — ReDoS prevention (#38025)
|
||||
- Cherry-pick upstream v1.26.3: require merged PR to bypass fork PR approval gate (#38041)
|
||||
- Cherry-pick upstream v1.26.3: LFS require Code-unit access for cross-repo object reuse (#38050)
|
||||
- Cherry-pick upstream v1.26.3: hostmatcher block reserved IP ranges — SSRF prevention (#38059)
|
||||
- Cherry-pick upstream v1.26.3: bound debian ParseControlFile — DoS prevention (#38055)
|
||||
- Cherry-pick upstream v1.26.3: feed token scope, migration SSRF, notification redaction (#38147)
|
||||
- Cherry-pick upstream v1.26.3: OIDC ignore stale external login links to organizations (#38141)
|
||||
- Cherry-pick upstream v1.26.3: 2FA timing, branch delete auth, org labels visibility, merge upstream auth (#38151)
|
||||
- Cherry-pick upstream v1.26.3: allow git clone of private repos with anonymous code access (#38146)
|
||||
- Cherry-pick upstream v1.26.3: hostmatcher patch incorrect private IP list (#38173)
|
||||
- Cherry-pick upstream v1.26.4: do not auto-reactivate disabled users on OAuth2 callback (#38183)
|
||||
- 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)
|
||||
- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885)
|
||||
- Cherry-pick upstream: reject workflow_dispatch for workflows without that trigger (#37660)
|
||||
- Cherry-pick upstream: keep action run title clickable when commit subject is a URL (#37867)
|
||||
- Cherry-pick upstream: exclude workflow_call from workflow trigger detection (#37894)
|
||||
- API token edit: reject empty scope update requests with 400 instead of silently succeeding
|
||||
- Workflow token auth: pr-check.yml pre-release dispatch was silently failing due to env var / curl reference mismatch
|
||||
- Workflow tokens: standardize all GA_TOKEN/GITEA_TOKEN/GITEA_URL env vars to MOKOGITEA_TOKEN/MOKOGITEA_URL across all workflow files in 5 template repos + MokoCLI (65+ files)
|
||||
- CI issue reporter: rename GITEA_TOKEN/GITEA_URL to MOKOGITEA_TOKEN/MOKOGITEA_URL in automation/ci-issue-reporter.sh
|
||||
- Workflow sync trigger: add workflow_dispatch event, fix if-condition to allow manual dispatch, add PHP install step for non-PHP runners
|
||||
- Deploy workflow: merge dev health check into deploy job to avoid runner status reporting failures on inter-job handoff
|
||||
- Licensing API: handle DB write errors in UpdateLicense, UpdateTier, DeleteTier instead of silently discarding
|
||||
- Wiki API: fix findEntryForFile URL-decode fallback for non-ASCII page names
|
||||
- Metadata settings template 500 error: removed reference to deleted Version field
|
||||
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
|
||||
- Wiki backlinks: proper URL encoding for subdirectory pages
|
||||
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
|
||||
- Issue statuses template: garbled em-dash character replaced
|
||||
|
||||
### Changed
|
||||
- Custom workflows moved to `.mokogitea/workflows/custom/`: deploy-mokogitea, deploy-dev, cascade-dev, pr-rc-release, test-mokogitea, upstream-bug-sync
|
||||
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
|
||||
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
|
||||
- CI issue reporter: moved to MokoCLI (cli/ci_issue_reporter.sh), pr-check and repo-health now use ci-issue-reporter.yml reusable workflow
|
||||
|
||||
### Removed
|
||||
- Workflows: gitleaks.yml, npm-publish.yml, notify.yml, workflow-sync-trigger.yml, composer-publish.yml, deploy-manual.yml, security-audit.yml (not applicable to Go repo)
|
||||
- automation/ci-issue-reporter.sh: moved to MokoCLI as centralized CLI tool
|
||||
|
||||
## [06.19.00] --- 2026-06-20
|
||||
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
# MokoGitea
|
||||
|
||||
Moko fork of Gitea — adding project board REST API endpoints and custom enhancements
|
||||
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API.
|
||||
|
||||
  
|
||||
|
||||
|
||||
Custom Gitea fork with Project Board API
|
||||
 
|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
## Key Features
|
||||
|
||||
- [Branding](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Branding)
|
||||
- [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment)
|
||||
- [Project API](Project API)
|
||||
- [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap)
|
||||
|
||||
---
|
||||
|
||||
**Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
|
||||
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
|
||||
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
|
||||
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration
|
||||
- **Cascade Merge** -- auto-create PRs to downstream branches after merge with configurable rules per repo
|
||||
- **Security Scanning** -- secret detection (pre-receive blocking) + code analysis (SQL injection, XSS, command injection, path traversal, and more) with REST API for alerts, config, and on-demand scans
|
||||
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
|
||||
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
|
||||
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
|
||||
- **Project Board API** -- REST endpoints for project columns and cards
|
||||
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
|
||||
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available on the [Wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki).
|
||||
- [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs
|
||||
- [Wiki Features](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features) -- all 10 wiki enhancements
|
||||
- [Licensing API](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/api/Licensing-API)
|
||||
- [Repo Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork/wiki/) -- feature docs, API reference, operations
|
||||
|
||||
## Contributing
|
||||
|
||||
See the wiki for development guidelines and contribution instructions.
|
||||
See the [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions.
|
||||
|
||||
## License
|
||||
|
||||
@@ -40,4 +38,4 @@ This project is licensed under the GNU General Public License v3.0 or later -- s
|
||||
|
||||
---
|
||||
|
||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki/Home)*
|
||||
*[Moko Consulting](https://mokoconsulting.tech)*
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/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
|
||||
+14
-9
@@ -113,23 +113,25 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
|
||||
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
|
||||
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
|
||||
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
|
||||
// check in routers/private/serv.go and grant access.
|
||||
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
|
||||
switch verb {
|
||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
case git.CmdVerbReceivePack:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||
switch lfsVerb {
|
||||
case git.CmdSubVerbLfsUpload:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdSubVerbLfsDownload:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
}
|
||||
}
|
||||
// should be unreachable
|
||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
||||
return perm.AccessModeNone
|
||||
return perm.AccessModeNone, false
|
||||
}
|
||||
|
||||
func runServ(ctx context.Context, c *cli.Command) error {
|
||||
@@ -247,7 +249,10 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
}
|
||||
|
||||
requestedMode := getAccessMode(verb, lfsVerb)
|
||||
requestedMode, ok := getAccessMode(verb, lfsVerb)
|
||||
if !ok {
|
||||
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
|
||||
}
|
||||
|
||||
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||
if extra.HasError() {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetAccessMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
verb, lfsVerb string
|
||||
expected perm.AccessMode
|
||||
}{
|
||||
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
|
||||
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
|
||||
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.expected, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
|
||||
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
|
||||
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
|
||||
// it would pass the `userMode < mode` permission check in routers/private/serv.go
|
||||
// and hand out valid LFS JWTs for any private repository.
|
||||
func TestGetAccessModeUnknownVerb(t *testing.T) {
|
||||
cases := []struct{ verb, lfsVerb string }{
|
||||
{git.CmdVerbLfsAuthenticate, ""},
|
||||
{git.CmdVerbLfsAuthenticate, "badverb"},
|
||||
{git.CmdVerbLfsTransfer, "badverb"},
|
||||
{"git-unknown-verb", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, perm.AccessModeNone, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
Submodule mcp-mokogitea-api deleted from dbaf91546e
@@ -64,7 +64,6 @@ type FindRunOptions struct {
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
TriggerUserID int64
|
||||
TriggerEvent webhook_module.HookEventType
|
||||
Approved bool // not util.OptionalBool, it works only when it's true
|
||||
Status []Status
|
||||
ConcurrencyGroup string
|
||||
CommitSHA string
|
||||
@@ -81,9 +80,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.TriggerUserID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
||||
}
|
||||
if opts.Approved {
|
||||
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
|
||||
}
|
||||
if len(opts.Status) > 0 {
|
||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
AccessTokenScopeCategoryIssue
|
||||
AccessTokenScopeCategoryRepository
|
||||
AccessTokenScopeCategoryUser
|
||||
AccessTokenScopeCategoryLicensing
|
||||
)
|
||||
|
||||
// AllAccessTokenScopeCategories contains all access token scope categories
|
||||
@@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{
|
||||
AccessTokenScopeCategoryIssue,
|
||||
AccessTokenScopeCategoryRepository,
|
||||
AccessTokenScopeCategoryUser,
|
||||
AccessTokenScopeCategoryLicensing,
|
||||
}
|
||||
|
||||
// AccessTokenScopeLevel represents the access levels without a given scope category
|
||||
@@ -82,6 +84,9 @@ const (
|
||||
|
||||
AccessTokenScopeReadUser AccessTokenScope = "read:user"
|
||||
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
|
||||
|
||||
AccessTokenScopeReadLicensing AccessTokenScope = "read:licensing"
|
||||
AccessTokenScopeWriteLicensing AccessTokenScope = "write:licensing"
|
||||
)
|
||||
|
||||
// accessTokenScopeBitmap represents a bitmap of access token scopes.
|
||||
@@ -93,7 +98,8 @@ const (
|
||||
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits |
|
||||
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits |
|
||||
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits |
|
||||
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits
|
||||
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits |
|
||||
accessTokenScopeWriteLicensingBits
|
||||
|
||||
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota
|
||||
|
||||
@@ -124,6 +130,9 @@ const (
|
||||
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
|
||||
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
|
||||
|
||||
accessTokenScopeReadLicensingBits accessTokenScopeBitmap = 1 << iota
|
||||
accessTokenScopeWriteLicensingBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadLicensingBits
|
||||
|
||||
// The current implementation only supports up to 64 token scopes.
|
||||
// If we need to support > 64 scopes,
|
||||
// refactoring the whole implementation in this file (and only this file) is needed.
|
||||
@@ -142,6 +151,7 @@ var allAccessTokenScopes = []AccessTokenScope{
|
||||
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
|
||||
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
|
||||
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
|
||||
AccessTokenScopeWriteLicensing, AccessTokenScopeReadLicensing,
|
||||
}
|
||||
|
||||
// allAccessTokenScopeBits contains all access token scopes.
|
||||
@@ -166,6 +176,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{
|
||||
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
|
||||
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
|
||||
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
|
||||
AccessTokenScopeReadLicensing: accessTokenScopeReadLicensingBits,
|
||||
AccessTokenScopeWriteLicensing: accessTokenScopeWriteLicensingBits,
|
||||
}
|
||||
|
||||
// readAccessTokenScopes maps a scope category to the read permission scope
|
||||
@@ -180,6 +192,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
||||
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
|
||||
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
|
||||
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
|
||||
AccessTokenScopeCategoryLicensing: AccessTokenScopeReadLicensing,
|
||||
},
|
||||
Write: {
|
||||
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
|
||||
@@ -191,6 +204,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
||||
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
|
||||
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
|
||||
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
|
||||
AccessTokenScopeCategoryLicensing: AccessTokenScopeWriteLicensing,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -370,7 +384,7 @@ func (bitmap accessTokenScopeBitmap) toScope() AccessTokenScope {
|
||||
scope := AccessTokenScope(strings.Join(scopes, ","))
|
||||
scope = AccessTokenScope(strings.ReplaceAll(
|
||||
string(scope),
|
||||
"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user",
|
||||
"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing",
|
||||
"all",
|
||||
))
|
||||
return scope
|
||||
|
||||
@@ -17,13 +17,13 @@ type scopeTestNormalize struct {
|
||||
}
|
||||
|
||||
func TestAccessTokenScope_Normalize(t *testing.T) {
|
||||
assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
|
||||
assert.Equal(t, []string{"activitypub", "admin", "issue", "licensing", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
|
||||
tests := []scopeTestNormalize{
|
||||
{"", "", nil},
|
||||
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
||||
{"all", "all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing", "all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing,public-only", "public-only,all", nil},
|
||||
}
|
||||
|
||||
for _, scope := range GetAccessTokenCategories() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
//
|
||||
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTOTP validates the provided passcode.
|
||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
||||
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
|
||||
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
|
||||
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
|
||||
}
|
||||
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
||||
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
||||
}
|
||||
secretStr := string(secretBytes)
|
||||
return totp.Validate(passcode, secretStr), nil
|
||||
}
|
||||
|
||||
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||
ok, err := t.validateTOTP(passcode)
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||
t.LastUsedPasscode = passcode
|
||||
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||
Cols("last_used_passcode").Update(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// NewTwoFactor creates a new two-factor authentication token.
|
||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||
_, err := db.GetEngine(ctx).Insert(t)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||
require.NoError(t, err)
|
||||
|
||||
tfa := &auth_model.TwoFactor{UID: 1}
|
||||
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||
|
||||
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// first use of a valid passcode succeeds
|
||||
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||
require.NoError(t, err)
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
|
||||
// an invalid passcode is rejected without consuming anything
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
||||
// LFS objects are repository code content, so authorization must require
|
||||
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||
// reuse of an existing LFS object across repositories.
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||
cond := builder.In(
|
||||
"`lfs_meta_object`.repository_id",
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||
)
|
||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgEmailDomainPolicy restricts which email domains an organization's members may
|
||||
// have. When configured, a user can only be added to the org if their primary email
|
||||
// matches one of the allowed domain globs. At most one row per org. See issue #727.
|
||||
type OrgEmailDomainPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
AllowedDomains string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgEmailDomainPolicy))
|
||||
}
|
||||
|
||||
// ErrEmailDomainNotAllowed is returned when a user's email domain is not permitted
|
||||
// by the organization's email domain policy.
|
||||
type ErrEmailDomainNotAllowed struct {
|
||||
Email string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
func (e ErrEmailDomainNotAllowed) Error() string {
|
||||
return fmt.Sprintf("email %q is not in an allowed domain for organization %d", e.Email, e.OrgID)
|
||||
}
|
||||
|
||||
// IsErrEmailDomainNotAllowed reports whether err is an ErrEmailDomainNotAllowed.
|
||||
func IsErrEmailDomainNotAllowed(err error) bool {
|
||||
_, ok := err.(ErrEmailDomainNotAllowed)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *OrgEmailDomainPolicy) domainGlobs() []glob.Glob {
|
||||
var out []glob.Glob
|
||||
for _, d := range strings.Split(p.AllowedDomains, ";") {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if g, err := glob.Compile(d); err == nil {
|
||||
out = append(out, g)
|
||||
} else {
|
||||
log.Warn("Invalid org email domain glob %q: %v", d, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EmailAllowed reports whether email's domain satisfies the policy. An empty policy
|
||||
// (no configured domains) allows any email.
|
||||
func (p *OrgEmailDomainPolicy) EmailAllowed(email string) bool {
|
||||
globs := p.domainGlobs()
|
||||
if len(globs) == 0 {
|
||||
return true
|
||||
}
|
||||
at := strings.LastIndexByte(email, '@')
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(email[at+1:])
|
||||
for _, g := range globs {
|
||||
if g.Match(domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetOrgEmailDomainPolicy returns the org's email domain policy, or nil if none.
|
||||
func GetOrgEmailDomainPolicy(ctx context.Context, orgID int64) (*OrgEmailDomainPolicy, error) {
|
||||
policy, exist, err := db.Get[OrgEmailDomainPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// OrgEmailDomainAllowed reports whether email is permitted for the org. It returns
|
||||
// true when the org has no policy configured.
|
||||
func OrgEmailDomainAllowed(ctx context.Context, orgID int64, email string) (bool, error) {
|
||||
policy, err := GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if policy == nil {
|
||||
return true, nil
|
||||
}
|
||||
return policy.EmailAllowed(email), nil
|
||||
}
|
||||
|
||||
// UpsertOrgEmailDomainPolicy creates or updates the single policy row for an org.
|
||||
func UpsertOrgEmailDomainPolicy(ctx context.Context, policy *OrgEmailDomainPolicy) error {
|
||||
existing, err := GetOrgEmailDomainPolicy(ctx, policy.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||
return fmt.Errorf("Insert OrgEmailDomainPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
policy.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||
return fmt.Errorf("Update OrgEmailDomainPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgEmailDomainPolicy removes an org's email domain policy.
|
||||
func DeleteOrgEmailDomainPolicy(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgEmailDomainPolicy))
|
||||
return err
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgProtectedTag represents an org-level tag protection rule. It cascades to all
|
||||
// repositories in the organization and layers on top of each repo's own protected
|
||||
// tags (a tag is controllable only if allowed at both levels). Org rules reference
|
||||
// teams only (like OrgProtectedBranch). See issue #727.
|
||||
type OrgProtectedTag struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||
NamePattern string `xorm:"UNIQUE(s)"`
|
||||
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgProtectedTag))
|
||||
}
|
||||
|
||||
// ToProtectedTag converts an org-level tag rule into a repo-scoped ProtectedTag so
|
||||
// the standard name-matching and allowlist logic can be reused. Org rules are
|
||||
// team-only, so the user allowlist is left empty.
|
||||
func (o *OrgProtectedTag) ToProtectedTag() *ProtectedTag {
|
||||
return &ProtectedTag{
|
||||
NamePattern: o.NamePattern,
|
||||
AllowlistTeamIDs: o.AllowlistTeamIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgProtectedTagByID retrieves a single org tag rule by org ID and rule ID.
|
||||
func GetOrgProtectedTagByID(ctx context.Context, orgID, id int64) (*OrgProtectedTag, error) {
|
||||
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "id": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// GetOrgProtectedTagByNamePattern retrieves a single org tag rule by its pattern.
|
||||
func GetOrgProtectedTagByNamePattern(ctx context.Context, orgID int64, pattern string) (*OrgProtectedTag, error) {
|
||||
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "name_pattern": pattern})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// FindOrgProtectedTags loads all org-level tag protection rules for an organization.
|
||||
func FindOrgProtectedTags(ctx context.Context, orgID int64) ([]*OrgProtectedTag, error) {
|
||||
var rules []*OrgProtectedTag
|
||||
err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("created_unix").Find(&rules)
|
||||
return rules, err
|
||||
}
|
||||
|
||||
// CreateOrgProtectedTag creates a new org-level tag protection rule.
|
||||
func CreateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||
if _, err := db.GetEngine(ctx).Insert(rule); err != nil {
|
||||
return fmt.Errorf("Insert OrgProtectedTag: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOrgProtectedTag updates an existing org-level tag protection rule.
|
||||
func UpdateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||
if _, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule); err != nil {
|
||||
return fmt.Errorf("Update OrgProtectedTag: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgProtectedTag deletes an org-level tag protection rule.
|
||||
func DeleteOrgProtectedTag(ctx context.Context, orgID, id int64) error {
|
||||
affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedTag))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("org tag protection rule ID(%d) not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserAllowedToControlTagInRepo layers org-level tag rules on top of a repo's own
|
||||
// protected tags: the user must be allowed by BOTH levels (most-restrictive). The
|
||||
// repo decision is evaluated first (using the already-loaded repoTags); if it
|
||||
// allows and the owner is an organization, the org-level rules must also allow.
|
||||
func IsUserAllowedToControlTagInRepo(ctx context.Context, repoTags []*ProtectedTag, repo *repo_model.Repository, tagName string, userID int64) (bool, error) {
|
||||
allowed, err := IsUserAllowedToControlTag(ctx, repoTags, tagName, userID)
|
||||
if err != nil || !allowed {
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
orgRules, err := FindOrgProtectedTags(ctx, owner.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(orgRules) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
orgTags := make([]*ProtectedTag, len(orgRules))
|
||||
for i, r := range orgRules {
|
||||
orgTags[i] = r.ToProtectedTag()
|
||||
}
|
||||
return IsUserAllowedToControlTag(ctx, orgTags, tagName, userID)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgPushPolicy is a single org-wide policy enforced in the pre-receive hook on
|
||||
// every repository of the organization. Unlike the branch/tag rulesets there is at
|
||||
// most one policy per org. Empty pattern / zero fields mean "no constraint". See #727.
|
||||
type OrgPushPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
BranchNamePattern string `xorm:"TEXT"`
|
||||
TagNamePattern string `xorm:"TEXT"`
|
||||
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
BlockedFilePatterns string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgPushPolicy))
|
||||
}
|
||||
|
||||
// nameMatchesPattern reports whether name satisfies a glob pattern. An empty pattern
|
||||
// imposes no constraint; an invalid pattern fails open (no constraint) so a
|
||||
// misconfigured policy never blocks all pushes.
|
||||
func nameMatchesPattern(pattern, name string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
g, err := glob.Compile(pattern, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid org push policy name pattern %q: %v", pattern, err)
|
||||
return true
|
||||
}
|
||||
return g.Match(name)
|
||||
}
|
||||
|
||||
// BranchNameAllowed reports whether a branch name satisfies the naming policy.
|
||||
func (p *OrgPushPolicy) BranchNameAllowed(name string) bool {
|
||||
return nameMatchesPattern(p.BranchNamePattern, name)
|
||||
}
|
||||
|
||||
// TagNameAllowed reports whether a tag name satisfies the naming policy.
|
||||
func (p *OrgPushPolicy) TagNameAllowed(name string) bool {
|
||||
return nameMatchesPattern(p.TagNamePattern, name)
|
||||
}
|
||||
|
||||
// BlockedFileGlobs parses the ';'-separated blocked file pattern list.
|
||||
func (p *OrgPushPolicy) BlockedFileGlobs() []glob.Glob {
|
||||
var out []glob.Glob
|
||||
for _, expr := range strings.Split(p.BlockedFilePatterns, ";") {
|
||||
expr = strings.TrimSpace(strings.ToLower(expr))
|
||||
if expr == "" {
|
||||
continue
|
||||
}
|
||||
if g, err := glob.Compile(expr, '.', '/'); err == nil {
|
||||
out = append(out, g)
|
||||
} else {
|
||||
log.Warn("Invalid org push policy blocked file pattern %q: %v", expr, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetOrgPushPolicy returns the org's push policy, or nil if none is configured.
|
||||
func GetOrgPushPolicy(ctx context.Context, orgID int64) (*OrgPushPolicy, error) {
|
||||
policy, exist, err := db.Get[OrgPushPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// GetOrgPushPolicyForRepo returns the push policy of the repo's owning organization,
|
||||
// or nil if the owner is not an organization or has no policy.
|
||||
func GetOrgPushPolicyForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgPushPolicy, error) {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return GetOrgPushPolicy(ctx, owner.ID)
|
||||
}
|
||||
|
||||
// UpsertOrgPushPolicy creates or updates the single push policy for an org.
|
||||
func UpsertOrgPushPolicy(ctx context.Context, policy *OrgPushPolicy) error {
|
||||
existing, err := GetOrgPushPolicy(ctx, policy.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||
return fmt.Errorf("Insert OrgPushPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
policy.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||
return fmt.Errorf("Update OrgPushPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgPushPolicy removes an org's push policy.
|
||||
func DeleteOrgPushPolicy(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgPushPolicy))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgRepoDefaults holds an organization's default repository settings, applied to a
|
||||
// repository when it is created in or transferred into the org (via a notifier).
|
||||
// There is at most one row per org. See issue #727.
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgRepoDefaults))
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaults returns the org's repo defaults, or nil if none are configured.
|
||||
func GetOrgRepoDefaults(ctx context.Context, orgID int64) (*OrgRepoDefaults, error) {
|
||||
defaults, exist, err := db.Get[OrgRepoDefaults](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaultsForRepo returns the repo-defaults of the repo's owning org, or
|
||||
// nil if the owner is not an organization or has none configured.
|
||||
func GetOrgRepoDefaultsForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgRepoDefaults, error) {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return GetOrgRepoDefaults(ctx, owner.ID)
|
||||
}
|
||||
|
||||
// UpsertOrgRepoDefaults creates or updates the single repo-defaults row for an org.
|
||||
func UpsertOrgRepoDefaults(ctx context.Context, defaults *OrgRepoDefaults) error {
|
||||
existing, err := GetOrgRepoDefaults(ctx, defaults.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(defaults); err != nil {
|
||||
return fmt.Errorf("Insert OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defaults.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(defaults); err != nil {
|
||||
return fmt.Errorf("Update OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgRepoDefaults removes an org's repo defaults.
|
||||
func DeleteOrgRepoDefaults(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgRepoDefaults))
|
||||
return err
|
||||
}
|
||||
@@ -51,6 +51,12 @@ type ProtectedBranch struct {
|
||||
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
||||
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
||||
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
@@ -194,6 +200,46 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
|
||||
return in && protectBranch.CanUserPush(ctx, user)
|
||||
}
|
||||
|
||||
// CanUserDelete returns if some user could delete this protected branch
|
||||
func (protectBranch *ProtectedBranch) CanUserDelete(ctx context.Context, user *user_model.User) bool {
|
||||
if !protectBranch.CanDelete {
|
||||
return false
|
||||
}
|
||||
|
||||
if user.IsActions() && protectBranch.DeleteAllowlistActionsUser {
|
||||
return true
|
||||
}
|
||||
|
||||
if !protectBranch.EnableDeleteAllowlist {
|
||||
if err := protectBranch.LoadRepo(ctx); err != nil {
|
||||
log.Error("LoadRepo: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
isAdmin, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeAdmin)
|
||||
if err != nil {
|
||||
log.Error("HasAccessUnit: %v", err)
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.DeleteAllowlistUserIDs, user.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(protectBranch.DeleteAllowlistTeamIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.DeleteAllowlistTeamIDs)
|
||||
if err != nil {
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||
func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
|
||||
// Allow the actions bot user if explicitly whitelisted.
|
||||
@@ -365,6 +411,9 @@ type WhitelistOptions struct {
|
||||
|
||||
ApprovalsUserIDs []int64
|
||||
ApprovalsTeamIDs []int64
|
||||
|
||||
DeleteUserIDs []int64
|
||||
DeleteTeamIDs []int64
|
||||
}
|
||||
|
||||
// UpdateProtectBranch saves branch protection options of repository.
|
||||
@@ -430,6 +479,18 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
|
||||
}
|
||||
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
|
||||
|
||||
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.DeleteAllowlistUserIDs, opts.DeleteUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.DeleteAllowlistUserIDs = whitelist
|
||||
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.DeleteAllowlistTeamIDs, opts.DeleteTeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.DeleteAllowlistTeamIDs = whitelist
|
||||
|
||||
// Looks like it's a new rule
|
||||
if protectBranch.ID == 0 {
|
||||
// as it's a new rule and if priority was not set, we need to calc it.
|
||||
@@ -574,14 +635,15 @@ func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id
|
||||
|
||||
// removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
|
||||
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
|
||||
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
|
||||
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
|
||||
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs, lenDeleteIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs), len(p.DeleteAllowlistUserIDs)
|
||||
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs, lenDeleteTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs), len(p.DeleteAllowlistTeamIDs)
|
||||
|
||||
if userID > 0 {
|
||||
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
||||
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
||||
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
||||
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
||||
p.DeleteAllowlistUserIDs = util.SliceRemoveAll(p.DeleteAllowlistUserIDs, userID)
|
||||
}
|
||||
|
||||
if teamID > 0 {
|
||||
@@ -589,16 +651,19 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI
|
||||
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
||||
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
||||
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
||||
p.DeleteAllowlistTeamIDs = util.SliceRemoveAll(p.DeleteAllowlistTeamIDs, teamID)
|
||||
}
|
||||
|
||||
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
||||
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
||||
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
||||
lenMergeIDs != len(p.MergeWhitelistUserIDs)) ||
|
||||
lenMergeIDs != len(p.MergeWhitelistUserIDs) ||
|
||||
lenDeleteIDs != len(p.DeleteAllowlistUserIDs)) ||
|
||||
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
||||
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
||||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
|
||||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) {
|
||||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs) ||
|
||||
lenDeleteTeamIDs != len(p.DeleteAllowlistTeamIDs)) {
|
||||
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
|
||||
return fmt.Errorf("updateProtectedBranches: %v", err)
|
||||
}
|
||||
@@ -613,6 +678,7 @@ func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, us
|
||||
"force_push_allowlist_user_i_ds",
|
||||
"merge_whitelist_user_i_ds",
|
||||
"approvals_whitelist_user_i_ds",
|
||||
"delete_allowlist_user_i_ds",
|
||||
}
|
||||
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
|
||||
}
|
||||
@@ -624,6 +690,7 @@ func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, te
|
||||
"force_push_allowlist_team_i_ds",
|
||||
"merge_whitelist_team_i_ds",
|
||||
"approvals_whitelist_team_i_ds",
|
||||
"delete_allowlist_team_i_ds",
|
||||
}
|
||||
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
|
||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||
Description string `xorm:"TEXT"`
|
||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
@@ -32,6 +33,211 @@ func (IssueStatusDef) TableName() string {
|
||||
return "issue_status_def"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Presets
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StatusPresetEntry defines a single status in a preset template.
|
||||
type StatusPresetEntry struct {
|
||||
Name string
|
||||
Color string
|
||||
Description string
|
||||
ClosesIssue bool
|
||||
IsRequired bool
|
||||
}
|
||||
|
||||
// StatusPreset defines a named collection of status definitions.
|
||||
type StatusPreset struct {
|
||||
Name string
|
||||
Description string
|
||||
Statuses []StatusPresetEntry
|
||||
}
|
||||
|
||||
// StatusPresets is the registry of built-in status presets.
|
||||
var StatusPresets = map[string]*StatusPreset{
|
||||
"default": {
|
||||
Name: "default",
|
||||
Description: "General-purpose workflow (default seed)",
|
||||
Statuses: []StatusPresetEntry{
|
||||
{Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true},
|
||||
{Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done"},
|
||||
{Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input"},
|
||||
{Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review"},
|
||||
{Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true},
|
||||
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
|
||||
},
|
||||
},
|
||||
"software-development": {
|
||||
Name: "software-development",
|
||||
Description: "Software development lifecycle",
|
||||
Statuses: []StatusPresetEntry{
|
||||
{Name: "Open", Color: "#2563eb", Description: "New or active issue", IsRequired: true},
|
||||
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on this"},
|
||||
{Name: "In Review", Color: "#0891b2", Description: "Pull request submitted, awaiting review"},
|
||||
{Name: "Testing", Color: "#d97706", Description: "Being tested or in QA"},
|
||||
{Name: "Closed", Color: "#16a34a", Description: "Completed, merged, and deployed", ClosesIssue: true, IsRequired: true},
|
||||
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
|
||||
},
|
||||
},
|
||||
"support-tickets": {
|
||||
Name: "support-tickets",
|
||||
Description: "Customer support ticket workflow",
|
||||
Statuses: []StatusPresetEntry{
|
||||
{Name: "New", Color: "#2563eb", Description: "Ticket received, not yet triaged", IsRequired: true},
|
||||
{Name: "Assigned", Color: "#7c3aed", Description: "Assigned to a support agent"},
|
||||
{Name: "Waiting for Customer", Color: "#f59e0b", Description: "Awaiting customer response"},
|
||||
{Name: "In Progress", Color: "#0891b2", Description: "Agent is actively working on this"},
|
||||
{Name: "Resolved", Color: "#16a34a", Description: "Issue resolved, awaiting confirmation", ClosesIssue: true},
|
||||
{Name: "Closed", Color: "#059669", Description: "Confirmed resolved", ClosesIssue: true, IsRequired: true},
|
||||
},
|
||||
},
|
||||
"bug-tracking": {
|
||||
Name: "bug-tracking",
|
||||
Description: "Bug lifecycle tracking",
|
||||
Statuses: []StatusPresetEntry{
|
||||
{Name: "New", Color: "#2563eb", Description: "Bug reported, not yet triaged", IsRequired: true},
|
||||
{Name: "Confirmed", Color: "#dc2626", Description: "Bug confirmed and reproducible"},
|
||||
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on a fix"},
|
||||
{Name: "Fixed", Color: "#0891b2", Description: "Fix implemented, awaiting verification"},
|
||||
{Name: "Verified", Color: "#16a34a", Description: "Fix verified by QA"},
|
||||
{Name: "Closed", Color: "#059669", Description: "Bug resolved and closed", ClosesIssue: true, IsRequired: true},
|
||||
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to fix", ClosesIssue: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// StatusPresetNames returns the list of available preset names in display order.
|
||||
func StatusPresetNames() []string {
|
||||
return []string{"default", "software-development", "support-tickets", "bug-tracking"}
|
||||
}
|
||||
|
||||
// ApplyStatusPreset replaces all non-required statuses for an org with a preset.
|
||||
// Required statuses (Open/Closed) are preserved if they already exist; the preset's
|
||||
// required entries are created if missing. Non-required statuses are soft-deleted
|
||||
// (is_active=false) and the preset's non-required entries are inserted.
|
||||
func ApplyStatusPreset(ctx context.Context, orgID int64, presetName string) error {
|
||||
preset, ok := StatusPresets[presetName]
|
||||
if !ok {
|
||||
return db.ErrNotExist{Resource: "StatusPreset", ID: 0}
|
||||
}
|
||||
|
||||
existing, err := GetAllIssueStatusDefsByOrg(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build lookup of existing statuses by name
|
||||
existingByName := make(map[string]*IssueStatusDef, len(existing))
|
||||
for _, d := range existing {
|
||||
existingByName[d.Name] = d
|
||||
}
|
||||
|
||||
// Deactivate all non-required existing statuses
|
||||
for _, d := range existing {
|
||||
if d.IsRequired {
|
||||
continue
|
||||
}
|
||||
if d.IsActive {
|
||||
d.IsActive = false
|
||||
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preset entries
|
||||
for i, entry := range preset.Statuses {
|
||||
if ex, found := existingByName[entry.Name]; found {
|
||||
// Update existing status to match preset
|
||||
ex.Color = entry.Color
|
||||
ex.Description = entry.Description
|
||||
ex.ClosesIssue = entry.ClosesIssue
|
||||
ex.SortOrder = i
|
||||
ex.IsActive = true
|
||||
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Create new status
|
||||
def := &IssueStatusDef{
|
||||
OrgID: orgID,
|
||||
Name: entry.Name,
|
||||
Color: entry.Color,
|
||||
Description: entry.Description,
|
||||
ClosesIssue: entry.ClosesIssue,
|
||||
IsRequired: entry.IsRequired,
|
||||
SortOrder: i,
|
||||
IsActive: true,
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyStatusesFromOrg copies all active status definitions from srcOrgID to dstOrgID.
|
||||
// Existing non-required statuses in dstOrgID are deactivated first.
|
||||
func CopyStatusesFromOrg(ctx context.Context, srcOrgID, dstOrgID int64) error {
|
||||
srcDefs, err := GetIssueStatusDefsByOrg(ctx, srcOrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := GetAllIssueStatusDefsByOrg(ctx, dstOrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingByName := make(map[string]*IssueStatusDef, len(existing))
|
||||
for _, d := range existing {
|
||||
existingByName[d.Name] = d
|
||||
}
|
||||
|
||||
// Deactivate non-required existing statuses
|
||||
for _, d := range existing {
|
||||
if d.IsRequired {
|
||||
continue
|
||||
}
|
||||
if d.IsActive {
|
||||
d.IsActive = false
|
||||
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy source statuses
|
||||
for _, src := range srcDefs {
|
||||
if ex, found := existingByName[src.Name]; found {
|
||||
ex.Color = src.Color
|
||||
ex.Description = src.Description
|
||||
ex.ClosesIssue = src.ClosesIssue
|
||||
ex.SortOrder = src.SortOrder
|
||||
ex.IsActive = true
|
||||
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
def := &IssueStatusDef{
|
||||
OrgID: dstOrgID,
|
||||
Name: src.Name,
|
||||
Color: src.Color,
|
||||
Description: src.Description,
|
||||
ClosesIssue: src.ClosesIssue,
|
||||
IsRequired: src.IsRequired,
|
||||
SortOrder: src.SortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Queries
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -56,14 +262,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
|
||||
}
|
||||
|
||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||
// Open and Closed are required (is_required=true) and cannot be deleted.
|
||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||
defaults := []*IssueStatusDef{
|
||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
||||
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
|
||||
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
|
||||
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
|
||||
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
|
||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||
@@ -111,13 +318,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrStatusRequired is returned when trying to delete a required status.
|
||||
type ErrStatusRequired struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e ErrStatusRequired) Error() string {
|
||||
return "status is required and cannot be deleted"
|
||||
}
|
||||
|
||||
// IsErrStatusRequired checks if an error is ErrStatusRequired.
|
||||
func IsErrStatusRequired(err error) bool {
|
||||
_, ok := err.(ErrStatusRequired)
|
||||
return ok
|
||||
}
|
||||
|
||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||
// Returns ErrStatusRequired if the status is marked as required.
|
||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||
def, err := GetIssueStatusDefByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if def.IsRequired {
|
||||
return ErrStatusRequired{ID: def.ID, Name: def.Name}
|
||||
}
|
||||
// Clear status_id on all issues that reference this definition
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
||||
return rules, warnings
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
for _, user := range tokens[1:] {
|
||||
user = strings.TrimPrefix(user, "@")
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -370,6 +373,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||
// each compiled rule must enforce a bounded match time.
|
||||
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||
start := time.Now()
|
||||
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||
}
|
||||
|
||||
func testGetApprovers(t *testing.T) {
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||
|
||||
@@ -436,6 +436,14 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
||||
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
||||
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
||||
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),
|
||||
newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable),
|
||||
newMigration(364, "Add org push policy table", v1_27.AddOrgPushPolicyTable),
|
||||
newMigration(365, "Add org repo defaults table", v1_27.AddOrgRepoDefaultsTable),
|
||||
newMigration(366, "Add org email domain policy table", v1_27.AddOrgEmailDomainPolicyTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddDeployFieldsToRepoManifest adds deploy configuration columns to repo_manifest.
|
||||
func AddDeployFieldsToRepoManifest(x *xorm.Engine) error {
|
||||
type RepoManifest struct {
|
||||
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"`
|
||||
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"`
|
||||
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"`
|
||||
DeployPath string `xorm:"TEXT 'deploy_path'"`
|
||||
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"`
|
||||
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"`
|
||||
ContainerName string `xorm:"VARCHAR(100) 'container_name'"`
|
||||
HealthURL string `xorm:"TEXT 'health_url'"`
|
||||
}
|
||||
return x.Sync(new(RepoManifest))
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
func AddDeleteAllowlistToProtectedBranch(x *xorm.Engine) error {
|
||||
type ProtectedBranch struct {
|
||||
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||
}
|
||||
return x.Sync(new(ProtectedBranch))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func AddCascadeMergeRuleTable(x *xorm.Engine) error {
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(CascadeMergeRule))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgProtectedTagTable creates the org-level tag protection table. Org tag rules
|
||||
// cascade to all repositories in the organization and layer on top of each repo's
|
||||
// own protected tags. See issue #727.
|
||||
func AddOrgProtectedTagTable(x *xorm.Engine) error {
|
||||
type OrgProtectedTag struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||
NamePattern string `xorm:"UNIQUE(s)"`
|
||||
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgProtectedTag))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgPushPolicyTable creates the org-level push policy table (one row per org),
|
||||
// enforced in the pre-receive hook across all repositories of the org. See #727.
|
||||
func AddOrgPushPolicyTable(x *xorm.Engine) error {
|
||||
type OrgPushPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
BranchNamePattern string `xorm:"TEXT"`
|
||||
TagNamePattern string `xorm:"TEXT"`
|
||||
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
BlockedFilePatterns string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgPushPolicy))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgRepoDefaultsTable creates the org repository-defaults table (one row per
|
||||
// org), applied to repositories created in or transferred into the org. See #727.
|
||||
func AddOrgRepoDefaultsTable(x *xorm.Engine) error {
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgRepoDefaults))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgEmailDomainPolicyTable creates the org email-domain policy table (one row
|
||||
// per org) restricting the email domains of members added to the org. See #727.
|
||||
func AddOrgEmailDomainPolicyTable(x *xorm.Engine) error {
|
||||
type OrgEmailDomainPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
AllowedDomains string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgEmailDomainPolicy))
|
||||
}
|
||||
@@ -323,6 +323,60 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
|
||||
}
|
||||
|
||||
// CreateOrganization creates record of a new organization.
|
||||
// DefaultTeamSpec defines a team to auto-create when a new organization is created.
|
||||
type DefaultTeamSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
AccessMode perm.AccessMode
|
||||
IncludesAllRepositories bool
|
||||
CanCreateOrgRepo bool
|
||||
Units map[unit.Type]perm.AccessMode
|
||||
}
|
||||
|
||||
// DefaultOrgTeams is the list of teams created for every new organization
|
||||
// (in addition to the mandatory Owners team). Override in tests or via init.
|
||||
var DefaultOrgTeams = []DefaultTeamSpec{
|
||||
{
|
||||
Name: "Developers",
|
||||
Description: "Members with write access to code, issues, and pull requests",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: true,
|
||||
Units: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeWrite,
|
||||
unit.TypeIssues: perm.AccessModeWrite,
|
||||
unit.TypePullRequests: perm.AccessModeWrite,
|
||||
unit.TypeReleases: perm.AccessModeRead,
|
||||
unit.TypeWiki: perm.AccessModeWrite,
|
||||
unit.TypeProjects: perm.AccessModeWrite,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Reviewers",
|
||||
Description: "Members with read access for code review",
|
||||
AccessMode: perm.AccessModeRead,
|
||||
IncludesAllRepositories: true,
|
||||
Units: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeRead,
|
||||
unit.TypeIssues: perm.AccessModeRead,
|
||||
unit.TypePullRequests: perm.AccessModeRead,
|
||||
unit.TypeReleases: perm.AccessModeRead,
|
||||
unit.TypeWiki: perm.AccessModeRead,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "CI/CD",
|
||||
Description: "Members with write access to actions and packages",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: true,
|
||||
Units: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeRead,
|
||||
unit.TypeActions: perm.AccessModeWrite,
|
||||
unit.TypePackages: perm.AccessModeWrite,
|
||||
unit.TypeReleases: perm.AccessModeWrite,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) {
|
||||
if !owner.CanCreateOrganization() {
|
||||
return ErrUserNotAllowedCreateOrg{}
|
||||
@@ -348,7 +402,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
||||
}
|
||||
org.UseCustomAvatar = true
|
||||
org.MaxRepoCreation = -1
|
||||
org.NumTeams = 1
|
||||
org.NumTeams = 1 + len(DefaultOrgTeams)
|
||||
org.NumMembers = 1
|
||||
org.Type = user_model.UserTypeOrganization
|
||||
|
||||
@@ -413,6 +467,37 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
||||
}); err != nil {
|
||||
return fmt.Errorf("insert team-user relation: %w", err)
|
||||
}
|
||||
|
||||
for _, spec := range DefaultOrgTeams {
|
||||
dt := &Team{
|
||||
OrgID: org.ID,
|
||||
LowerName: strings.ToLower(spec.Name),
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
AccessMode: spec.AccessMode,
|
||||
IncludesAllRepositories: spec.IncludesAllRepositories,
|
||||
CanCreateOrgRepo: spec.CanCreateOrgRepo,
|
||||
}
|
||||
if err = db.Insert(ctx, dt); err != nil {
|
||||
return fmt.Errorf("insert default team %q: %w", spec.Name, err)
|
||||
}
|
||||
|
||||
dtUnits := make([]TeamUnit, 0, len(spec.Units))
|
||||
for tp, am := range spec.Units {
|
||||
dtUnits = append(dtUnits, TeamUnit{
|
||||
OrgID: org.ID,
|
||||
TeamID: dt.ID,
|
||||
Type: tp,
|
||||
AccessMode: am,
|
||||
})
|
||||
}
|
||||
if len(dtUnits) > 0 {
|
||||
if err = db.Insert(ctx, &dtUnits); err != nil {
|
||||
return fmt.Errorf("insert default team %q units: %w", spec.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CascadeMergeRule))
|
||||
}
|
||||
|
||||
func GetCascadeRulesByRepoID(ctx context.Context, repoID int64) ([]*CascadeMergeRule, error) {
|
||||
rules := make([]*CascadeMergeRule, 0)
|
||||
return rules, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&rules)
|
||||
}
|
||||
|
||||
func GetCascadeRulesForBranch(ctx context.Context, repoID int64, sourceBranch string) ([]*CascadeMergeRule, error) {
|
||||
rules := make([]*CascadeMergeRule, 0)
|
||||
return rules, db.GetEngine(ctx).Where("repo_id = ? AND source_branch = ? AND enabled = ?", repoID, sourceBranch, true).Find(&rules)
|
||||
}
|
||||
|
||||
func GetCascadeRuleByID(ctx context.Context, id int64) (*CascadeMergeRule, error) {
|
||||
rule := &CascadeMergeRule{ID: id}
|
||||
has, err := db.GetEngine(ctx).Get(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func CreateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||
_, err := db.GetEngine(ctx).Insert(rule)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||
_, err := db.GetEngine(ctx).ID(rule.ID).Cols("source_branch", "target_branch", "enabled", "auto_merge").Update(rule)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteCascadeRule(ctx context.Context, repoID, id int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", id, repoID).Delete(&CascadeMergeRule{})
|
||||
return err
|
||||
}
|
||||
@@ -50,6 +50,16 @@ type RepoMetadata struct {
|
||||
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
||||
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
||||
|
||||
// deploy section
|
||||
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
|
||||
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
|
||||
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"` // SSH user
|
||||
DeployPath string `xorm:"TEXT 'deploy_path'"` // remote path for source/compose
|
||||
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"` // e.g. mokoconsulting/mokogitea
|
||||
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"` // e.g. git.mokoconsulting.tech
|
||||
ContainerName string `xorm:"VARCHAR(100) 'container_name'"` // Docker container name
|
||||
HealthURL string `xorm:"TEXT 'health_url'"` // health check URL after deploy
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
||||
}
|
||||
|
||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
|
||||
for _, condition := range conditions {
|
||||
switch cond := condition.(type) {
|
||||
case *testCond:
|
||||
|
||||
@@ -80,8 +80,11 @@ func init() {
|
||||
}
|
||||
|
||||
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
||||
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(externalLoginUser)
|
||||
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
|
||||
return db.Get[ExternalLoginUser](ctx, builder.Eq{
|
||||
"external_id": externalID,
|
||||
"login_source_id": loginSourceID,
|
||||
})
|
||||
}
|
||||
|
||||
// LinkExternalToUser link the external user to the user
|
||||
@@ -118,6 +121,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveExternalLoginByExternalID removes a specific external login link by its provider-side identifier.
|
||||
func RemoveExternalLoginByExternalID(ctx context.Context, loginSourceID int64, externalID string) error {
|
||||
_, err := db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", externalID, loginSourceID).Delete(new(ExternalLoginUser))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserIDByExternalUserID get user id according to provider and userID
|
||||
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
||||
var id int64
|
||||
|
||||
@@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext {
|
||||
return gitContext
|
||||
}
|
||||
|
||||
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
|
||||
const workflowCallEvent = "workflow_call"
|
||||
|
||||
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||
switch rawOn.Kind {
|
||||
case yaml.ScalarNode:
|
||||
@@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if val == workflowCallEvent {
|
||||
return []*Event{}, nil
|
||||
}
|
||||
return []*Event{
|
||||
{Name: val},
|
||||
}, nil
|
||||
@@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||
for _, v := range val {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == workflowCallEvent {
|
||||
continue
|
||||
}
|
||||
res = append(res, &Event{Name: t})
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid type %T", t)
|
||||
@@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||
}
|
||||
res := make([]*Event, 0, len(events))
|
||||
for i, k := range events {
|
||||
if k == workflowCallEvent {
|
||||
continue
|
||||
}
|
||||
v := triggers[i]
|
||||
switch v.Kind {
|
||||
case yaml.ScalarNode:
|
||||
|
||||
@@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
|
||||
input: `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
env:
|
||||
type: string
|
||||
required: true
|
||||
outputs:
|
||||
sha:
|
||||
value: ${{ jobs.build.outputs.commit }}
|
||||
secrets:
|
||||
DEPLOY_KEY:
|
||||
required: true
|
||||
`,
|
||||
result: []*Event{},
|
||||
},
|
||||
{
|
||||
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
|
||||
input: `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
env:
|
||||
type: string
|
||||
push:
|
||||
branches: [main]
|
||||
`,
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{"branches": {"main"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Scalar form: a purely reusable workflow has no event triggers.
|
||||
input: "on: workflow_call",
|
||||
result: []*Event{},
|
||||
},
|
||||
{
|
||||
// Sequence form: `workflow_call` is excluded while sibling events are kept.
|
||||
input: "on:\n - push\n - workflow_call\n - pull_request",
|
||||
result: []*Event{
|
||||
{Name: "push"},
|
||||
{Name: "pull_request"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.input, func(t *testing.T) {
|
||||
|
||||
@@ -78,6 +78,18 @@ func TestIsWorkflow(t *testing.T) {
|
||||
path: ".gitea/workflows2/test.yml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "subdirectory workflow",
|
||||
dirs: []string{".gitea/workflows"},
|
||||
path: ".gitea/workflows/custom/deploy.yml",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deeply nested subdirectory workflow",
|
||||
dirs: []string{".mokogitea/workflows"},
|
||||
path: ".mokogitea/workflows/custom/deploy/staging.yaml",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated path",
|
||||
dirs: []string{".gitea/workflows", ".github/workflows"},
|
||||
|
||||
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
|
||||
// GetLastCommitForPaths returns last commit information
|
||||
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
||||
// We read backwards from the commit to obtain all of the commits
|
||||
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||
revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
|
||||
require.NoError(t, err)
|
||||
entries, err := commit.Tree.ListEntries()
|
||||
require.NoError(t, err)
|
||||
|
||||
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
|
||||
for _, info := range infos {
|
||||
nilCommits += util.Iif(info.Commit == nil, 1, 0)
|
||||
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
|
||||
}
|
||||
return nilCommits, nonNilCommits
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
|
||||
|
||||
walkGitLogDebugBeforeNext = cancel
|
||||
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
|
||||
assert.NoError(t, err)
|
||||
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
|
||||
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
|
||||
assert.Equal(t, 3, nilCommits)
|
||||
|
||||
walkGitLogDebugBeforeNext = nil
|
||||
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
|
||||
assert.NoError(t, err)
|
||||
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
|
||||
assert.Equal(t, 3, nonNilCommits)
|
||||
assert.Equal(t, 0, nilCommits)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
|
||||
entryPaths[i] = entry.Name()
|
||||
}
|
||||
|
||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
@@ -18,10 +20,8 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
|
||||
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
|
||||
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
|
||||
|
||||
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
|
||||
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
|
||||
cmd := gitcmd.NewCommand()
|
||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
||||
|
||||
@@ -54,77 +54,62 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
|
||||
ctx, ctxCancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||
|
||||
return bufReader, func() {
|
||||
ctxCancel()
|
||||
stdoutReaderClose()
|
||||
return &logNameStatusRepoParser{
|
||||
treepath: treepath,
|
||||
paths: paths,
|
||||
rd: bufReader,
|
||||
close: func() {
|
||||
ctxCancel()
|
||||
stdoutReaderClose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||
type LogNameStatusRepoParser struct {
|
||||
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||
type logNameStatusRepoParser struct {
|
||||
treepath string
|
||||
paths []string
|
||||
next []byte
|
||||
buffull bool
|
||||
rd *bufio.Reader
|
||||
cancel func()
|
||||
close func()
|
||||
}
|
||||
|
||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
||||
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
|
||||
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
|
||||
return &LogNameStatusRepoParser{
|
||||
treepath: treepath,
|
||||
paths: paths,
|
||||
rd: rd,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// LogNameStatusCommitData represents a commit artefact from git log raw
|
||||
type LogNameStatusCommitData struct {
|
||||
// logNameStatusCommitData represents a commit artifact from git log raw
|
||||
type logNameStatusCommitData struct {
|
||||
CommitID string
|
||||
ParentIDs []string
|
||||
Paths []bool
|
||||
}
|
||||
|
||||
// Next returns the next LogStatusCommitData
|
||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
||||
// walkNext returns the next LogStatusCommitData
|
||||
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
|
||||
var err error
|
||||
if len(g.next) == 0 {
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, bufio.ErrBufferFull):
|
||||
g.buffull = true
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ret := LogNameStatusCommitData{}
|
||||
ret := logNameStatusCommitData{}
|
||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, bufio.ErrBufferFull):
|
||||
g.buffull = true
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,13 +258,10 @@ diffloop:
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the parser
|
||||
func (g *LogNameStatusRepoParser) Close() {
|
||||
g.cancel()
|
||||
}
|
||||
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
|
||||
|
||||
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
||||
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||
// walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
||||
func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||
headRef := head.ID.String()
|
||||
|
||||
tree, err := head.SubTree(treepath)
|
||||
@@ -322,11 +304,9 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
||||
}
|
||||
}
|
||||
|
||||
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||
// don't use defer g.Close() here as g may change its value - instead wrap in a func
|
||||
defer func() {
|
||||
g.Close()
|
||||
}()
|
||||
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
|
||||
defer func() { g.close() }()
|
||||
|
||||
results := make([]string, len(paths))
|
||||
remaining := len(paths)
|
||||
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
||||
|
||||
heaploop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
if walkGitLogDebugBeforeNext != nil {
|
||||
walkGitLogDebugBeforeNext()
|
||||
}
|
||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
break heaploop
|
||||
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
|
||||
if ctx.Err() != nil {
|
||||
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
|
||||
} else if errors.Is(err, io.EOF) {
|
||||
break heaploop // reached to the end of log output
|
||||
} else if err != nil {
|
||||
return nil, err // other unknown errors
|
||||
}
|
||||
parentRemaining.Remove(current.CommitID)
|
||||
for i, found := range current.Paths {
|
||||
@@ -395,14 +366,14 @@ heaploop:
|
||||
if remaining <= nextRestart {
|
||||
commitSinceNextRestart++
|
||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||
g.Close()
|
||||
remainingPaths := make([]string, 0, len(paths))
|
||||
for i, pth := range paths {
|
||||
if results[i] == "" {
|
||||
remainingPaths = append(remainingPaths, pth)
|
||||
}
|
||||
}
|
||||
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||
g.close()
|
||||
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||
parentRemaining = make(container.Set[string])
|
||||
nextRestart = (remaining * 3) / 4
|
||||
continue heaploop
|
||||
@@ -410,7 +381,6 @@ heaploop:
|
||||
}
|
||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||
}
|
||||
g.Close()
|
||||
|
||||
resultsMap := map[string]string{}
|
||||
for i, pth := range paths {
|
||||
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||
if opts.SkipTLSVerify {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, isEmpty)
|
||||
}
|
||||
|
||||
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||
func TestCloneRefusesRedirects(t *testing.T) {
|
||||
var targetHit atomic.Bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHit.Store(true)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
|
||||
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HostMatchList is used to check if a host or IP is in a list.
|
||||
@@ -23,10 +24,61 @@ type HostMatchList struct {
|
||||
ipNets []*net.IPNet
|
||||
}
|
||||
|
||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
||||
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||
const MatchBuiltinExternal = "external"
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
||||
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range []string{
|
||||
// IPv4
|
||||
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||
// IPv6
|
||||
"100::/64", // RFC 6666 discard-only
|
||||
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||
"2001:db8::/32", // RFC 3849 documentation
|
||||
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||
} {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
})
|
||||
|
||||
// isReservedIP reports whether ip falls in reserved special-purpose
|
||||
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||
func isReservedIP(ip net.IP) bool {
|
||||
for _, ipNet := range reservedIPNets() {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||
// Also called LAN/Intranet.
|
||||
const MatchBuiltinPrivate = "private"
|
||||
|
||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||
@@ -93,18 +145,22 @@ func (hl *HostMatchList) checkPattern(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
||||
// matchesIP determines if the given IP matches any of the configured rules
|
||||
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
|
||||
if slices.Contains(hl.patterns, "*") {
|
||||
return true
|
||||
}
|
||||
for _, builtin := range hl.builtins {
|
||||
switch builtin {
|
||||
case MatchBuiltinExternal:
|
||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
||||
// External address must be a global unicast, must not be in reserved range and must not be in private range
|
||||
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinPrivate:
|
||||
if ip.IsPrivate() {
|
||||
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
|
||||
// and must be in private range
|
||||
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinLoopback:
|
||||
@@ -135,7 +191,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
return hl.checkIP(ip)
|
||||
return hl.matchesIP(ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -146,7 +202,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
||||
return hl.checkPattern(host) || hl.checkIP(ip)
|
||||
return hl.checkPattern(host) || hl.matchesIP(ip)
|
||||
}
|
||||
|
||||
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
||||
|
||||
@@ -159,3 +159,60 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
||||
}
|
||||
test(cases)
|
||||
}
|
||||
|
||||
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
|
||||
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
|
||||
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
|
||||
func TestReservedRanges(t *testing.T) {
|
||||
external := ParseHostMatchList("", "external")
|
||||
private := ParseHostMatchList("", "private")
|
||||
|
||||
// legitimate public destinations: external, not private
|
||||
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
|
||||
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
|
||||
}
|
||||
|
||||
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
|
||||
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
|
||||
for _, ip := range []string{
|
||||
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
|
||||
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
|
||||
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
|
||||
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
|
||||
} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
|
||||
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
|
||||
}
|
||||
|
||||
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
|
||||
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
|
||||
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
|
||||
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
|
||||
}
|
||||
|
||||
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
|
||||
for _, ip := range []string{
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.254", // CGNAT
|
||||
"168.63.129.16", // Azure WireServer
|
||||
"192.0.2.1", // TEST-NET-1
|
||||
"198.18.0.1", // benchmarking
|
||||
"198.51.100.1", // TEST-NET-2
|
||||
"203.0.113.1", // TEST-NET-3
|
||||
"169.254.169.254", // Cloud metadata
|
||||
"192.88.99.1", // 6to4 relay anycast
|
||||
"64:ff9b::1", // NAT64
|
||||
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
|
||||
"2001::1", // Teredo
|
||||
"2002::1", // 6to4
|
||||
"2001:db8::1", // documentation
|
||||
"fe80::1", // link local address
|
||||
} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||
assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -175,16 +175,25 @@ var emojiProcessors = []processor{
|
||||
emojiProcessor,
|
||||
}
|
||||
|
||||
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
|
||||
// is entirely a single URL, ignoring leading/trailing whitespace.
|
||||
func isBareURLSubject(content string) bool {
|
||||
s := strings.TrimSpace(html.UnescapeString(content))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
|
||||
return m != nil && m[0] == 0 && m[1] == len(s)
|
||||
}
|
||||
|
||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
|
||||
// which changes every text node into a link to the passed default link.
|
||||
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
fullHashPatternProcessor,
|
||||
linkProcessor,
|
||||
mentionProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
@@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}
|
||||
// When the whole subject is a bare URL, linkProcessor would turn it into
|
||||
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
|
||||
// the subject visually unclickable. Match GitHub: render such subjects as
|
||||
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||
// naturally breaks on that span, same as on GitHub).
|
||||
if !isBareURLSubject(content) {
|
||||
procs = append(procs, linkProcessor)
|
||||
}
|
||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||
node.Type = html.ElementNode
|
||||
|
||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
|
||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
// A binary package control file holds exactly one stanza. Stop at the
|
||||
// blank line that terminates it, otherwise a crafted control file could
|
||||
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||
// fields) into the generated repository "Packages" index.
|
||||
if control.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
|
||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
||||
assert.NotNil(t, p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SingleStanzaOnly", func(t *testing.T) {
|
||||
// A control file with a trailing stanza must not leak the extra fields into
|
||||
// p.Control, otherwise buildPackagesIndices would emit a second package entry
|
||||
// with an attacker-chosen Filename into the repository "Packages" index.
|
||||
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
|
||||
|
||||
p, err := ParseControlFile(content)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "realpkg", p.Name)
|
||||
assert.Equal(t, "1.0.0", p.Version)
|
||||
assert.NotContains(t, p.Control, "openssl")
|
||||
assert.NotContains(t, p.Control, "evil.deb")
|
||||
})
|
||||
}
|
||||
|
||||
+27
-20
@@ -14,6 +14,7 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||
)
|
||||
|
||||
// settings
|
||||
@@ -197,32 +198,38 @@ func loadLoginNotificationFrom(cfg ConfigProvider) {
|
||||
|
||||
func loadRunModeFrom(rootCfg ConfigProvider) {
|
||||
rootSec := rootCfg.Section("")
|
||||
mustNotRunAsRoot(rootSec)
|
||||
|
||||
runModeValue := os.Getenv("GITEA_RUN_MODE")
|
||||
runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String())
|
||||
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
|
||||
IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future
|
||||
RunMode = util.Iif(IsProd, "prod", "dev")
|
||||
|
||||
// there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser)
|
||||
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
|
||||
}
|
||||
|
||||
func mustNotRunAsRoot(rootSec ConfigSection) {
|
||||
if os.Getuid() != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0
|
||||
if mustRunAsRoot {
|
||||
return
|
||||
}
|
||||
|
||||
// The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches.
|
||||
// Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
|
||||
unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")
|
||||
unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
|
||||
RunMode = os.Getenv("GITEA_RUN_MODE")
|
||||
if RunMode == "" {
|
||||
RunMode = rootSec.Key("RUN_MODE").MustString("prod")
|
||||
}
|
||||
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config
|
||||
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var
|
||||
|
||||
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
|
||||
RunMode = strings.ToLower(RunMode)
|
||||
if RunMode != "dev" {
|
||||
RunMode = "prod"
|
||||
}
|
||||
IsProd = RunMode != "dev"
|
||||
|
||||
// check if we run as root
|
||||
if os.Getuid() == 0 {
|
||||
if !unsafeAllowRunAsRoot {
|
||||
// Special thanks to VLC which inspired the wording of this messaging.
|
||||
log.Fatal("Gitea is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission")
|
||||
}
|
||||
log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
|
||||
if !allowRunAsRoot {
|
||||
// Special thanks to VLC which inspired the wording of this messaging.
|
||||
log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.")
|
||||
}
|
||||
log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
|
||||
}
|
||||
|
||||
// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
|
||||
|
||||
@@ -165,9 +165,28 @@ type IssueStatusDef struct {
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
ClosesIssue bool `json:"closes_issue"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// StatusPresetEntry represents a single status in a preset template.
|
||||
// swagger:model
|
||||
type StatusPresetEntry struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
ClosesIssue bool `json:"closes_issue"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
}
|
||||
|
||||
// StatusPreset represents a named status preset template.
|
||||
// swagger:model
|
||||
type StatusPreset struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Statuses []*StatusPresetEntry `json:"statuses"`
|
||||
}
|
||||
|
||||
// IssuePriorityDef represents an org-level issue priority definition
|
||||
// swagger:model
|
||||
type IssuePriorityDef struct {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
// OrgEmailDomainPolicy represents an organization's email domain policy
|
||||
type OrgEmailDomainPolicy struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
AllowedDomains string `json:"allowed_domains"`
|
||||
}
|
||||
|
||||
// EditOrgEmailDomainPolicyOption options for editing an org's email domain policy
|
||||
type EditOrgEmailDomainPolicyOption struct {
|
||||
AllowedDomains *string `json:"allowed_domains"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// OrgPushPolicy represents an organization's push policy (one per org)
|
||||
type OrgPushPolicy struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
BranchNamePattern string `json:"branch_name_pattern"`
|
||||
TagNamePattern string `json:"tag_name_pattern"`
|
||||
RequireSecretBlock bool `json:"require_secret_block"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
BlockedFilePatterns string `json:"blocked_file_patterns"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EditOrgPushPolicyOption options for editing an organization's push policy. Only
|
||||
// fields that are set will be changed.
|
||||
type EditOrgPushPolicyOption struct {
|
||||
BranchNamePattern *string `json:"branch_name_pattern"`
|
||||
TagNamePattern *string `json:"tag_name_pattern"`
|
||||
RequireSecretBlock *bool `json:"require_secret_block"`
|
||||
MaxFileSize *int64 `json:"max_file_size"`
|
||||
BlockedFilePatterns *string `json:"blocked_file_patterns"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
// OrgRepoDefaults represents an organization's default repository settings
|
||||
type OrgRepoDefaults struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
ForcePrivate bool `json:"force_private"`
|
||||
ApplyPRDefaults bool `json:"apply_pr_defaults"`
|
||||
AllowMerge bool `json:"allow_merge"`
|
||||
AllowRebase bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge bool `json:"allow_rebase_merge"`
|
||||
AllowSquash bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
|
||||
// EditOrgRepoDefaultsOption options for editing an org's repo defaults. Only fields
|
||||
// that are set will be changed.
|
||||
type EditOrgRepoDefaultsOption struct {
|
||||
ForcePrivate *bool `json:"force_private"`
|
||||
ApplyPRDefaults *bool `json:"apply_pr_defaults"`
|
||||
AllowMerge *bool `json:"allow_merge"`
|
||||
AllowRebase *bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
||||
AllowSquash *bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly *bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle *string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// OrgTagProtection represents an org-level tag protection rule
|
||||
type OrgTagProtection struct {
|
||||
ID int64 `json:"id"`
|
||||
OrgID int64 `json:"org_id"`
|
||||
NamePattern string `json:"name_pattern"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateOrgTagProtectionOption options for creating an org-level tag protection
|
||||
type CreateOrgTagProtectionOption struct {
|
||||
NamePattern string `json:"name_pattern" binding:"Required"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
}
|
||||
|
||||
// EditOrgTagProtectionOption options for editing an org-level tag protection
|
||||
type EditOrgTagProtectionOption struct {
|
||||
NamePattern *string `json:"name_pattern"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
}
|
||||
@@ -48,7 +48,13 @@ type BranchProtection struct {
|
||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||
EnableDelete bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"`
|
||||
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
@@ -93,7 +99,13 @@ type CreateBranchProtectionOption struct {
|
||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||
EnableDelete bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"`
|
||||
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
@@ -129,7 +141,13 @@ type EditBranchProtectionOption struct {
|
||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"`
|
||||
ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"`
|
||||
ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"`
|
||||
EnableDelete *bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
DeleteAllowlistDeployKeys *bool `json:"delete_allowlist_deploy_keys"`
|
||||
DeleteAllowlistActionsUser *bool `json:"delete_allowlist_actions_user"`
|
||||
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// CascadeMergeRule represents a cascade merge rule
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AutoMerge bool `json:"auto_merge"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateCascadeMergeRuleOption options for creating a cascade merge rule
|
||||
type CreateCascadeMergeRuleOption struct {
|
||||
SourceBranch string `json:"source_branch" binding:"Required"`
|
||||
TargetBranch string `json:"target_branch" binding:"Required"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AutoMerge *bool `json:"auto_merge"`
|
||||
}
|
||||
|
||||
// EditCascadeMergeRuleOption options for editing a cascade merge rule
|
||||
type EditCascadeMergeRuleOption struct {
|
||||
SourceBranch *string `json:"source_branch"`
|
||||
TargetBranch *string `json:"target_branch"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AutoMerge *bool `json:"auto_merge"`
|
||||
}
|
||||
@@ -40,6 +40,16 @@ type CreateAccessTokenOption struct {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// EditAccessTokenOption options when editing access token scopes
|
||||
// swagger:model EditAccessTokenOption
|
||||
type EditAccessTokenOption struct {
|
||||
// The new name for the token (optional)
|
||||
Name string `json:"name"`
|
||||
// The new scopes for the token
|
||||
// example: ["read:repository", "write:issue"]
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
||||
type CreateOAuth2ApplicationOptions struct {
|
||||
// The name of the OAuth2 application
|
||||
|
||||
@@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
||||
// a bare URL in the subject must not hijack the default link
|
||||
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
||||
// a URL embedded in larger subject text still becomes its own link
|
||||
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderIssueTitle", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
expected := ` space @mention-user<SPACE><SPACE>
|
||||
|
||||
@@ -855,6 +855,8 @@
|
||||
"settings.access_token_deletion_confirm_action": "Delete",
|
||||
"settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?",
|
||||
"settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.",
|
||||
"settings.edit_token_scopes": "Edit Token Scopes",
|
||||
"settings.update_token_success": "Token scopes have been updated successfully.",
|
||||
"settings.repo_and_org_access": "Repository and Organization Access",
|
||||
"settings.permissions_public_only": "Public only",
|
||||
"settings.permissions_access_all": "All (public, private, and limited)",
|
||||
@@ -2409,6 +2411,56 @@
|
||||
"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.org_protected_tag": "Organization Tag Protection",
|
||||
"repo.settings.org_protected_tag_desc": "These tag protection rules are defined by the organization and are enforced on top of this repository's own rules. They cannot be edited here.",
|
||||
"repo.settings.org_protected_tag.read_only": "Read-only",
|
||||
"repo.settings.org_push_policy": "Organization Push Policy",
|
||||
"repo.settings.org_push_policy_desc": "This push policy is defined by the organization and enforced on every push to this repository. It cannot be edited here.",
|
||||
"repo.settings.org_push_policy.inherited": "Organization",
|
||||
"repo.settings.org_push_policy.read_only": "Read-only",
|
||||
"repo.settings.org_push_policy.branch_pattern": "Allowed branch names",
|
||||
"repo.settings.org_push_policy.tag_pattern": "Allowed tag names",
|
||||
"repo.settings.org_push_policy.require_secret_block": "Block pushes containing secrets",
|
||||
"repo.settings.org_push_policy.max_file_size": "Maximum file size",
|
||||
"repo.settings.org_push_policy.blocked_files": "Blocked file paths",
|
||||
"repo.settings.org_push_policy.enabled": "Enabled",
|
||||
"repo.settings.org_repo_defaults": "Organization Repository Defaults",
|
||||
"repo.settings.org_repo_defaults_desc": "These defaults are defined by the organization and were applied to this repository when it was created or transferred into the organization. They are shown for reference and cannot be edited here.",
|
||||
"repo.settings.org_repo_defaults.inherited": "Organization",
|
||||
"repo.settings.org_repo_defaults.read_only": "Read-only",
|
||||
"repo.settings.org_repo_defaults.force_private": "Force private repositories",
|
||||
"repo.settings.org_repo_defaults.merge_styles": "Allowed merge styles",
|
||||
"repo.settings.org_repo_defaults.default_merge_style": "Default merge style",
|
||||
"repo.settings.org_repo_defaults.delete_branch_after_merge": "Delete branch after merge",
|
||||
"repo.settings.org_repo_defaults.style_merge": "Merge commits",
|
||||
"repo.settings.org_repo_defaults.style_rebase": "Rebase",
|
||||
"repo.settings.org_repo_defaults.style_rebase_merge": "Rebase with merge commit",
|
||||
"repo.settings.org_repo_defaults.style_squash": "Squash",
|
||||
"repo.settings.org_repo_defaults.style_fast_forward_only": "Fast-forward only",
|
||||
"repo.settings.org_repo_defaults.enabled": "Enabled",
|
||||
"repo.settings.org_repo_defaults.disabled": "Disabled",
|
||||
"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",
|
||||
@@ -2437,6 +2489,17 @@
|
||||
"repo.settings.protect_force_push_allowlist_teams": "Allowlisted teams for force pushing:",
|
||||
"repo.settings.protect_force_push_allowlist_deploy_keys": "Allowlist deploy keys with push access to force push.",
|
||||
"repo.settings.protect_force_push_allowlist_actions_user": "Allowlist actions bot user to force push.",
|
||||
"repo.settings.event_delete": "Branch Deletion",
|
||||
"repo.settings.protect_disable_delete": "Disable Deletion",
|
||||
"repo.settings.protect_disable_delete_desc": "This branch cannot be deleted.",
|
||||
"repo.settings.protect_enable_delete_all": "Enable Deletion",
|
||||
"repo.settings.protect_enable_delete_all_desc": "Anyone with admin access will be allowed to delete this branch.",
|
||||
"repo.settings.protect_enable_delete_allowlist": "Allowlist Restricted Deletion",
|
||||
"repo.settings.protect_enable_delete_allowlist_desc": "Only allowlisted users or teams will be allowed to delete this branch.",
|
||||
"repo.settings.protect_delete_allowlist_users": "Allowlisted users for deletion:",
|
||||
"repo.settings.protect_delete_allowlist_teams": "Allowlisted teams for deletion:",
|
||||
"repo.settings.protect_delete_allowlist_deploy_keys": "Allowlist deploy keys with write access to delete.",
|
||||
"repo.settings.protect_delete_allowlist_actions_user": "Allowlist actions bot user to delete.",
|
||||
"repo.settings.protect_merge_whitelist_committers": "Enable Merge Allowlist",
|
||||
"repo.settings.protect_merge_whitelist_committers_desc": "Allow only allowlisted users or teams to merge pull requests into this branch.",
|
||||
"repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:",
|
||||
@@ -2996,6 +3059,11 @@
|
||||
"org.settings.issue_status_created": "Issue status created.",
|
||||
"org.settings.issue_status_updated": "Issue status updated.",
|
||||
"org.settings.issue_status_deleted": "Issue status deleted.",
|
||||
"org.settings.issue_status_presets": "Status Presets",
|
||||
"org.settings.issue_status_presets_desc": "Apply a preset template to replace your current statuses. Required statuses (Open/Closed) are preserved; others are deactivated and replaced.",
|
||||
"org.settings.issue_status_preset_apply": "Apply Preset",
|
||||
"org.settings.issue_status_preset_confirm": "This will deactivate your current custom statuses and replace them with the selected preset. Required statuses are preserved. Continue?",
|
||||
"org.settings.issue_status_preset_applied": "Status preset applied successfully.",
|
||||
"org.settings.issue_priorities": "Issue Priorities",
|
||||
"org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.",
|
||||
"org.settings.issue_priorities_empty": "No custom issue priorities defined yet.",
|
||||
|
||||
@@ -261,16 +261,32 @@ func (s *Service) UpdateLog(
|
||||
}
|
||||
ack := task.LogLength
|
||||
|
||||
if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack {
|
||||
// Trim rows the runner already had acked.
|
||||
var rows []*runnerv1.LogRow
|
||||
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack {
|
||||
rows = req.Msg.Rows[ack-req.Msg.Index:]
|
||||
}
|
||||
|
||||
// Ack a re-sent finalize idempotently. Appending new rows past the seal errors.
|
||||
if task.LogInStorage {
|
||||
if len(rows) > 0 {
|
||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
||||
}
|
||||
res.Msg.AckIndex = ack
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if task.LogInStorage {
|
||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
||||
// Bail unless we have new rows or a NoMore to finalize. Even with
|
||||
// NoMore, bail when the runner has outrun the server — archiving a
|
||||
// log with a gap is worse than asking it to retry.
|
||||
if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) {
|
||||
res.Msg.AckIndex = ack
|
||||
return res, nil
|
||||
}
|
||||
|
||||
rows := req.Msg.Rows[ack-req.Msg.Index:]
|
||||
// WriteLogs is called even with no rows: with offset==0 it bootstraps
|
||||
// an empty DBFS file so TransferLogs below has something to read when
|
||||
// the runner finalizes a task that produced no log output.
|
||||
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
|
||||
|
||||
+131
-37
@@ -294,6 +294,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
|
||||
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
|
||||
return
|
||||
}
|
||||
case auth_model.AccessTokenScopeCategoryLicensing:
|
||||
ctx.APIError(http.StatusForbidden, "token scope is limited to public resources, licensing is not available")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,41 +508,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be an team member, or a site admin
|
||||
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.Org.Organization == nil {
|
||||
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||
return
|
||||
}
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
return 0, true, true
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
orgID = ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
} else if isOwner {
|
||||
return orgID, true, true
|
||||
}
|
||||
|
||||
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
}
|
||||
return orgID, isTeamMember, true
|
||||
}
|
||||
|
||||
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be a team member, or a site admin
|
||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
return
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if isOwner {
|
||||
return
|
||||
}
|
||||
|
||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isTeamMember {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,7 +1045,9 @@ func Routes() *web.Router {
|
||||
m.Group("/tokens", func() {
|
||||
m.Combo("").Get(user.ListAccessTokens).
|
||||
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
|
||||
m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken)
|
||||
m.Combo("/{id}").
|
||||
Patch(bind(api.EditAccessTokenOption{}), reqToken(), user.UpdateAccessToken).
|
||||
Delete(reqToken(), user.DeleteAccessToken)
|
||||
}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
|
||||
|
||||
m.Get("/activities/feeds", user.ListUserActivityFeeds)
|
||||
@@ -1257,6 +1300,23 @@ func Routes() *web.Router {
|
||||
})
|
||||
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
||||
}, reqToken(), reqAdmin())
|
||||
m.Group("/cascade_rules", func() {
|
||||
m.Get("", repo.ListCascadeRules)
|
||||
m.Post("", mustNotBeArchived, repo.CreateCascadeRule)
|
||||
m.Group("/{id}", func() {
|
||||
m.Get("", repo.GetCascadeRule)
|
||||
m.Patch("", mustNotBeArchived, repo.EditCascadeRule)
|
||||
m.Delete("", mustNotBeArchived, repo.DeleteCascadeRule)
|
||||
})
|
||||
}, reqToken(), reqAdmin())
|
||||
m.Group("/security", func() {
|
||||
m.Get("/alerts", repo.ListSecurityAlerts)
|
||||
m.Get("/alerts/{id}", repo.GetSecurityAlert)
|
||||
m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert)
|
||||
m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan)
|
||||
m.Get("/config", repo.GetSecurityConfig)
|
||||
m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig)
|
||||
}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true))
|
||||
m.Group("/tags", func() {
|
||||
m.Get("", repo.ListTags)
|
||||
m.Get("/*", repo.GetTag)
|
||||
@@ -1314,6 +1374,7 @@ func Routes() *web.Router {
|
||||
m.Get("/revisions/*", repo.ListPageRevisions)
|
||||
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
||||
m.Get("/pages", repo.ListWikiPages)
|
||||
m.Get("/search", repo.SearchWikiPages)
|
||||
}, mustEnableWiki)
|
||||
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
||||
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
||||
@@ -1739,7 +1800,7 @@ func Routes() *web.Router {
|
||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||
})
|
||||
}, reqOrgVisible())
|
||||
m.Group("/hooks", func() {
|
||||
m.Combo("").Get(org.ListHooks).
|
||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||
@@ -1762,6 +1823,34 @@ func Routes() *web.Router {
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/tag_protections", func() {
|
||||
m.Combo("").Get(org.ListOrgTagProtections).
|
||||
Post(bind(api.CreateOrgTagProtectionOption{}), org.CreateOrgTagProtection)
|
||||
m.Group("/{id}", func() {
|
||||
m.Get("", org.GetOrgTagProtection)
|
||||
m.Patch("", bind(api.EditOrgTagProtectionOption{}), org.EditOrgTagProtection)
|
||||
m.Delete("", org.DeleteOrgTagProtection)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/push_policy", func() {
|
||||
m.Combo("").Get(org.GetOrgPushPolicy).
|
||||
Patch(bind(api.EditOrgPushPolicyOption{}), org.EditOrgPushPolicy).
|
||||
Delete(org.DeleteOrgPushPolicy)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/repo_defaults", func() {
|
||||
m.Combo("").Get(org.GetOrgRepoDefaults).
|
||||
Patch(bind(api.EditOrgRepoDefaultsOption{}), org.EditOrgRepoDefaults).
|
||||
Delete(org.DeleteOrgRepoDefaults)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/email_domain_policy", func() {
|
||||
m.Combo("").Get(org.GetOrgEmailDomainPolicy).
|
||||
Patch(bind(api.EditOrgEmailDomainPolicyOption{}), org.EditOrgEmailDomainPolicy).
|
||||
Delete(org.DeleteOrgEmailDomainPolicy)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/blocks", func() {
|
||||
m.Get("", org.ListBlocks)
|
||||
m.Group("/{username}", func() {
|
||||
@@ -1776,6 +1865,11 @@ func Routes() *web.Router {
|
||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||
})
|
||||
m.Get("/issue-statuses", org.ListIssueStatuses)
|
||||
m.Group("/issue-statuses", func() {
|
||||
m.Get("/presets", org.ListIssueStatusPresets)
|
||||
m.Post("/presets/{preset}", reqToken(), reqOrgOwnership(), org.ApplyIssueStatusPreset)
|
||||
m.Post("/copy/{source_org}", reqToken(), reqOrgOwnership(), org.CopyIssueStatusesFromOrg)
|
||||
})
|
||||
m.Get("/issue-priorities", org.ListIssuePriorities)
|
||||
m.Get("/issue-types", org.ListIssueTypes)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
@@ -1793,12 +1887,12 @@ func Routes() *web.Router {
|
||||
m.Group("/repos", func() {
|
||||
m.Get("", reqToken(), org.GetTeamRepos)
|
||||
m.Combo("/{org}/{reponame}").
|
||||
Put(reqToken(), org.AddTeamRepository).
|
||||
Delete(reqToken(), org.RemoveTeamRepository).
|
||||
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
|
||||
Get(reqToken(), org.GetTeamRepo)
|
||||
})
|
||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
|
||||
|
||||
m.Group("/admin", func() {
|
||||
m.Group("/cron", func() {
|
||||
@@ -1891,7 +1985,7 @@ func Routes() *web.Router {
|
||||
|
||||
// Authenticated license detail
|
||||
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryLicensing))
|
||||
}, sudo())
|
||||
|
||||
return m
|
||||
|
||||
@@ -207,7 +207,10 @@ func UpdateLicense(ctx *context.APIContext) {
|
||||
}
|
||||
if len(cols) > 0 {
|
||||
cols = append(cols, "updated_at")
|
||||
db.GetEngine(ctx).ID(id).Cols(cols...).Update(license)
|
||||
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(license); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
|
||||
@@ -399,7 +402,10 @@ func UpdateTier(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
if len(cols) > 0 {
|
||||
db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier)
|
||||
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, tierToJSON(tier))
|
||||
@@ -427,7 +433,10 @@ func DeleteTier(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
|
||||
if _, err := db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier)); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
func toAPIOrgEmailDomainPolicy(policy *git_model.OrgEmailDomainPolicy, orgID int64) *api.OrgEmailDomainPolicy {
|
||||
if policy == nil {
|
||||
return &api.OrgEmailDomainPolicy{OrgID: orgID}
|
||||
}
|
||||
return &api.OrgEmailDomainPolicy{
|
||||
OrgID: policy.OrgID,
|
||||
AllowedDomains: policy.AllowedDomains,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgEmailDomainPolicy get the organization's email domain policy
|
||||
func GetOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// EditOrgEmailDomainPolicy create or update the organization's email domain policy
|
||||
func EditOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgEmailDomainPolicyOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if policy == nil {
|
||||
policy = &git_model.OrgEmailDomainPolicy{OrgID: orgID}
|
||||
}
|
||||
if form.AllowedDomains != nil {
|
||||
policy.AllowedDomains = *form.AllowedDomains
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgEmailDomainPolicy(ctx, policy); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgEmailDomainPolicy remove the organization's email domain policy
|
||||
func DeleteOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgEmailDomainPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -6,11 +6,26 @@ package org
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// checkOrgVisibility returns true if the current user can view org metadata.
|
||||
// Public orgs are visible to everyone. Private/limited orgs require authentication.
|
||||
func checkOrgVisibility(ctx *context.APIContext) bool {
|
||||
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
|
||||
return true
|
||||
}
|
||||
if ctx.Doer == nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ListIssueStatuses returns active issue status definitions for an org.
|
||||
func ListIssueStatuses(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
||||
@@ -34,6 +49,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !checkOrgVisibility(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -47,6 +66,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
||||
Color: d.Color,
|
||||
Description: d.Description,
|
||||
ClosesIssue: d.ClosesIssue,
|
||||
IsRequired: d.IsRequired,
|
||||
SortOrder: d.SortOrder,
|
||||
})
|
||||
}
|
||||
@@ -76,6 +96,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !checkOrgVisibility(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -118,6 +142,10 @@ func ListIssueTypes(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !checkOrgVisibility(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -136,3 +164,124 @@ func ListIssueTypes(ctx *context.APIContext) {
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ListIssueStatusPresets returns the available status preset templates.
|
||||
func ListIssueStatusPresets(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/issue-statuses/presets organization orgListIssueStatusPresets
|
||||
// ---
|
||||
// summary: List available issue status presets
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "StatusPresetList"
|
||||
|
||||
result := make([]*api.StatusPreset, 0, len(issues_model.StatusPresetNames()))
|
||||
for _, name := range issues_model.StatusPresetNames() {
|
||||
preset := issues_model.StatusPresets[name]
|
||||
statuses := make([]*api.StatusPresetEntry, 0, len(preset.Statuses))
|
||||
for _, s := range preset.Statuses {
|
||||
statuses = append(statuses, &api.StatusPresetEntry{
|
||||
Name: s.Name,
|
||||
Color: s.Color,
|
||||
Description: s.Description,
|
||||
ClosesIssue: s.ClosesIssue,
|
||||
IsRequired: s.IsRequired,
|
||||
})
|
||||
}
|
||||
result = append(result, &api.StatusPreset{
|
||||
Name: preset.Name,
|
||||
Description: preset.Description,
|
||||
Statuses: statuses,
|
||||
})
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ApplyIssueStatusPreset applies a status preset to an organization.
|
||||
func ApplyIssueStatusPreset(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/issue-statuses/presets/{preset} organization orgApplyIssueStatusPreset
|
||||
// ---
|
||||
// summary: Apply a status preset to an organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: preset
|
||||
// in: path
|
||||
// description: preset name
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: "StatusPresetApplied"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
presetName := ctx.PathParam("preset")
|
||||
if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// CopyIssueStatusesFromOrg copies status definitions from another organization.
|
||||
func CopyIssueStatusesFromOrg(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/issue-statuses/copy/{source_org} organization orgCopyIssueStatuses
|
||||
// ---
|
||||
// summary: Copy issue statuses from another organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: target organization name
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: source_org
|
||||
// in: path
|
||||
// description: source organization name to copy from
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: "StatusesCopied"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
sourceOrgName := ctx.PathParam("source_org")
|
||||
sourceOrg, err := org_model.GetOrgByName(ctx, sourceOrgName)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if sourceOrg.Visibility != api.VisibleTypePublic && !ctx.Doer.IsAdmin {
|
||||
isMember, err := org_model.IsOrganizationMember(ctx, sourceOrg.ID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if !isMember {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// toAPIOrgPushPolicy converts the model to its API representation. A nil policy is
|
||||
// rendered as an all-empty policy so clients always get a consistent shape.
|
||||
func toAPIOrgPushPolicy(policy *git_model.OrgPushPolicy, orgID int64) *api.OrgPushPolicy {
|
||||
if policy == nil {
|
||||
return &api.OrgPushPolicy{OrgID: orgID}
|
||||
}
|
||||
return &api.OrgPushPolicy{
|
||||
OrgID: policy.OrgID,
|
||||
BranchNamePattern: policy.BranchNamePattern,
|
||||
TagNamePattern: policy.TagNamePattern,
|
||||
RequireSecretBlock: policy.RequireSecretBlock,
|
||||
MaxFileSize: policy.MaxFileSize,
|
||||
BlockedFilePatterns: policy.BlockedFilePatterns,
|
||||
Created: policy.CreatedUnix.AsTime(),
|
||||
Updated: policy.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgPushPolicy get the organization's push policy
|
||||
func GetOrgPushPolicy(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// EditOrgPushPolicy create or update the organization's push policy
|
||||
func EditOrgPushPolicy(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgPushPolicyOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if policy == nil {
|
||||
policy = &git_model.OrgPushPolicy{OrgID: orgID}
|
||||
}
|
||||
|
||||
if form.BranchNamePattern != nil {
|
||||
policy.BranchNamePattern = *form.BranchNamePattern
|
||||
}
|
||||
if form.TagNamePattern != nil {
|
||||
policy.TagNamePattern = *form.TagNamePattern
|
||||
}
|
||||
if form.RequireSecretBlock != nil {
|
||||
policy.RequireSecretBlock = *form.RequireSecretBlock
|
||||
}
|
||||
if form.MaxFileSize != nil {
|
||||
policy.MaxFileSize = *form.MaxFileSize
|
||||
}
|
||||
if form.BlockedFilePatterns != nil {
|
||||
policy.BlockedFilePatterns = *form.BlockedFilePatterns
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgPushPolicy(ctx, policy); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgPushPolicy remove the organization's push policy
|
||||
func DeleteOrgPushPolicy(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgPushPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user