Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87f92fe1ab | |||
| 66aea89b40 | |||
| 7c75133ef1 | |||
| 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 |
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Determine target repos
|
- name: Determine target repos
|
||||||
id: repos
|
id: repos
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API="${GITEA_URL}/api/v1"
|
API="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
REPOS=""
|
REPOS=""
|
||||||
while true; do
|
while true; do
|
||||||
BATCH=$(curl -sS \
|
BATCH=$(curl -sS \
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||||
| jq -r '.[].name // empty')
|
| jq -r '.[].name // empty')
|
||||||
[ -z "$BATCH" ] && break
|
[ -z "$BATCH" ] && break
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Apply protection rules
|
- name: Apply protection rules
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||||
run: |
|
run: |
|
||||||
API="${GITEA_URL}/api/v1"
|
API="${GITEA_URL}/api/v1"
|
||||||
@@ -214,13 +214,13 @@ jobs:
|
|||||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||||
curl -sS -o /dev/null -w "" \
|
curl -sS -o /dev/null -w "" \
|
||||||
-X DELETE \
|
-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
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||||
|
|
||||||
# Create rule
|
# Create rule
|
||||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$RULE" \
|
-d "$RULE" \
|
||||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
# VERSION: 09.02.00
|
# VERSION: 09.02.00
|
||||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||||
|
|
||||||
name: "Universal: Auto Version Bump"
|
name: "Universal: Auto Version Bump"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- rc
|
- rc
|
||||||
- 'feature/**'
|
- 'feature/**'
|
||||||
- 'patch/**'
|
- 'patch/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump:
|
bump:
|
||||||
name: Version Bump
|
name: Version Bump
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
run: |
|
run: |
|
||||||
if ! command -v composer &> /dev/null; then
|
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
|
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
|
fi
|
||||||
if [ -d "/opt/mokocli/cli" ]; then
|
if [ -d "/opt/mokocli/cli" ]; then
|
||||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||||
/tmp/mokocli
|
/tmp/mokocli
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
run: |
|
run: |
|
||||||
php ${MOKO_CLI}/version_auto_bump.php \
|
php ${MOKO_CLI}/version_auto_bump.php \
|
||||||
--path . --branch "${GITHUB_REF_NAME}" \
|
--path . --branch "${GITHUB_REF_NAME}" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
@@ -21,15 +21,24 @@
|
|||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, closed]
|
types: [opened, synchronize, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.mokogitea/workflows/**'
|
||||||
|
- '*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.gitattributes'
|
||||||
|
- '.gitmessage'
|
||||||
|
- 'LICENSE'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -43,7 +52,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
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_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
@@ -51,12 +60,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||||
promote-rc:
|
promote-rc:
|
||||||
name: Promote to RC
|
name: Promote to RC
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
(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')
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -92,7 +102,7 @@ jobs:
|
|||||||
php ${MOKO_CLI}/branch_rename.php \
|
php ${MOKO_CLI}/branch_rename.php \
|
||||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
--pr "${{ github.event.pull_request.number }}"
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
|
|
||||||
- name: Checkout rc and configure git
|
- name: Checkout rc and configure git
|
||||||
@@ -111,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update RC release notes from CHANGELOG.md
|
- name: Update RC release notes from CHANGELOG.md
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Extract [Unreleased] section from changelog
|
# Extract [Unreleased] section from changelog
|
||||||
@@ -149,7 +159,7 @@ jobs:
|
|||||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Branch renamed to rc, minor bump, RC release built" >> $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:
|
release:
|
||||||
name: Build & Release Pipeline
|
name: Build & Release Pipeline
|
||||||
runs-on: release
|
runs-on: release
|
||||||
@@ -241,14 +251,50 @@ jobs:
|
|||||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
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 "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
echo "Published version: ${VERSION}"
|
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
|
- name: Update release notes and promote changelog
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Get the stable release info (version and ID)
|
# Get the stable release info (version and ID)
|
||||||
@@ -317,7 +363,7 @@ jobs:
|
|||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
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 \
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
@@ -346,7 +392,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Delete rc branch (ephemeral — created by promote-rc)
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
@@ -370,7 +416,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
BRANCH_NAME="version/${VERSION}"
|
BRANCH_NAME="version/${VERSION}"
|
||||||
@@ -391,7 +437,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 \
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
--branch dev --path . 2>&1 || true
|
--branch dev --path . 2>&1 || true
|
||||||
@@ -417,5 +463,5 @@ jobs:
|
|||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $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
|
fi
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
name: "Generic: Project CI"
|
name: "Generic: Project CI"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev/**
|
||||||
|
- rc/**
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
@@ -21,7 +21,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# 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')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
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
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
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)
|
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
|
# 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" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
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
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
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:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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)
|
- name: Checkout source (for version detection)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup MokoStandards tools
|
- name: Setup MokoStandards tools
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ permissions:
|
|||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-branch:
|
create-branch:
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create branch and comment
|
- name: Create branch and comment
|
||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
echo "Created branch: ${BRANCH}"
|
echo "Created branch: ${BRANCH}"
|
||||||
|
|
||||||
# Comment on issue with branch link
|
# 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\`\`\`"
|
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||||
|
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
+531
-534
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
# 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
|
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
name: "Universal: Pre-Release"
|
||||||
@@ -59,6 +59,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
ref: ${{ github.ref_name }}
|
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
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
@@ -88,8 +93,20 @@ jobs:
|
|||||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Check platform eligibility (Joomla only)
|
||||||
|
id: eligibility
|
||||||
|
run: |
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Resolve metadata and bump version
|
- name: Resolve metadata and bump version
|
||||||
id: meta
|
id: meta
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
@@ -166,6 +183,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: release
|
id: release
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -176,6 +194,7 @@ jobs:
|
|||||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
- name: Update release notes from CHANGELOG.md
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -212,6 +231,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package and upload
|
- name: Build package and upload
|
||||||
id: package
|
id: package
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
@@ -225,6 +245,7 @@ jobs:
|
|||||||
# No need to build, commit, or sync updates.xml from workflows
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|||||||
@@ -29,12 +29,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Rename branch
|
- 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: |
|
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/}"
|
SUFFIX="${BRANCH#rc/}"
|
||||||
DEV_BRANCH="dev/${SUFFIX}"
|
DEV_BRANCH="dev/${SUFFIX}"
|
||||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
|
||||||
|
|
||||||
# Create dev/ branch from rc/ branch
|
# Create dev/ branch from rc/ branch
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||||
@@ -42,25 +50,22 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||||
"${API}" 2>/dev/null || true)
|
"${API}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "201" ]; then
|
if [ "$STATUS" = "201" ]; then
|
||||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete rc/ branch
|
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "204" ]; then
|
if [ "$STATUS" = "204" ]; then
|
||||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $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,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"
|
name: "Universal: Workflow Sync Trigger"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [closed]
|
types: [closed]
|
||||||
branches:
|
branches:
|
||||||
@@ -26,8 +27,9 @@ jobs:
|
|||||||
name: Sync workflows to live repos
|
name: Sync workflows to live repos
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >-
|
if: >-
|
||||||
github.event.pull_request.merged == true &&
|
github.event_name == 'workflow_dispatch' ||
|
||||||
!contains(github.event.pull_request.title, '[skip sync]')
|
(github.event.pull_request.merged == true &&
|
||||||
|
!contains(github.event.pull_request.title, '[skip sync]'))
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Determine platform from repo name
|
- name: Determine platform from repo name
|
||||||
@@ -49,8 +51,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
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
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,6 +3,28 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- 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)
|
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
||||||
- License CRUD with CRC32-checksummed DLID generation and format validation
|
- License CRUD with CRC32-checksummed DLID generation and format validation
|
||||||
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
||||||
@@ -10,6 +32,61 @@
|
|||||||
- 13 seeded product tiers from base to enterprise
|
- 13 seeded product tiers from base to enterprise
|
||||||
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||||
- Profile repo fallback chain: .mokogitea > .profile > .github
|
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||||
|
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
|
||||||
|
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
|
||||||
|
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
|
||||||
|
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
|
||||||
|
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
|
||||||
|
- Issue status API response includes is_required field
|
||||||
|
- Wiki recent changes page: cross-page edit activity with pagination (#670)
|
||||||
|
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- 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
|
## [06.19.00] --- 2026-06-20
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
# MokoGitea
|
# MokoGitea
|
||||||
|
|
||||||
Moko fork of Gitea — adding project board REST API endpoints and custom enhancements
|
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, 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)
|
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
|
||||||
- [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment)
|
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
|
||||||
- [Project API](Project API)
|
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
|
||||||
- [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap)
|
- **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
|
||||||
**Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
|
- **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
|
## 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
|
## Contributing
|
||||||
|
|
||||||
See the wiki for development guidelines and contribution instructions.
|
See the [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -40,4 +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
|
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 {
|
switch verb {
|
||||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
case git.CmdVerbReceivePack:
|
case git.CmdVerbReceivePack:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||||
switch lfsVerb {
|
switch lfsVerb {
|
||||||
case git.CmdSubVerbLfsUpload:
|
case git.CmdSubVerbLfsUpload:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdSubVerbLfsDownload:
|
case git.CmdSubVerbLfsDownload:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// should be unreachable
|
return perm.AccessModeNone, false
|
||||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
|
||||||
return perm.AccessModeNone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServ(ctx context.Context, c *cli.Command) error {
|
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)
|
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||||
if extra.HasError() {
|
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]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ type FindRunOptions struct {
|
|||||||
Ref string // the commit/tag/… that caused this workflow
|
Ref string // the commit/tag/… that caused this workflow
|
||||||
TriggerUserID int64
|
TriggerUserID int64
|
||||||
TriggerEvent webhook_module.HookEventType
|
TriggerEvent webhook_module.HookEventType
|
||||||
Approved bool // not util.OptionalBool, it works only when it's true
|
|
||||||
Status []Status
|
Status []Status
|
||||||
ConcurrencyGroup string
|
ConcurrencyGroup string
|
||||||
CommitSHA string
|
CommitSHA string
|
||||||
@@ -81,9 +80,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
|||||||
if opts.TriggerUserID > 0 {
|
if opts.TriggerUserID > 0 {
|
||||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
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 {
|
if len(opts.Status) > 0 {
|
||||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
AccessTokenScopeCategoryIssue
|
AccessTokenScopeCategoryIssue
|
||||||
AccessTokenScopeCategoryRepository
|
AccessTokenScopeCategoryRepository
|
||||||
AccessTokenScopeCategoryUser
|
AccessTokenScopeCategoryUser
|
||||||
|
AccessTokenScopeCategoryLicensing
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllAccessTokenScopeCategories contains all access token scope categories
|
// AllAccessTokenScopeCategories contains all access token scope categories
|
||||||
@@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{
|
|||||||
AccessTokenScopeCategoryIssue,
|
AccessTokenScopeCategoryIssue,
|
||||||
AccessTokenScopeCategoryRepository,
|
AccessTokenScopeCategoryRepository,
|
||||||
AccessTokenScopeCategoryUser,
|
AccessTokenScopeCategoryUser,
|
||||||
|
AccessTokenScopeCategoryLicensing,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessTokenScopeLevel represents the access levels without a given scope category
|
// AccessTokenScopeLevel represents the access levels without a given scope category
|
||||||
@@ -82,6 +84,9 @@ const (
|
|||||||
|
|
||||||
AccessTokenScopeReadUser AccessTokenScope = "read:user"
|
AccessTokenScopeReadUser AccessTokenScope = "read:user"
|
||||||
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
|
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
|
||||||
|
|
||||||
|
AccessTokenScopeReadLicensing AccessTokenScope = "read:licensing"
|
||||||
|
AccessTokenScopeWriteLicensing AccessTokenScope = "write:licensing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// accessTokenScopeBitmap represents a bitmap of access token scopes.
|
// accessTokenScopeBitmap represents a bitmap of access token scopes.
|
||||||
@@ -93,7 +98,8 @@ const (
|
|||||||
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits |
|
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits |
|
||||||
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits |
|
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits |
|
||||||
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits |
|
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits |
|
||||||
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits
|
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits |
|
||||||
|
accessTokenScopeWriteLicensingBits
|
||||||
|
|
||||||
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota
|
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota
|
||||||
|
|
||||||
@@ -124,6 +130,9 @@ const (
|
|||||||
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
|
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
|
||||||
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
|
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
|
||||||
|
|
||||||
|
accessTokenScopeReadLicensingBits accessTokenScopeBitmap = 1 << iota
|
||||||
|
accessTokenScopeWriteLicensingBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadLicensingBits
|
||||||
|
|
||||||
// The current implementation only supports up to 64 token scopes.
|
// The current implementation only supports up to 64 token scopes.
|
||||||
// If we need to support > 64 scopes,
|
// If we need to support > 64 scopes,
|
||||||
// refactoring the whole implementation in this file (and only this file) is needed.
|
// refactoring the whole implementation in this file (and only this file) is needed.
|
||||||
@@ -142,6 +151,7 @@ var allAccessTokenScopes = []AccessTokenScope{
|
|||||||
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
|
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
|
||||||
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
|
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
|
||||||
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
|
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
|
||||||
|
AccessTokenScopeWriteLicensing, AccessTokenScopeReadLicensing,
|
||||||
}
|
}
|
||||||
|
|
||||||
// allAccessTokenScopeBits contains all access token scopes.
|
// allAccessTokenScopeBits contains all access token scopes.
|
||||||
@@ -166,6 +176,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{
|
|||||||
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
|
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
|
||||||
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
|
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
|
||||||
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
|
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
|
||||||
|
AccessTokenScopeReadLicensing: accessTokenScopeReadLicensingBits,
|
||||||
|
AccessTokenScopeWriteLicensing: accessTokenScopeWriteLicensingBits,
|
||||||
}
|
}
|
||||||
|
|
||||||
// readAccessTokenScopes maps a scope category to the read permission scope
|
// readAccessTokenScopes maps a scope category to the read permission scope
|
||||||
@@ -180,6 +192,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
|||||||
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
|
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
|
||||||
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
|
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
|
||||||
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
|
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
|
||||||
|
AccessTokenScopeCategoryLicensing: AccessTokenScopeReadLicensing,
|
||||||
},
|
},
|
||||||
Write: {
|
Write: {
|
||||||
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
|
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
|
||||||
@@ -191,6 +204,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
|||||||
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
|
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
|
||||||
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
|
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
|
||||||
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
|
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
|
||||||
|
AccessTokenScopeCategoryLicensing: AccessTokenScopeWriteLicensing,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +384,7 @@ func (bitmap accessTokenScopeBitmap) toScope() AccessTokenScope {
|
|||||||
scope := AccessTokenScope(strings.Join(scopes, ","))
|
scope := AccessTokenScope(strings.Join(scopes, ","))
|
||||||
scope = AccessTokenScope(strings.ReplaceAll(
|
scope = AccessTokenScope(strings.ReplaceAll(
|
||||||
string(scope),
|
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",
|
"all",
|
||||||
))
|
))
|
||||||
return scope
|
return scope
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ type scopeTestNormalize struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAccessTokenScope_Normalize(t *testing.T) {
|
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{
|
tests := []scopeTestNormalize{
|
||||||
{"", "", nil},
|
{"", "", nil},
|
||||||
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
||||||
{"all", "all", 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,write:licensing", "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,public-only", "public-only,all", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scope := range GetAccessTokenCategories() {
|
for _, scope := range GetAccessTokenCategories() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateTOTP validates the provided passcode.
|
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
// 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)
|
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||||
if err != nil {
|
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)
|
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
||||||
if err != nil {
|
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)
|
secretStr := string(secretBytes)
|
||||||
return totp.Validate(passcode, secretStr), nil
|
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.
|
// NewTwoFactor creates a new two-factor authentication token.
|
||||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||||
_, err := db.GetEngine(ctx).Insert(t)
|
_, 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}})
|
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||||
return count > 0, err
|
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}})
|
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
|
return count > 0, err
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
|||||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||||
cond := builder.In(
|
cond := builder.In(
|
||||||
"`lfs_meta_object`.repository_id",
|
"`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 {
|
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ type ProtectedBranch struct {
|
|||||||
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
ForcePushAllowlistActionsUser 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"`
|
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
||||||
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
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)
|
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
|
// 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 {
|
func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
|
||||||
// Allow the actions bot user if explicitly whitelisted.
|
// Allow the actions bot user if explicitly whitelisted.
|
||||||
@@ -365,6 +411,9 @@ type WhitelistOptions struct {
|
|||||||
|
|
||||||
ApprovalsUserIDs []int64
|
ApprovalsUserIDs []int64
|
||||||
ApprovalsTeamIDs []int64
|
ApprovalsTeamIDs []int64
|
||||||
|
|
||||||
|
DeleteUserIDs []int64
|
||||||
|
DeleteTeamIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProtectBranch saves branch protection options of repository.
|
// UpdateProtectBranch saves branch protection options of repository.
|
||||||
@@ -430,6 +479,18 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
|
|||||||
}
|
}
|
||||||
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
|
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
|
// Looks like it's a new rule
|
||||||
if protectBranch.ID == 0 {
|
if protectBranch.ID == 0 {
|
||||||
// as it's a new rule and if priority was not set, we need to calc it.
|
// 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
|
// 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 {
|
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)
|
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs, lenDeleteIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs), len(p.DeleteAllowlistUserIDs)
|
||||||
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
|
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs, lenDeleteTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs), len(p.DeleteAllowlistTeamIDs)
|
||||||
|
|
||||||
if userID > 0 {
|
if userID > 0 {
|
||||||
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
||||||
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
||||||
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
||||||
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
||||||
|
p.DeleteAllowlistUserIDs = util.SliceRemoveAll(p.DeleteAllowlistUserIDs, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if teamID > 0 {
|
if teamID > 0 {
|
||||||
@@ -589,16 +651,19 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI
|
|||||||
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
||||||
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
||||||
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
||||||
|
p.DeleteAllowlistTeamIDs = util.SliceRemoveAll(p.DeleteAllowlistTeamIDs, teamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
||||||
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
||||||
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
||||||
lenMergeIDs != len(p.MergeWhitelistUserIDs)) ||
|
lenMergeIDs != len(p.MergeWhitelistUserIDs) ||
|
||||||
|
lenDeleteIDs != len(p.DeleteAllowlistUserIDs)) ||
|
||||||
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
||||||
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
||||||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
|
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 {
|
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
|
||||||
return fmt.Errorf("updateProtectedBranches: %v", err)
|
return fmt.Errorf("updateProtectedBranches: %v", err)
|
||||||
}
|
}
|
||||||
@@ -613,6 +678,7 @@ func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, us
|
|||||||
"force_push_allowlist_user_i_ds",
|
"force_push_allowlist_user_i_ds",
|
||||||
"merge_whitelist_user_i_ds",
|
"merge_whitelist_user_i_ds",
|
||||||
"approvals_whitelist_user_i_ds",
|
"approvals_whitelist_user_i_ds",
|
||||||
|
"delete_allowlist_user_i_ds",
|
||||||
}
|
}
|
||||||
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
|
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",
|
"force_push_allowlist_team_i_ds",
|
||||||
"merge_whitelist_team_i_ds",
|
"merge_whitelist_team_i_ds",
|
||||||
"approvals_whitelist_team_i_ds",
|
"approvals_whitelist_team_i_ds",
|
||||||
|
"delete_allowlist_team_i_ds",
|
||||||
}
|
}
|
||||||
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||||
Description string `xorm:"TEXT"`
|
Description string `xorm:"TEXT"`
|
||||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||||
|
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
|
||||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
@@ -32,6 +33,211 @@ func (IssueStatusDef) TableName() string {
|
|||||||
return "issue_status_def"
|
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
|
// Queries
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -56,14 +262,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||||
|
// Open and Closed are required (is_required=true) and cannot be deleted.
|
||||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||||
defaults := []*IssueStatusDef{
|
defaults := []*IssueStatusDef{
|
||||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
|
||||||
|
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
|
||||||
}
|
}
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||||
@@ -111,13 +318,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStatusRequired is returned when trying to delete a required status.
|
||||||
|
type ErrStatusRequired struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrStatusRequired) Error() string {
|
||||||
|
return "status is required and cannot be deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrStatusRequired checks if an error is ErrStatusRequired.
|
||||||
|
func IsErrStatusRequired(err error) bool {
|
||||||
|
_, ok := err.(ErrStatusRequired)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||||
|
// Returns ErrStatusRequired if the status is marked as required.
|
||||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||||
|
def, err := GetIssueStatusDefByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if def.IsRequired {
|
||||||
|
return ErrStatusRequired{ID: def.ID, Name: def.Name}
|
||||||
|
}
|
||||||
// Clear status_id on all issues that reference this definition
|
// Clear status_id on all issues that reference this definition
|
||||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
|||||||
return rules, warnings
|
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 {
|
type CodeOwnerRule struct {
|
||||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||||
Negative bool
|
Negative bool
|
||||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
|||||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||||
return nil, warnings
|
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:] {
|
for _, user := range tokens[1:] {
|
||||||
user = strings.TrimPrefix(user, "@")
|
user = strings.TrimPrefix(user, "@")
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package issues_test
|
package issues_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
|||||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||||
|
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||||
t.Run("GetApprovers", testGetApprovers)
|
t.Run("GetApprovers", testGetApprovers)
|
||||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
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) {
|
func testGetApprovers(t *testing.T) {
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||||
|
|||||||
@@ -436,6 +436,9 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
||||||
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
||||||
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
||||||
|
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
|
||||||
|
newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch),
|
||||||
|
newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddDeployFieldsToRepoManifest adds deploy configuration columns to repo_manifest.
|
||||||
|
func AddDeployFieldsToRepoManifest(x *xorm.Engine) error {
|
||||||
|
type RepoManifest struct {
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"`
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"`
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"`
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"`
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"`
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"`
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"`
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(RepoManifest))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -323,6 +323,60 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrganization creates record of a new organization.
|
// 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) {
|
func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) {
|
||||||
if !owner.CanCreateOrganization() {
|
if !owner.CanCreateOrganization() {
|
||||||
return ErrUserNotAllowedCreateOrg{}
|
return ErrUserNotAllowedCreateOrg{}
|
||||||
@@ -348,7 +402,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
|||||||
}
|
}
|
||||||
org.UseCustomAvatar = true
|
org.UseCustomAvatar = true
|
||||||
org.MaxRepoCreation = -1
|
org.MaxRepoCreation = -1
|
||||||
org.NumTeams = 1
|
org.NumTeams = 1 + len(DefaultOrgTeams)
|
||||||
org.NumMembers = 1
|
org.NumMembers = 1
|
||||||
org.Type = user_model.UserTypeOrganization
|
org.Type = user_model.UserTypeOrganization
|
||||||
|
|
||||||
@@ -413,6 +467,37 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("insert team-user relation: %w", err)
|
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
|
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
|
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
||||||
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
||||||
|
|
||||||
|
// deploy section
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"` // SSH user
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"` // remote path for source/compose
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"` // e.g. mokoconsulting/mokogitea
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"` // e.g. git.mokoconsulting.tech
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"` // Docker container name
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"` // health check URL after deploy
|
||||||
|
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
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 {
|
for _, condition := range conditions {
|
||||||
switch cond := condition.(type) {
|
switch cond := condition.(type) {
|
||||||
case *testCond:
|
case *testCond:
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
||||||
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
|
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
|
||||||
return db.GetEngine(ctx).Get(externalLoginUser)
|
return db.Get[ExternalLoginUser](ctx, builder.Eq{
|
||||||
|
"external_id": externalID,
|
||||||
|
"login_source_id": loginSourceID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkExternalToUser link the external user to the user
|
// LinkExternalToUser link the external user to the user
|
||||||
@@ -118,6 +121,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
|
|||||||
return err
|
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
|
// GetUserIDByExternalUserID get user id according to provider and userID
|
||||||
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext {
|
|||||||
return gitContext
|
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) {
|
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||||
switch rawOn.Kind {
|
switch rawOn.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
@@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if val == workflowCallEvent {
|
||||||
|
return []*Event{}, nil
|
||||||
|
}
|
||||||
return []*Event{
|
return []*Event{
|
||||||
{Name: val},
|
{Name: val},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
for _, v := range val {
|
for _, v := range val {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
if t == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
res = append(res, &Event{Name: t})
|
res = append(res, &Event{Name: t})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid type %T", t)
|
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))
|
res := make([]*Event, 0, len(events))
|
||||||
for i, k := range events {
|
for i, k := range events {
|
||||||
|
if k == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v := triggers[i]
|
v := triggers[i]
|
||||||
switch v.Kind {
|
switch v.Kind {
|
||||||
case yaml.ScalarNode:
|
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 {
|
for _, kase := range kases {
|
||||||
t.Run(kase.input, func(t *testing.T) {
|
t.Run(kase.input, func(t *testing.T) {
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ func TestIsWorkflow(t *testing.T) {
|
|||||||
path: ".gitea/workflows2/test.yml",
|
path: ".gitea/workflows2/test.yml",
|
||||||
expected: false,
|
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",
|
name: "unrelated path",
|
||||||
dirs: []string{".gitea/workflows", ".github/workflows"},
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
|
|||||||
// GetLastCommitForPaths returns last commit information
|
// GetLastCommitForPaths returns last commit information
|
||||||
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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()
|
entryPaths[i] = entry.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !gogit
|
||||||
|
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,10 +20,8 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"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
|
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
|
||||||
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
|
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
|
||||||
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
|
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand()
|
cmd := gitcmd.NewCommand()
|
||||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
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)
|
ctx, ctxCancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
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)
|
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||||
|
return &logNameStatusRepoParser{
|
||||||
return bufReader, func() {
|
treepath: treepath,
|
||||||
ctxCancel()
|
paths: paths,
|
||||||
stdoutReaderClose()
|
rd: bufReader,
|
||||||
|
close: func() {
|
||||||
|
ctxCancel()
|
||||||
|
stdoutReaderClose()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||||
type LogNameStatusRepoParser struct {
|
type logNameStatusRepoParser struct {
|
||||||
treepath string
|
treepath string
|
||||||
paths []string
|
paths []string
|
||||||
next []byte
|
next []byte
|
||||||
buffull bool
|
buffull bool
|
||||||
rd *bufio.Reader
|
rd *bufio.Reader
|
||||||
cancel func()
|
close func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
// logNameStatusCommitData represents a commit artifact from git log raw
|
||||||
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
|
type logNameStatusCommitData struct {
|
||||||
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 {
|
|
||||||
CommitID string
|
CommitID string
|
||||||
ParentIDs []string
|
ParentIDs []string
|
||||||
Paths []bool
|
Paths []bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next returns the next LogStatusCommitData
|
// walkNext returns the next LogStatusCommitData
|
||||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
|
||||||
var err error
|
var err error
|
||||||
if len(g.next) == 0 {
|
if len(g.next) == 0 {
|
||||||
g.buffull = false
|
g.buffull = false
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := LogNameStatusCommitData{}
|
ret := logNameStatusCommitData{}
|
||||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +258,10 @@ diffloop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the parser
|
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
|
||||||
func (g *LogNameStatusRepoParser) Close() {
|
|
||||||
g.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
// 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) {
|
func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||||
headRef := head.ID.String()
|
headRef := head.ID.String()
|
||||||
|
|
||||||
tree, err := head.SubTree(treepath)
|
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...)
|
g := logNameStatusRepo(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
|
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
|
||||||
defer func() {
|
defer func() { g.close() }()
|
||||||
g.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
results := make([]string, len(paths))
|
results := make([]string, len(paths))
|
||||||
remaining := len(paths)
|
remaining := len(paths)
|
||||||
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
|||||||
|
|
||||||
heaploop:
|
heaploop:
|
||||||
for {
|
for {
|
||||||
select {
|
if walkGitLogDebugBeforeNext != nil {
|
||||||
case <-ctx.Done():
|
walkGitLogDebugBeforeNext()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
break heaploop
|
|
||||||
}
|
|
||||||
g.Close()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
|
||||||
if err != nil {
|
if ctx.Err() != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
|
||||||
break heaploop
|
} else if errors.Is(err, io.EOF) {
|
||||||
}
|
break heaploop // reached to the end of log output
|
||||||
g.Close()
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err // other unknown errors
|
||||||
}
|
|
||||||
if current == nil {
|
|
||||||
break heaploop
|
|
||||||
}
|
}
|
||||||
parentRemaining.Remove(current.CommitID)
|
parentRemaining.Remove(current.CommitID)
|
||||||
for i, found := range current.Paths {
|
for i, found := range current.Paths {
|
||||||
@@ -395,14 +366,14 @@ heaploop:
|
|||||||
if remaining <= nextRestart {
|
if remaining <= nextRestart {
|
||||||
commitSinceNextRestart++
|
commitSinceNextRestart++
|
||||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||||
g.Close()
|
|
||||||
remainingPaths := make([]string, 0, len(paths))
|
remainingPaths := make([]string, 0, len(paths))
|
||||||
for i, pth := range paths {
|
for i, pth := range paths {
|
||||||
if results[i] == "" {
|
if results[i] == "" {
|
||||||
remainingPaths = append(remainingPaths, pth)
|
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])
|
parentRemaining = make(container.Set[string])
|
||||||
nextRestart = (remaining * 3) / 4
|
nextRestart = (remaining * 3) / 4
|
||||||
continue heaploop
|
continue heaploop
|
||||||
@@ -410,7 +381,6 @@ heaploop:
|
|||||||
}
|
}
|
||||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||||
}
|
}
|
||||||
g.Close()
|
|
||||||
|
|
||||||
resultsMap := map[string]string{}
|
resultsMap := map[string]string{}
|
||||||
for i, pth := range paths {
|
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")
|
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 {
|
if opts.SkipTLSVerify {
|
||||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, isEmpty)
|
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"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostMatchList is used to check if a host or IP is in a list.
|
// HostMatchList is used to check if a host or IP is in a list.
|
||||||
@@ -23,10 +24,61 @@ type HostMatchList struct {
|
|||||||
ipNets []*net.IPNet
|
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"
|
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"
|
const MatchBuiltinPrivate = "private"
|
||||||
|
|
||||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
// 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
|
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, "*") {
|
if slices.Contains(hl.patterns, "*") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, builtin := range hl.builtins {
|
for _, builtin := range hl.builtins {
|
||||||
switch builtin {
|
switch builtin {
|
||||||
case MatchBuiltinExternal:
|
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
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinPrivate:
|
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
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinLoopback:
|
case MatchBuiltinLoopback:
|
||||||
@@ -135,7 +191,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if ip := net.ParseIP(hostname); ip != nil {
|
if ip := net.ParseIP(hostname); ip != nil {
|
||||||
return hl.checkIP(ip)
|
return hl.matchesIP(ip)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -146,7 +202,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
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
|
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
||||||
|
|||||||
@@ -159,3 +159,60 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
test(cases)
|
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,
|
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
|
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||||
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
|
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||||
// which changes every text node into a link to the passed default link.
|
|
||||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||||
procs := []processor{
|
procs := []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
fullHashPatternProcessor,
|
fullHashPatternProcessor,
|
||||||
linkProcessor,
|
|
||||||
mentionProcessor,
|
mentionProcessor,
|
||||||
issueIndexPatternProcessor,
|
issueIndexPatternProcessor,
|
||||||
commitCrossReferencePatternProcessor,
|
commitCrossReferencePatternProcessor,
|
||||||
@@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
|||||||
emojiShortCodeProcessor,
|
emojiShortCodeProcessor,
|
||||||
emojiProcessor,
|
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) {
|
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||||
node.Type = html.ElementNode
|
node.Type = html.ElementNode
|
||||||
|
|||||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
|||||||
var depends strings.Builder
|
var depends strings.Builder
|
||||||
var control 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() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
|
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if trimmed == "" {
|
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' {
|
if line[0] == ' ' || line[0] == '\t' {
|
||||||
switch key {
|
switch key {
|
||||||
case "Description":
|
case "Description":
|
||||||
|
|||||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
|||||||
assert.NotNil(t, p)
|
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/log"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
@@ -197,32 +198,38 @@ func loadLoginNotificationFrom(cfg ConfigProvider) {
|
|||||||
|
|
||||||
func loadRunModeFrom(rootCfg ConfigProvider) {
|
func loadRunModeFrom(rootCfg ConfigProvider) {
|
||||||
rootSec := rootCfg.Section("")
|
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())
|
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.
|
// 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.
|
// 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")
|
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config
|
||||||
unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
|
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var
|
||||||
RunMode = os.Getenv("GITEA_RUN_MODE")
|
|
||||||
if RunMode == "" {
|
|
||||||
RunMode = rootSec.Key("RUN_MODE").MustString("prod")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
if !allowRunAsRoot {
|
||||||
RunMode = strings.ToLower(RunMode)
|
// Special thanks to VLC which inspired the wording of this messaging.
|
||||||
if RunMode != "dev" {
|
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.")
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
|
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.
|
// 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"`
|
Color string `json:"color"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ClosesIssue bool `json:"closes_issue"`
|
ClosesIssue bool `json:"closes_issue"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// IssuePriorityDef represents an org-level issue priority definition
|
||||||
// swagger:model
|
// swagger:model
|
||||||
type IssuePriorityDef struct {
|
type IssuePriorityDef struct {
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ type BranchProtection struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
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"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
@@ -93,7 +99,13 @@ type CreateBranchProtectionOption struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
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"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
@@ -129,7 +141,13 @@ type EditBranchProtectionOption struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"`
|
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"`
|
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
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"`
|
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
|
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
||||||
type CreateOAuth2ApplicationOptions struct {
|
type CreateOAuth2ApplicationOptions struct {
|
||||||
// The name of the OAuth2 application
|
// 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))
|
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) {
|
t.Run("RenderIssueTitle", func(t *testing.T) {
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||||
expected := ` space @mention-user<SPACE><SPACE>
|
expected := ` space @mention-user<SPACE><SPACE>
|
||||||
|
|||||||
@@ -855,6 +855,8 @@
|
|||||||
"settings.access_token_deletion_confirm_action": "Delete",
|
"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.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.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.repo_and_org_access": "Repository and Organization Access",
|
||||||
"settings.permissions_public_only": "Public only",
|
"settings.permissions_public_only": "Public only",
|
||||||
"settings.permissions_access_all": "All (public, private, and limited)",
|
"settings.permissions_access_all": "All (public, private, and limited)",
|
||||||
@@ -2437,6 +2439,17 @@
|
|||||||
"repo.settings.protect_force_push_allowlist_teams": "Allowlisted teams for force pushing:",
|
"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_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.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": "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_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:",
|
"repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:",
|
||||||
@@ -2996,6 +3009,11 @@
|
|||||||
"org.settings.issue_status_created": "Issue status created.",
|
"org.settings.issue_status_created": "Issue status created.",
|
||||||
"org.settings.issue_status_updated": "Issue status updated.",
|
"org.settings.issue_status_updated": "Issue status updated.",
|
||||||
"org.settings.issue_status_deleted": "Issue status deleted.",
|
"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": "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_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.",
|
"org.settings.issue_priorities_empty": "No custom issue priorities defined yet.",
|
||||||
|
|||||||
@@ -261,16 +261,32 @@ func (s *Service) UpdateLog(
|
|||||||
}
|
}
|
||||||
ack := task.LogLength
|
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
|
res.Msg.AckIndex = ack
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.LogInStorage {
|
// Bail unless we have new rows or a NoMore to finalize. Even with
|
||||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
// 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)
|
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
|
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
|
||||||
|
|||||||
+103
-37
@@ -294,6 +294,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
|
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
|
||||||
return
|
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) {
|
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
if ctx.IsUserSiteAdmin() {
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
return
|
if !ok || privileged {
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,7 +1045,9 @@ func Routes() *web.Router {
|
|||||||
m.Group("/tokens", func() {
|
m.Group("/tokens", func() {
|
||||||
m.Combo("").Get(user.ListAccessTokens).
|
m.Combo("").Get(user.ListAccessTokens).
|
||||||
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
|
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())
|
}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
|
||||||
|
|
||||||
m.Get("/activities/feeds", user.ListUserActivityFeeds)
|
m.Get("/activities/feeds", user.ListUserActivityFeeds)
|
||||||
@@ -1257,6 +1300,23 @@ func Routes() *web.Router {
|
|||||||
})
|
})
|
||||||
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
||||||
}, reqToken(), reqAdmin())
|
}, 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.Group("/tags", func() {
|
||||||
m.Get("", repo.ListTags)
|
m.Get("", repo.ListTags)
|
||||||
m.Get("/*", repo.GetTag)
|
m.Get("/*", repo.GetTag)
|
||||||
@@ -1314,6 +1374,7 @@ func Routes() *web.Router {
|
|||||||
m.Get("/revisions/*", repo.ListPageRevisions)
|
m.Get("/revisions/*", repo.ListPageRevisions)
|
||||||
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
||||||
m.Get("/pages", repo.ListWikiPages)
|
m.Get("/pages", repo.ListWikiPages)
|
||||||
|
m.Get("/search", repo.SearchWikiPages)
|
||||||
}, mustEnableWiki)
|
}, mustEnableWiki)
|
||||||
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
||||||
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
||||||
@@ -1739,7 +1800,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||||
})
|
}, reqOrgVisible())
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Combo("").Get(org.ListHooks).
|
m.Combo("").Get(org.ListHooks).
|
||||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||||
@@ -1776,6 +1837,11 @@ func Routes() *web.Router {
|
|||||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||||
})
|
})
|
||||||
m.Get("/issue-statuses", org.ListIssueStatuses)
|
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-priorities", org.ListIssuePriorities)
|
||||||
m.Get("/issue-types", org.ListIssueTypes)
|
m.Get("/issue-types", org.ListIssueTypes)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||||
@@ -1793,12 +1859,12 @@ func Routes() *web.Router {
|
|||||||
m.Group("/repos", func() {
|
m.Group("/repos", func() {
|
||||||
m.Get("", reqToken(), org.GetTeamRepos)
|
m.Get("", reqToken(), org.GetTeamRepos)
|
||||||
m.Combo("/{org}/{reponame}").
|
m.Combo("/{org}/{reponame}").
|
||||||
Put(reqToken(), org.AddTeamRepository).
|
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||||
Delete(reqToken(), org.RemoveTeamRepository).
|
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
|
||||||
Get(reqToken(), org.GetTeamRepo)
|
Get(reqToken(), org.GetTeamRepo)
|
||||||
})
|
})
|
||||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
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("/admin", func() {
|
||||||
m.Group("/cron", func() {
|
m.Group("/cron", func() {
|
||||||
@@ -1891,7 +1957,7 @@ func Routes() *web.Router {
|
|||||||
|
|
||||||
// Authenticated license detail
|
// Authenticated license detail
|
||||||
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
||||||
})
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryLicensing))
|
||||||
}, sudo())
|
}, sudo())
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -207,7 +207,10 @@ func UpdateLicense(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
if len(cols) > 0 {
|
if len(cols) > 0 {
|
||||||
cols = append(cols, "updated_at")
|
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))
|
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
|
||||||
@@ -399,7 +402,10 @@ func UpdateTier(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(cols) > 0 {
|
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))
|
ctx.JSON(http.StatusOK, tierToJSON(tier))
|
||||||
@@ -427,7 +433,10 @@ func DeleteTier(ctx *context.APIContext) {
|
|||||||
return
|
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)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,26 @@ package org
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
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"
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkOrgVisibility returns true if the current user can view org metadata.
|
||||||
|
// Public orgs are visible to everyone. Private/limited orgs require authentication.
|
||||||
|
func checkOrgVisibility(ctx *context.APIContext) bool {
|
||||||
|
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ctx.Doer == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssueStatuses returns active issue status definitions for an org.
|
// ListIssueStatuses returns active issue status definitions for an org.
|
||||||
func ListIssueStatuses(ctx *context.APIContext) {
|
func ListIssueStatuses(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
||||||
@@ -34,6 +49,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -47,6 +66,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
Color: d.Color,
|
Color: d.Color,
|
||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
ClosesIssue: d.ClosesIssue,
|
ClosesIssue: d.ClosesIssue,
|
||||||
|
IsRequired: d.IsRequired,
|
||||||
SortOrder: d.SortOrder,
|
SortOrder: d.SortOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,6 +96,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -118,6 +142,10 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -136,3 +164,124 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, result)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1081,6 +1081,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
} else if errors.Is(err, util.ErrPermissionDenied) {
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.APIError(http.StatusForbidden, err)
|
ctx.APIError(http.StatusForbidden, err)
|
||||||
|
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
} else {
|
} else {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -693,6 +693,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteAllowlistUsers, err := user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if user_model.IsErrUserNotExist(err) {
|
if user_model.IsErrUserNotExist(err) {
|
||||||
@@ -711,7 +720,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -731,6 +740,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false)
|
||||||
|
if err != nil {
|
||||||
|
if organization.IsErrTeamNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if organization.IsErrTeamNotExist(err) {
|
if organization.IsErrTeamNotExist(err) {
|
||||||
@@ -763,6 +781,10 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
||||||
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
|
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
|
||||||
ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser,
|
ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser,
|
||||||
|
CanDelete: form.EnableDelete,
|
||||||
|
EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist,
|
||||||
|
DeleteAllowlistDeployKeys: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistDeployKeys,
|
||||||
|
DeleteAllowlistActionsUser: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistActionsUser,
|
||||||
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
||||||
MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser,
|
MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser,
|
||||||
EnableStatusCheck: form.EnableStatusCheck,
|
EnableStatusCheck: form.EnableStatusCheck,
|
||||||
@@ -785,6 +807,8 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
TeamIDs: whitelistTeams,
|
TeamIDs: whitelistTeams,
|
||||||
ForcePushUserIDs: forcePushAllowlistUsers,
|
ForcePushUserIDs: forcePushAllowlistUsers,
|
||||||
ForcePushTeamIDs: forcePushAllowlistTeams,
|
ForcePushTeamIDs: forcePushAllowlistTeams,
|
||||||
|
DeleteUserIDs: deleteAllowlistUsers,
|
||||||
|
DeleteTeamIDs: deleteAllowlistTeams,
|
||||||
MergeUserIDs: mergeWhitelistUsers,
|
MergeUserIDs: mergeWhitelistUsers,
|
||||||
MergeTeamIDs: mergeWhitelistTeams,
|
MergeTeamIDs: mergeWhitelistTeams,
|
||||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||||
@@ -911,6 +935,32 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.EnableDelete != nil {
|
||||||
|
if !*form.EnableDelete {
|
||||||
|
protectBranch.CanDelete = false
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
} else {
|
||||||
|
protectBranch.CanDelete = true
|
||||||
|
if form.EnableDeleteAllowlist != nil {
|
||||||
|
if !*form.EnableDeleteAllowlist {
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
} else {
|
||||||
|
protectBranch.EnableDeleteAllowlist = true
|
||||||
|
if form.DeleteAllowlistDeployKeys != nil {
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = *form.DeleteAllowlistDeployKeys
|
||||||
|
}
|
||||||
|
if form.DeleteAllowlistActionsUser != nil {
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = *form.DeleteAllowlistActionsUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if form.Priority != nil {
|
if form.Priority != nil {
|
||||||
protectBranch.Priority = *form.Priority
|
protectBranch.Priority = *form.Priority
|
||||||
}
|
}
|
||||||
@@ -977,7 +1027,7 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
|
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
|
var whitelistUsers, forcePushAllowlistUsers, deleteAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
|
||||||
if form.PushWhitelistUsernames != nil {
|
if form.PushWhitelistUsernames != nil {
|
||||||
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
|
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1004,6 +1054,19 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
} else {
|
} else {
|
||||||
forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs
|
forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs
|
||||||
}
|
}
|
||||||
|
if form.DeleteAllowlistUsernames != nil {
|
||||||
|
deleteAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteAllowlistUsers = protectBranch.DeleteAllowlistUserIDs
|
||||||
|
}
|
||||||
if form.MergeWhitelistUsernames != nil {
|
if form.MergeWhitelistUsernames != nil {
|
||||||
mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1031,7 +1094,7 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
|
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
if form.PushWhitelistTeams != nil {
|
if form.PushWhitelistTeams != nil {
|
||||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||||
@@ -1059,6 +1122,19 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
} else {
|
} else {
|
||||||
forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs
|
forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs
|
||||||
}
|
}
|
||||||
|
if form.DeleteAllowlistTeams != nil {
|
||||||
|
deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false)
|
||||||
|
if err != nil {
|
||||||
|
if organization.IsErrTeamNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteAllowlistTeams = protectBranch.DeleteAllowlistTeamIDs
|
||||||
|
}
|
||||||
if form.MergeWhitelistTeams != nil {
|
if form.MergeWhitelistTeams != nil {
|
||||||
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1092,6 +1168,8 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
TeamIDs: whitelistTeams,
|
TeamIDs: whitelistTeams,
|
||||||
ForcePushUserIDs: forcePushAllowlistUsers,
|
ForcePushUserIDs: forcePushAllowlistUsers,
|
||||||
ForcePushTeamIDs: forcePushAllowlistTeams,
|
ForcePushTeamIDs: forcePushAllowlistTeams,
|
||||||
|
DeleteUserIDs: deleteAllowlistUsers,
|
||||||
|
DeleteTeamIDs: deleteAllowlistTeams,
|
||||||
MergeUserIDs: mergeWhitelistUsers,
|
MergeUserIDs: mergeWhitelistUsers,
|
||||||
MergeTeamIDs: mergeWhitelistTeams,
|
MergeTeamIDs: mergeWhitelistTeams,
|
||||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||||
@@ -1287,6 +1365,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
|||||||
} else if errors.Is(err, util.ErrNotExist) {
|
} else if errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
return
|
return
|
||||||
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
|
ctx.APIError(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toCascadeRuleAPI(rule *repo_model.CascadeMergeRule) *api.CascadeMergeRule {
|
||||||
|
return &api.CascadeMergeRule{
|
||||||
|
ID: rule.ID,
|
||||||
|
SourceBranch: rule.SourceBranch,
|
||||||
|
TargetBranch: rule.TargetBranch,
|
||||||
|
Enabled: rule.Enabled,
|
||||||
|
AutoMerge: rule.AutoMerge,
|
||||||
|
CreatedAt: rule.CreatedUnix.AsTime(),
|
||||||
|
UpdatedAt: rule.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListCascadeRules(ctx *context.APIContext) {
|
||||||
|
rules, err := repo_model.GetCascadeRulesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiRules := make([]*api.CascadeMergeRule, len(rules))
|
||||||
|
for i, rule := range rules {
|
||||||
|
apiRules[i] = toCascadeRuleAPI(rule)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, apiRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCascadeRule(ctx *context.APIContext) {
|
||||||
|
var req api.CreateCascadeMergeRuleOption
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch == "" || req.TargetBranch == "" {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch == req.TargetBranch {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := &repo_model.CascadeMergeRule{
|
||||||
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
SourceBranch: req.SourceBranch,
|
||||||
|
TargetBranch: req.TargetBranch,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
rule.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.AutoMerge != nil {
|
||||||
|
rule.AutoMerge = *req.AutoMerge
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.CreateCascadeRule(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req api.EditCascadeMergeRuleOption
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch != nil {
|
||||||
|
rule.SourceBranch = *req.SourceBranch
|
||||||
|
}
|
||||||
|
if req.TargetBranch != nil {
|
||||||
|
rule.TargetBranch = *req.TargetBranch
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
rule.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.AutoMerge != nil {
|
||||||
|
rule.AutoMerge = *req.AutoMerge
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.SourceBranch == rule.TargetBranch {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.UpdateCascadeRule(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.DeleteCascadeRule(ctx, ctx.Repo.Repository.ID, rule.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -32,6 +32,16 @@ type apiMetadata struct {
|
|||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
ExtensionType string `json:"extension_type"`
|
ExtensionType string `json:"extension_type"`
|
||||||
EntryPoint string `json:"entry_point"`
|
EntryPoint string `json:"entry_point"`
|
||||||
|
|
||||||
|
// deploy
|
||||||
|
DeployHost string `json:"deploy_host,omitempty"`
|
||||||
|
DeployPort string `json:"deploy_port,omitempty"`
|
||||||
|
DeployUser string `json:"deploy_user,omitempty"`
|
||||||
|
DeployPath string `json:"deploy_path,omitempty"`
|
||||||
|
DockerImage string `json:"docker_image,omitempty"`
|
||||||
|
DockerRegistry string `json:"docker_registry,omitempty"`
|
||||||
|
ContainerName string `json:"container_name,omitempty"`
|
||||||
|
HealthURL string `json:"health_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoMetadata returns the manifest settings for a repository.
|
// GetRepoMetadata returns the manifest settings for a repository.
|
||||||
@@ -81,6 +91,14 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,35 +114,59 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/Manifest"
|
// "$ref": "#/responses/Manifest"
|
||||||
var req apiMetadata
|
// Decode into a map to detect which fields were actually sent.
|
||||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
var raw map[string]any
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
|
||||||
ctx.APIError(http.StatusBadRequest, err)
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &repo_model.RepoMetadata{
|
// Load existing metadata (or create defaults).
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
m, _ := repo_model.GetRepoMetadata(ctx, ctx.Repo.Repository.ID)
|
||||||
Name: req.Name,
|
if m == nil {
|
||||||
Org: req.Org,
|
m = &repo_model.RepoMetadata{
|
||||||
Description: req.Description,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
Name: ctx.Repo.Repository.Name,
|
||||||
LicenseSPDX: req.LicenseSPDX,
|
Org: ctx.Repo.Repository.OwnerName,
|
||||||
LicenseName: req.LicenseName,
|
Description: ctx.Repo.Repository.Description,
|
||||||
VersionPrefix: req.VersionPrefix,
|
}
|
||||||
ElementName: req.ElementName,
|
|
||||||
Platform: req.Platform,
|
|
||||||
StandardsVersion: req.StandardsVersion,
|
|
||||||
StandardsSource: req.StandardsSource,
|
|
||||||
Maintainer: req.Maintainer,
|
|
||||||
MaintainerURL: req.MaintainerURL,
|
|
||||||
InfoURL: req.InfoURL,
|
|
||||||
TargetVersion: req.TargetVersion,
|
|
||||||
PHPMinimum: req.PHPMinimum,
|
|
||||||
Language: req.Language,
|
|
||||||
ExtensionType: req.ExtensionType,
|
|
||||||
EntryPoint: req.EntryPoint,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply only the fields present in the request.
|
||||||
|
setStr := func(key string, target *string) {
|
||||||
|
if v, ok := raw[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
*target = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStr("name", &m.Name)
|
||||||
|
setStr("org", &m.Org)
|
||||||
|
setStr("description", &m.Description)
|
||||||
|
setStr("license_spdx", &m.LicenseSPDX)
|
||||||
|
setStr("license_name", &m.LicenseName)
|
||||||
|
setStr("version_prefix", &m.VersionPrefix)
|
||||||
|
setStr("element_name", &m.ElementName)
|
||||||
|
setStr("platform", &m.Platform)
|
||||||
|
setStr("standards_version", &m.StandardsVersion)
|
||||||
|
setStr("standards_source", &m.StandardsSource)
|
||||||
|
setStr("maintainer", &m.Maintainer)
|
||||||
|
setStr("maintainer_url", &m.MaintainerURL)
|
||||||
|
setStr("info_url", &m.InfoURL)
|
||||||
|
setStr("target_version", &m.TargetVersion)
|
||||||
|
setStr("php_minimum", &m.PHPMinimum)
|
||||||
|
setStr("language", &m.Language)
|
||||||
|
setStr("extension_type", &m.ExtensionType)
|
||||||
|
setStr("entry_point", &m.EntryPoint)
|
||||||
|
setStr("deploy_host", &m.DeployHost)
|
||||||
|
setStr("deploy_port", &m.DeployPort)
|
||||||
|
setStr("deploy_user", &m.DeployUser)
|
||||||
|
setStr("deploy_path", &m.DeployPath)
|
||||||
|
setStr("docker_image", &m.DockerImage)
|
||||||
|
setStr("docker_registry", &m.DockerRegistry)
|
||||||
|
setStr("container_name", &m.ContainerName)
|
||||||
|
setStr("health_url", &m.HealthURL)
|
||||||
|
|
||||||
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -151,5 +193,13 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiSecurityAlert struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Scanner string `json:"scanner"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RuleID string `json:"rule_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
LineNumber int `json:"line_number,omitempty"`
|
||||||
|
CommitSHA string `json:"commit_sha,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiSecurityConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BlockOnPush bool `json:"block_on_push"`
|
||||||
|
SecretScanner bool `json:"secret_scanner"`
|
||||||
|
DependScanner bool `json:"depend_scanner"`
|
||||||
|
CodeScanner bool `json:"code_scanner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSecurityAlerts returns all security alerts for a repo.
|
||||||
|
func ListSecurityAlerts(ctx *context.APIContext) {
|
||||||
|
status := ctx.FormString("status")
|
||||||
|
repoID := ctx.Repo.Repository.ID
|
||||||
|
|
||||||
|
var alerts []*security_model.SecurityAlert
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "", "active":
|
||||||
|
alerts, err = security_model.GetActiveAlerts(ctx, repoID)
|
||||||
|
default:
|
||||||
|
alerts, err = security_model.GetAllAlerts(ctx, repoID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*apiSecurityAlert, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
result[i] = toAPISecurityAlert(a)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecurityAlert returns a single security alert.
|
||||||
|
func GetSecurityAlert(ctx *context.APIContext) {
|
||||||
|
id := ctx.PathParamInt64("id")
|
||||||
|
alert, err := security_model.GetAlertByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityAlert changes the status of a security alert.
|
||||||
|
func UpdateSecurityAlert(ctx *context.APIContext) {
|
||||||
|
id := ctx.PathParamInt64("id")
|
||||||
|
alert, err := security_model.GetAlertByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := security_model.AlertStatus(req.Status)
|
||||||
|
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "status must be 'resolved' or 'dismissed'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alert, _ = security_model.GetAlertByID(ctx, id)
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSecurityScan runs all enabled scanners against HEAD.
|
||||||
|
func TriggerSecurityScan(ctx *context.APIContext) {
|
||||||
|
commit := ctx.Repo.Commit
|
||||||
|
if commit == nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "no commits in repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
|
||||||
|
|
||||||
|
alerts, err := security_model.GetActiveAlerts(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*apiSecurityAlert, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
result[i] = toAPISecurityAlert(a)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecurityConfig returns the scanner config for a repo.
|
||||||
|
func GetSecurityConfig(ctx *context.APIContext) {
|
||||||
|
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityConfig updates the scanner config for a repo.
|
||||||
|
func UpdateSecurityConfig(ctx *context.APIContext) {
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
BlockOnPush *bool `json:"block_on_push"`
|
||||||
|
SecretScanner *bool `json:"secret_scanner"`
|
||||||
|
DependScanner *bool `json:"depend_scanner"`
|
||||||
|
CodeScanner *bool `json:"code_scanner"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
cfg.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.BlockOnPush != nil {
|
||||||
|
cfg.BlockOnPush = *req.BlockOnPush
|
||||||
|
}
|
||||||
|
if req.SecretScanner != nil {
|
||||||
|
cfg.SecretScanner = *req.SecretScanner
|
||||||
|
}
|
||||||
|
if req.DependScanner != nil {
|
||||||
|
cfg.DependScanner = *req.DependScanner
|
||||||
|
}
|
||||||
|
if req.CodeScanner != nil {
|
||||||
|
cfg.CodeScanner = *req.CodeScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPISecurityAlert(a *security_model.SecurityAlert) *apiSecurityAlert {
|
||||||
|
return &apiSecurityAlert{
|
||||||
|
ID: a.ID,
|
||||||
|
Scanner: string(a.Scanner),
|
||||||
|
Severity: string(a.Severity),
|
||||||
|
Status: string(a.Status),
|
||||||
|
RuleID: a.RuleID,
|
||||||
|
Title: a.Title,
|
||||||
|
Description: a.Description,
|
||||||
|
FilePath: a.FilePath,
|
||||||
|
LineNumber: a.LineNumber,
|
||||||
|
CommitSHA: a.CommitSHA,
|
||||||
|
Fingerprint: a.Fingerprint,
|
||||||
|
CreatedAt: a.CreatedUnix.AsTime(),
|
||||||
|
UpdatedAt: a.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurityConfig {
|
||||||
|
return &apiSecurityConfig{
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
BlockOnPush: cfg.BlockOnPush,
|
||||||
|
SecretScanner: cfg.SecretScanner,
|
||||||
|
DependScanner: cfg.DependScanner,
|
||||||
|
CodeScanner: cfg.CodeScanner,
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
@@ -461,10 +462,148 @@ func ListPageRevisions(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchWikiPages searches wiki page titles and content.
|
||||||
|
func SearchWikiPages(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/wiki/search repository repoSearchWikiPages
|
||||||
|
// ---
|
||||||
|
// summary: Search wiki pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: q
|
||||||
|
// in: query
|
||||||
|
// description: search query
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: "SearchResults"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
query := strings.TrimSpace(ctx.FormString("q"))
|
||||||
|
if query == "" {
|
||||||
|
ctx.JSON(http.StatusOK, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiRepo, commit := findWikiRepoCommit(ctx)
|
||||||
|
if wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
}
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
type WikiSearchResult struct {
|
||||||
|
PageName string `json:"page_name"`
|
||||||
|
PageURL string `json:"page_url"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := commit.ListEntriesRecursiveFast()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []WikiSearchResult
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsRegular() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseName := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
// Extract just the filename without path for special file check
|
||||||
|
parts := strings.Split(baseName, "/")
|
||||||
|
shortName := parts[len(parts)-1]
|
||||||
|
if shortName == "_Sidebar" || shortName == "_Footer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
titleMatch := strings.Contains(strings.ToLower(baseName), queryLower)
|
||||||
|
contentMatch := strings.Contains(strings.ToLower(content), queryLower)
|
||||||
|
|
||||||
|
if !titleMatch && !contentMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contextLine := ""
|
||||||
|
if contentMatch {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.Contains(strings.ToLower(line), queryLower) {
|
||||||
|
contextLine = strings.TrimSpace(line)
|
||||||
|
if len(contextLine) > 200 {
|
||||||
|
contextLine = contextLine[:200] + "..."
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
|
||||||
|
|
||||||
|
results = append(results, WikiSearchResult{
|
||||||
|
PageName: displayName,
|
||||||
|
PageURL: string(wikiName),
|
||||||
|
Context: contextLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = setting.API.DefaultPagingNum
|
||||||
|
}
|
||||||
|
total := len(results)
|
||||||
|
start := (page - 1) * limit
|
||||||
|
end := start + limit
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetLinkHeader(int64(total), limit)
|
||||||
|
ctx.SetTotalCountHeader(int64(total))
|
||||||
|
ctx.JSON(http.StatusOK, results[start:end])
|
||||||
|
}
|
||||||
|
|
||||||
// findEntryForFile finds the tree entry for a target filepath.
|
// findEntryForFile finds the tree entry for a target filepath.
|
||||||
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
||||||
entry, err := commit.GetTreeEntryByPath(target)
|
entry, err := commit.GetTreeEntryByPath(target)
|
||||||
if err != nil {
|
if err != nil && !git.IsErrNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
|
|||||||
@@ -209,6 +209,106 @@ func DeleteAccessToken(ctx *context.APIContext) {
|
|||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccessToken update access token scopes
|
||||||
|
func UpdateAccessToken(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /users/{username}/tokens/{id} user userUpdateAccessToken
|
||||||
|
// ---
|
||||||
|
// summary: Update an access token's scopes
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user whose token is to be updated
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the token to update
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditAccessTokenOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/AccessToken"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
tokenID, _ := strconv.ParseInt(ctx.PathParam("id"), 0, 64)
|
||||||
|
if tokenID == 0 {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
|
||||||
|
UserID: ctx.ContextUser.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token *auth_model.AccessToken
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t.ID == tokenID {
|
||||||
|
token = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditAccessTokenOption)
|
||||||
|
|
||||||
|
if form.Name == "" && len(form.Scopes) == 0 {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "must provide name or scopes to update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Name != "" {
|
||||||
|
token.Name = form.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(form.Scopes) > 0 {
|
||||||
|
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, fmt.Errorf("invalid access token scope: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scope == "" {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "access token must have a scope")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.Scope = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth_model.UpdateAccessToken(ctx, token); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.AccessToken{
|
||||||
|
ID: token.ID,
|
||||||
|
Name: token.Name,
|
||||||
|
TokenLastEight: token.TokenLastEight,
|
||||||
|
Scopes: token.Scope.StringSlice(),
|
||||||
|
Created: token.CreatedUnix.AsTime(),
|
||||||
|
Updated: token.UpdatedUnix.AsTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
||||||
func CreateOauth2Application(ctx *context.APIContext) {
|
func CreateOauth2Application(ctx *context.APIContext) {
|
||||||
// swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application
|
// swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cascade"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron"
|
||||||
feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed"
|
feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed"
|
||||||
indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer"
|
indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer"
|
||||||
@@ -153,6 +154,7 @@ func InitWebInstalled(ctx context.Context) {
|
|||||||
mustInit(webhook.Init)
|
mustInit(webhook.Init)
|
||||||
mustInit(pull_service.Init)
|
mustInit(pull_service.Init)
|
||||||
mustInit(automerge.Init)
|
mustInit(automerge.Init)
|
||||||
|
cascade.Init()
|
||||||
mustInit(task.Init)
|
mustInit(task.Init)
|
||||||
mustInit(repo_migrations.Init)
|
mustInit(repo_migrations.Init)
|
||||||
eventsource.GetManager().Init()
|
eventsource.GetManager().Init()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit"
|
||||||
gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||||
|
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
type preReceiveContext struct {
|
type preReceiveContext struct {
|
||||||
@@ -40,9 +41,6 @@ type preReceiveContext struct {
|
|||||||
canCreatePullRequest bool
|
canCreatePullRequest bool
|
||||||
checkedCanCreatePullRequest bool
|
checkedCanCreatePullRequest bool
|
||||||
|
|
||||||
canWriteCode bool
|
|
||||||
checkedCanWriteCode bool
|
|
||||||
|
|
||||||
protectedTags []*git_model.ProtectedTag
|
protectedTags []*git_model.ProtectedTag
|
||||||
gotProtectedTags bool
|
gotProtectedTags bool
|
||||||
|
|
||||||
@@ -50,24 +48,36 @@ type preReceiveContext struct {
|
|||||||
|
|
||||||
opts *private.HookOptions
|
opts *private.HookOptions
|
||||||
|
|
||||||
branchName string
|
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||||
|
canWriteCodeUnitCached *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWriteCode returns true if pusher can write code
|
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
if ctx.canWriteCodeUnitCached == nil {
|
||||||
if !ctx.checkedCanWriteCode {
|
var canWrite bool
|
||||||
if !ctx.loadPusherAndPermission() {
|
if ctx.loadPusherAndPermission() {
|
||||||
return false
|
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||||
}
|
}
|
||||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
ctx.canWriteCodeUnitCached = &canWrite
|
||||||
ctx.checkedCanWriteCode = true
|
|
||||||
}
|
}
|
||||||
return ctx.canWriteCode
|
return *ctx.canWriteCodeUnitCached
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssertCanWriteCode returns true if pusher can write code
|
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
|
||||||
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
|
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||||
if !ctx.CanWriteCode() {
|
if ctx.canWriteCodeUnit() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
|
||||||
|
if !refFullName.IsBranch() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||||
|
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||||
|
if !ctx.canWriteCodeRef(refFullName) {
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,7 +139,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
||||||
preReceiveFor(ourCtx, refFullName)
|
preReceiveFor(ourCtx, refFullName)
|
||||||
default:
|
default:
|
||||||
ourCtx.AssertCanWriteCode()
|
ourCtx.assertCanWriteRef(refFullName)
|
||||||
}
|
}
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
@@ -141,9 +151,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
|
|
||||||
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
||||||
branchName := refFullName.BranchName()
|
branchName := refFullName.BranchName()
|
||||||
ctx.branchName = branchName
|
|
||||||
|
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +160,25 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
gitRepo := ctx.Repo.GitRepo
|
gitRepo := ctx.Repo.GitRepo
|
||||||
objectFormat := ctx.Repo.GetObjectFormat()
|
objectFormat := ctx.Repo.GetObjectFormat()
|
||||||
|
|
||||||
|
if newCommitID != objectFormat.EmptyObjectID().String() {
|
||||||
|
newCommit, err := gitRepo.GetCommit(newCommitID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Secret scan: failed to get commit %s in %-v: %v", newCommitID[:12], repo, err)
|
||||||
|
} else {
|
||||||
|
if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 {
|
||||||
|
msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12])
|
||||||
|
for _, f := range findings {
|
||||||
|
msg += fmt.Sprintf("\n - %s in %s:%d", f.Title, f.FilePath, f.LineNumber)
|
||||||
|
}
|
||||||
|
log.Warn("Secret scan blocked push to %s in %-v: %d findings", branchName, repo, len(findings))
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: msg,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultBranch := repo.DefaultBranch
|
defaultBranch := repo.DefaultBranch
|
||||||
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
|
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
|
||||||
defaultBranch = repo.DefaultWikiBranch
|
defaultBranch = repo.DefaultWikiBranch
|
||||||
@@ -182,12 +210,29 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
//
|
//
|
||||||
// First of all we need to enforce absolutely:
|
// First of all we need to enforce absolutely:
|
||||||
//
|
//
|
||||||
// 1. Detect and prevent deletion of the branch
|
// 1. Detect and prevent deletion of the branch (unless user is in delete allowlist)
|
||||||
if newCommitID == objectFormat.EmptyObjectID().String() {
|
if newCommitID == objectFormat.EmptyObjectID().String() {
|
||||||
log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
|
canDelete := false
|
||||||
ctx.JSON(http.StatusForbidden, private.Response{
|
if ctx.opts.DeployKeyID != 0 {
|
||||||
UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
|
canDelete = protectBranch.CanDelete && (!protectBranch.EnableDeleteAllowlist || protectBranch.DeleteAllowlistDeployKeys)
|
||||||
})
|
} else {
|
||||||
|
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to GetUserByID for delete check in %-v: %v", repo, err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||||
|
Err: fmt.Sprintf("Unable to GetUserByID: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canDelete = protectBranch.CanUserDelete(ctx, user)
|
||||||
|
}
|
||||||
|
if !canDelete {
|
||||||
|
log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +449,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package private
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/contexttest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||||
|
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||||
|
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||||
|
// together with a protected branch or a tag to escalate into full repository write.
|
||||||
|
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||||
|
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||||
|
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||||
|
|
||||||
|
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||||
|
// repo owner write access to exactly this head branch and nothing else.
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: baseRepo.ID,
|
||||||
|
PosterID: headRepo.OwnerID,
|
||||||
|
},
|
||||||
|
HeadRepoID: headRepo.ID,
|
||||||
|
BaseRepoID: baseRepo.ID,
|
||||||
|
HeadBranch: "granted-branch",
|
||||||
|
BaseBranch: "master",
|
||||||
|
AllowMaintainerEdit: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||||
|
|
||||||
|
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||||
|
maintainer := baseRepo.Owner
|
||||||
|
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||||
|
ctx := &preReceiveContext{
|
||||||
|
PrivateContext: mockCtx,
|
||||||
|
loadedPusher: true,
|
||||||
|
user: maintainer,
|
||||||
|
userPerm: headPerm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The granted branch must be writable...
|
||||||
|
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||||
|
|
||||||
|
// ...but another branch in the same push must NOT inherit that grant.
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
|
||||||
|
|
||||||
|
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
|
||||||
|
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
|
||||||
|
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
|
||||||
|
}
|
||||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the passcode with the stored TOTP secret.
|
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
if ok {
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
u, err := user_model.GetUserByID(ctx, id)
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = form.Passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -368,9 +368,21 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
|
|
||||||
opts := &user_service.UpdateOptions{}
|
opts := &user_service.UpdateOptions{}
|
||||||
|
|
||||||
// Reactivate user if they are deactivated
|
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
|
||||||
|
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||||
|
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||||
|
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||||
|
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||||
if !u.IsActive {
|
if !u.IsActive {
|
||||||
opts.IsActive = optional.Some(true)
|
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetExternalLogin", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
|
||||||
|
if isDisabledByAutoSync {
|
||||||
|
opts.IsActive = optional.Some(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update GroupClaims
|
// Update GroupClaims
|
||||||
@@ -514,17 +526,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// search in external linked users
|
// search in external linked users
|
||||||
externalLoginUser := &user_model.ExternalLoginUser{
|
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
ExternalID: gothUser.UserID,
|
|
||||||
LoginSourceID: authSource.ID,
|
|
||||||
}
|
|
||||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return nil, goth.User{}, err
|
||||||
}
|
}
|
||||||
if hasUser {
|
if hasUser {
|
||||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
||||||
return user, gothUser, err
|
if err != nil && !user_model.IsErrUserNotExist(err) {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
|
if err == nil && user.IsIndividual() {
|
||||||
|
return user, gothUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The external login record is stale: the linked user no longer exists, or it exists but is
|
||||||
|
// not an individual user (only individual users can sign in, so a link pointing at an
|
||||||
|
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
|
||||||
|
// relink the external account to the correct user. Nothing is lost, because the link is
|
||||||
|
// recreated automatically on the next sign-in.
|
||||||
|
reason := "linked user does not exist"
|
||||||
|
if err == nil {
|
||||||
|
reason = fmt.Sprintf("linked user type is %d", user.Type)
|
||||||
|
}
|
||||||
|
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
|
||||||
|
|
||||||
|
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found to login
|
// no user found to login
|
||||||
|
|||||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
regenerateScratchToken = true
|
regenerateScratchToken = true
|
||||||
} else {
|
} else {
|
||||||
passcode := ctx.FormString("passcode")
|
passcode := ctx.FormString("passcode")
|
||||||
ok, err := twofa.ValidateTOTP(passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || twofa.LastUsedPasscode == passcode {
|
if !ok {
|
||||||
ctx.Data["IsResetForm"] = true
|
ctx.Data["IsResetForm"] = true
|
||||||
ctx.Data["Err_Passcode"] = true
|
ctx.Data["Err_Passcode"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var commits []*git.Commit
|
var commits []*git.Commit
|
||||||
var err error
|
var err error
|
||||||
if ctx.Repo.Commit != nil {
|
if ctx.Repo.Commit != nil {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
fileName := ctx.Repo.TreePath
|
fileName := ctx.Repo.TreePath
|
||||||
if len(fileName) == 0 {
|
if len(fileName) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
// shows tags and/or releases on the repo as RSS / Atom feed
|
// shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
IncludeTags: !isReleasesOnly,
|
IncludeTags: !isReleasesOnly,
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
|||||||
@@ -4,9 +4,18 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
|
||||||
|
// feed serves private repository content, mirroring checkDownloadTokenScope for
|
||||||
|
// downloads. Returns false (and writes the response) when the token is denied.
|
||||||
|
func checkRepoFeedTokenScope(ctx *context.Context) bool {
|
||||||
|
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
|
||||||
|
return !ctx.Written()
|
||||||
|
}
|
||||||
|
|
||||||
// RenderBranchFeed render format for branch or file
|
// RenderBranchFeed render format for branch or file
|
||||||
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
||||||
if ctx.Repo.TreePath == "" {
|
if ctx.Repo.TreePath == "" {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
||||||
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||||
RequestedRepo: repo,
|
RequestedRepo: repo,
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
|
|||||||
@@ -27,9 +27,35 @@ func SettingsIssueStatuses(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["IssueStatuses"] = defs
|
ctx.Data["IssueStatuses"] = defs
|
||||||
|
|
||||||
|
// Load preset names for the preset selector
|
||||||
|
presetNames := issues_model.StatusPresetNames()
|
||||||
|
type presetInfo struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
presets := make([]presetInfo, 0, len(presetNames))
|
||||||
|
for _, name := range presetNames {
|
||||||
|
p := issues_model.StatusPresets[name]
|
||||||
|
presets = append(presets, presetInfo{Name: p.Name, Description: p.Description})
|
||||||
|
}
|
||||||
|
ctx.Data["StatusPresets"] = presets
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, tplOrgIssueStatuses)
|
ctx.HTML(http.StatusOK, tplOrgIssueStatuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingsIssueStatusesApplyPresetPost applies a status preset to the org.
|
||||||
|
func SettingsIssueStatusesApplyPresetPost(ctx *context.Context) {
|
||||||
|
presetName := ctx.FormString("preset")
|
||||||
|
if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil {
|
||||||
|
ctx.Flash.Error("Unknown preset: " + presetName)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("org.settings.issue_status_preset_applied"))
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
}
|
||||||
|
|
||||||
// SettingsIssueStatusesCreatePost creates a new org-level issue status.
|
// SettingsIssueStatusesCreatePost creates a new org-level issue status.
|
||||||
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
||||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
@@ -103,6 +129,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
||||||
|
if issues_model.IsErrStatusRequired(err) {
|
||||||
|
ctx.Flash.Error("Cannot delete required status: " + def.Name)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("DeleteIssueStatusDef", err)
|
ctx.ServerError("DeleteIssueStatusDef", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
|
|||||||
SubURL string
|
SubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
|
||||||
|
type OrgWikiTreeNode struct {
|
||||||
|
Name string
|
||||||
|
SubURL string
|
||||||
|
IsDir bool
|
||||||
|
Children []*OrgWikiTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
// Wiki renders the org wiki tab.
|
// Wiki renders the org wiki tab.
|
||||||
func Wiki(ctx *context.Context) {
|
func Wiki(ctx *context.Context) {
|
||||||
org := ctx.Org.Organization
|
org := ctx.Org.Organization
|
||||||
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
||||||
|
|
||||||
// Build page list from repo root.
|
// Build folder tree for sidebar navigation.
|
||||||
entries, err := commit.ListEntries()
|
wikiTree := buildOrgWikiTree(commit)
|
||||||
if err != nil {
|
ctx.Data["WikiTree"] = wikiTree
|
||||||
ctx.ServerError("ListEntries", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pages := make([]OrgWikiPage, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if !isMarkdownFile(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
displayName := strings.TrimSuffix(name, path.Ext(name))
|
|
||||||
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages = append(pages, OrgWikiPage{
|
|
||||||
Name: displayName,
|
|
||||||
SubURL: displayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx.Data["Pages"] = pages
|
|
||||||
|
|
||||||
// Determine which page to render.
|
// Determine which page to render.
|
||||||
pageName := ctx.PathParamRaw("*")
|
pageName := ctx.PathParamRaw("*")
|
||||||
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
|
|||||||
ctx.HTML(http.StatusOK, tplOrgWiki)
|
ctx.HTML(http.StatusOK, tplOrgWiki)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
|
||||||
|
// Shows up to 2 levels deep (folders and their immediate children).
|
||||||
|
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevel []*OrgWikiTreeNode
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
node := &OrgWikiTreeNode{
|
||||||
|
Name: name,
|
||||||
|
SubURL: name,
|
||||||
|
IsDir: true,
|
||||||
|
}
|
||||||
|
// List children of this directory (1 level deep).
|
||||||
|
subTree := entry.Tree()
|
||||||
|
if subTree != nil {
|
||||||
|
children, _ := subTree.ListEntries()
|
||||||
|
for _, child := range children {
|
||||||
|
childName := child.Name()
|
||||||
|
if child.IsDir() {
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: childName,
|
||||||
|
SubURL: name + "/" + childName,
|
||||||
|
IsDir: true,
|
||||||
|
})
|
||||||
|
} else if isMarkdownFile(childName) {
|
||||||
|
displayName := strings.TrimSuffix(childName, path.Ext(childName))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: name + "/" + displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, node)
|
||||||
|
} else if isMarkdownFile(name) {
|
||||||
|
displayName := strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topLevel
|
||||||
|
}
|
||||||
|
|
||||||
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
||||||
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
||||||
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func MergeUpstream(ctx *context.Context) {
|
|||||||
branchName := ctx.FormString("branch")
|
branchName := ctx.FormString("branch")
|
||||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.JSONErrorNotFound()
|
ctx.JSONErrorNotFound()
|
||||||
return
|
return
|
||||||
} else if pull_service.IsErrMergeConflicts(err) {
|
} else if pull_service.IsErrMergeConflicts(err) {
|
||||||
|
|||||||
+19
-28
@@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
|
|||||||
// httpBase does the common work for git http services,
|
// httpBase does the common work for git http services,
|
||||||
// including early response, authentication, repository lookup and permission check.
|
// including early response, authentication, repository lookup and permission check.
|
||||||
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||||
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
|
||||||
|
|
||||||
if ctx.FormString("go-get") == "1" {
|
if ctx.FormString("go-get") == "1" {
|
||||||
context.EarlyResponseForGoGetMeta(ctx)
|
context.EarlyResponseForGoGetMeta(ctx)
|
||||||
return nil
|
return nil
|
||||||
@@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
|
|
||||||
isWiki := false
|
isWiki := false
|
||||||
unitType := unit.TypeCode
|
unitType := unit.TypeCode
|
||||||
|
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||||
if strings.HasSuffix(reponame, ".wiki") {
|
if strings.HasSuffix(repoName, ".wiki") {
|
||||||
isWiki = true
|
isWiki = true
|
||||||
unitType = unit.TypeWiki
|
unitType = unit.TypeWiki
|
||||||
reponame = reponame[:len(reponame)-5]
|
repoName = repoName[:len(repoName)-5]
|
||||||
}
|
}
|
||||||
|
|
||||||
owner := ctx.ContextUser
|
owner := ctx.ContextUser
|
||||||
@@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repoExist := true
|
repoExist := true
|
||||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
|
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !repo_model.IsErrRepoNotExist(err) {
|
if !repo_model.IsErrRepoNotExist(err) {
|
||||||
ctx.ServerError("GetRepositoryByName", err)
|
ctx.ServerError("GetRepositoryByName", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil {
|
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil {
|
||||||
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -127,31 +125,24 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only public pull don't need auth.
|
// Only public pulls don't need auth: repo must exist, not require-sign-in
|
||||||
// For private repos, also allow anonymous pull if the specific unit
|
canAnonymousPull := false
|
||||||
// (code or wiki) has AnonymousAccessMode >= Read.
|
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
|
||||||
isPublicPull := repoExist && isPull && !repo.IsPrivate
|
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
|
||||||
if repoExist && isPull && repo.IsPrivate {
|
canAnonymousPull = true
|
||||||
repoUnit := repo.MustGetUnit(ctx, unitType)
|
|
||||||
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
|
|
||||||
isPublicPull = true
|
|
||||||
}
|
}
|
||||||
}
|
if !canAnonymousPull && ctx.Doer == nil {
|
||||||
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
|
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
|
||||||
|
if err != nil {
|
||||||
// don't allow anonymous pulls if organization is not public
|
ctx.ServerError("GetDoerRepoPermission", err)
|
||||||
if isPublicPull {
|
return nil
|
||||||
if err := repo.LoadOwner(ctx); err != nil {
|
}
|
||||||
ctx.ServerError("LoadOwner", err)
|
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check access
|
// check access
|
||||||
if askAuth {
|
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
|
||||||
// rely on the results of Contexter
|
|
||||||
if !ctx.IsSigned {
|
if !ctx.IsSigned {
|
||||||
// TODO: support digit auth - which would be Authorization header with digit
|
// TODO: support digit auth - which would be Authorization header with digit
|
||||||
if setting.OAuth2.Enabled {
|
if setting.OAuth2.Enabled {
|
||||||
@@ -237,7 +228,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame)
|
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("pushCreateRepo: %v", err)
|
log.Error("pushCreateRepo: %v", err)
|
||||||
ctx.Status(http.StatusNotFound)
|
ctx.Status(http.StatusNotFound)
|
||||||
|
|||||||
+29
-24
@@ -681,6 +681,8 @@ func indexCommit(commits []*git.Commit, commitID string) *git.Commit {
|
|||||||
|
|
||||||
// ViewPullFiles render pull request changed files list page
|
// ViewPullFiles render pull request changed files list page
|
||||||
func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
||||||
|
var err error
|
||||||
|
|
||||||
ctx.Data["PageIsPullList"] = true
|
ctx.Data["PageIsPullList"] = true
|
||||||
ctx.Data["PageIsPullFiles"] = true
|
ctx.Data["PageIsPullFiles"] = true
|
||||||
|
|
||||||
@@ -705,44 +707,47 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
|||||||
|
|
||||||
headCommitID := prCompareInfo.HeadCommitID
|
headCommitID := prCompareInfo.HeadCommitID
|
||||||
isSingleCommit := beforeCommitID == "" && afterCommitID != ""
|
isSingleCommit := beforeCommitID == "" && afterCommitID != ""
|
||||||
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
|
|
||||||
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
|
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
|
||||||
|
|
||||||
|
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
|
||||||
ctx.Data["IsShowingAllCommits"] = isShowAllCommits
|
ctx.Data["IsShowingAllCommits"] = isShowAllCommits
|
||||||
|
|
||||||
if afterCommitID == "" || afterCommitID == headCommitID {
|
afterCommitID = util.IfZero(afterCommitID, headCommitID)
|
||||||
afterCommitID = headCommitID
|
|
||||||
}
|
|
||||||
afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID)
|
afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID)
|
||||||
|
if afterCommit == nil && afterCommitID == headCommitID {
|
||||||
|
afterCommit, err = gitRepo.GetCommit(afterCommitID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetCommit(afterCommitID)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if afterCommit == nil {
|
if afterCommit == nil {
|
||||||
ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits")
|
ctx.NotFound(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var beforeCommit *git.Commit
|
var beforeCommit *git.Commit
|
||||||
var err error
|
if isSingleCommit {
|
||||||
if !isSingleCommit {
|
|
||||||
if beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase {
|
|
||||||
beforeCommitID = prCompareInfo.CompareBase
|
|
||||||
// merge base commit is not in the list of the pull request commits
|
|
||||||
beforeCommit, err = gitRepo.GetCommit(beforeCommitID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetCommit", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID)
|
|
||||||
if beforeCommit == nil {
|
|
||||||
ctx.HTTPError(http.StatusBadRequest, "before commit not found in PR commits")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
beforeCommit, err = afterCommit.Parent(0)
|
beforeCommit, err = afterCommit.Parent(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("Parent", err)
|
ctx.ServerError("afterCommit.Parent", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
beforeCommitID = beforeCommit.ID.String()
|
beforeCommitID = beforeCommit.ID.String()
|
||||||
|
} else {
|
||||||
|
beforeCommitID = util.IfZero(beforeCommitID, prCompareInfo.CompareBase)
|
||||||
|
beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID)
|
||||||
|
if beforeCommit == nil && beforeCommitID == prCompareInfo.CompareBase {
|
||||||
|
beforeCommit, err = gitRepo.GetCommit(beforeCommitID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetCommit(beforeCommitID)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if beforeCommit == nil {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["CompareInfo"] = prCompareInfo
|
ctx.Data["CompareInfo"] = prCompareInfo
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ func saveMetadata(ctx *context.Context) {
|
|||||||
manifest.Maintainer = existing.Maintainer
|
manifest.Maintainer = existing.Maintainer
|
||||||
manifest.MaintainerURL = existing.MaintainerURL
|
manifest.MaintainerURL = existing.MaintainerURL
|
||||||
manifest.Language = existing.Language
|
manifest.Language = existing.Language
|
||||||
|
manifest.DeployHost = existing.DeployHost
|
||||||
|
manifest.DeployPort = existing.DeployPort
|
||||||
|
manifest.DeployUser = existing.DeployUser
|
||||||
|
manifest.DeployPath = existing.DeployPath
|
||||||
|
manifest.DockerImage = existing.DockerImage
|
||||||
|
manifest.DockerRegistry = existing.DockerRegistry
|
||||||
|
manifest.ContainerName = existing.ContainerName
|
||||||
|
manifest.HealthURL = existing.HealthURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
|
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func SettingsProtectedBranch(c *context.Context) {
|
|||||||
c.Data["Users"] = users
|
c.Data["Users"] = users
|
||||||
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
|
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
|
||||||
c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",")
|
c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",")
|
||||||
|
c.Data["delete_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistUserIDs), ",")
|
||||||
c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
|
c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
|
||||||
c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
|
c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
|
||||||
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
|
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
|
||||||
@@ -97,6 +98,7 @@ func SettingsProtectedBranch(c *context.Context) {
|
|||||||
c.Data["Teams"] = teams
|
c.Data["Teams"] = teams
|
||||||
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
|
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
|
||||||
c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",")
|
c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",")
|
||||||
|
c.Data["delete_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistTeamIDs), ",")
|
||||||
c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
|
c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
|
||||||
c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
|
c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
|
||||||
}
|
}
|
||||||
@@ -155,7 +157,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64
|
var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, deleteAllowlistUsers, deleteAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64
|
||||||
protectBranch.RuleName = f.RuleName
|
protectBranch.RuleName = f.RuleName
|
||||||
if f.RequiredApprovals < 0 {
|
if f.RequiredApprovals < 0 {
|
||||||
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
|
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
|
||||||
@@ -211,6 +213,30 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
|||||||
protectBranch.ForcePushAllowlistActionsUser = false
|
protectBranch.ForcePushAllowlistActionsUser = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch f.EnableDelete {
|
||||||
|
case "all":
|
||||||
|
protectBranch.CanDelete = true
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
case "whitelist":
|
||||||
|
protectBranch.CanDelete = true
|
||||||
|
protectBranch.EnableDeleteAllowlist = true
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = f.DeleteAllowlistDeployKeys
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = f.DeleteAllowlistActionsUser
|
||||||
|
if strings.TrimSpace(f.DeleteAllowlistUsers) != "" {
|
||||||
|
deleteAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistUsers, ","))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.DeleteAllowlistTeams) != "" {
|
||||||
|
deleteAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistTeams, ","))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
protectBranch.CanDelete = false
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
}
|
||||||
|
|
||||||
protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist
|
protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist
|
||||||
protectBranch.MergeWhitelistActionsUser = f.EnableMergeWhitelist && f.MergeWhitelistActionsUser
|
protectBranch.MergeWhitelistActionsUser = f.EnableMergeWhitelist && f.MergeWhitelistActionsUser
|
||||||
if f.EnableMergeWhitelist {
|
if f.EnableMergeWhitelist {
|
||||||
@@ -274,6 +300,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
|||||||
TeamIDs: whitelistTeams,
|
TeamIDs: whitelistTeams,
|
||||||
ForcePushUserIDs: forcePushAllowlistUsers,
|
ForcePushUserIDs: forcePushAllowlistUsers,
|
||||||
ForcePushTeamIDs: forcePushAllowlistTeams,
|
ForcePushTeamIDs: forcePushAllowlistTeams,
|
||||||
|
DeleteUserIDs: deleteAllowlistUsers,
|
||||||
|
DeleteTeamIDs: deleteAllowlistTeams,
|
||||||
MergeUserIDs: mergeWhitelistUsers,
|
MergeUserIDs: mergeWhitelistUsers,
|
||||||
MergeTeamIDs: mergeWhitelistTeams,
|
MergeTeamIDs: mergeWhitelistTeams,
|
||||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||||
|
|||||||
+1111
-66
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user