Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c74173bb86 | |||
| b2447da3dd | |||
| 7be712571c | |||
| 6634b049ab | |||
| 5c62cefcd3 | |||
| f2ca569906 | |||
| b9301a8f31 | |||
| cac06c2ac7 | |||
| 6c0c2c3f1f | |||
| 3efbab985b | |||
| 98b1ed2f7b | |||
| ccfc9a604b | |||
| 2f119fbd95 | |||
| 7f229ba01c | |||
| e98fca780e | |||
| 7a4dc5e809 | |||
| 8c63b00953 | |||
| 6b81922c47 | |||
| 93365cdd95 | |||
| 81ea2fcb05 | |||
| 2713c49aec | |||
| 3917bf6a29 | |||
| 89ed32e961 | |||
| 948e7bcd21 | |||
| 5d797431f0 | |||
| 63f773aa56 | |||
| 125eefc650 | |||
| d07cfd412b | |||
| bd821e2d44 | |||
| aeed197ea5 | |||
| 45fc346d52 | |||
| 02071a23d6 | |||
| 3a5c6a37cf | |||
| 37fb3703c7 | |||
| 6a3db171c1 | |||
| d3134b1c53 | |||
| 3aac1b456c | |||
| b31336d1fe | |||
| 4b68853f08 | |||
| 86bd8a2cad | |||
| 24b3516c1d | |||
| 343cba690e |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a new feature or enhancement
|
about: Suggest a new feature or enhancement
|
||||||
title: '[FEATURE] '
|
title: '(feat) '
|
||||||
labels: 'enhancement'
|
labels: 'enhancement'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
|
|||||||
@@ -52,51 +52,69 @@ jobs:
|
|||||||
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
TAG: ${{ steps.config.outputs.tag }}
|
TAG: ${{ steps.config.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
|
# Inject runner-side values (TAG, REGISTRY_TOKEN) into the remote shell's
|
||||||
|
# environment via a command prefix, then use a *quoted* heredoc so every
|
||||||
|
# $var below expands in exactly one place: the remote dev host. This avoids
|
||||||
|
# the local-vs-remote expansion trap that previously left TAG empty.
|
||||||
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
|
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
|
||||||
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
||||||
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
|
||||||
|
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
|
||||||
set -e
|
set -e
|
||||||
echo 'SSH connected to dev environment'
|
echo 'SSH connected to dev environment'
|
||||||
|
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo 'ERROR: TAG is empty; refusing to build an untagged image' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEALTH_FMT='{{.State.Health.Status}}'
|
||||||
|
|
||||||
echo 'Cleaning Docker build cache...'
|
echo 'Cleaning Docker build cache...'
|
||||||
docker builder prune -af 2>/dev/null || true
|
docker builder prune -af 2>/dev/null || true
|
||||||
docker image prune -af 2>/dev/null || true
|
docker image prune -af 2>/dev/null || true
|
||||||
|
|
||||||
echo 'Pulling source...'
|
echo 'Pulling source...'
|
||||||
SOURCE_DIR=/opt/gitea-dev/source
|
SOURCE_DIR=/opt/gitea-dev/source
|
||||||
if [ ! -d \$SOURCE_DIR/.git ]; then
|
if [ ! -d "$SOURCE_DIR/.git" ]; then
|
||||||
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
|
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git "$SOURCE_DIR"
|
||||||
fi
|
fi
|
||||||
cd \$SOURCE_DIR
|
cd "$SOURCE_DIR"
|
||||||
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
|
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
|
||||||
git fetch origin dev
|
git fetch origin dev
|
||||||
git reset --hard origin/dev
|
git reset --hard origin/dev
|
||||||
|
|
||||||
echo 'Building Docker image...'
|
echo "Building Docker image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG"
|
||||||
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
||||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
|
--tag "${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG" \
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
|
|
||||||
echo 'Pushing to registry...'
|
echo 'Pushing to registry...'
|
||||||
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
|
echo "$REGISTRY_TOKEN" | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
|
docker push "${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG"
|
||||||
|
|
||||||
echo 'Restarting dev container...'
|
echo 'Restarting dev container...'
|
||||||
cd /opt/gitea-dev
|
cd /opt/gitea-dev
|
||||||
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
|
# The dev service in the SHARED compose file reads its image tag from
|
||||||
docker compose up -d mokogitea-dev
|
# ${MOKOGITEA_DEV_TAG}. Drive it from the freshly built tag instead of
|
||||||
|
# rewriting the file with sed: the old sed pattern matched the *prod*
|
||||||
|
# service line (container_name: mokogitea) and left the dev service pinned
|
||||||
|
# to a stale image, so every dev deploy recreated old code while silently
|
||||||
|
# corrupting the prod image pin. The env-var only affects the dev service.
|
||||||
|
# Remove any lingering fixed-name container first so the recreate can't hit
|
||||||
|
# a name conflict, pin the project name for determinism, then force-recreate.
|
||||||
|
docker rm -f mokogitea-dev 2>/dev/null || true
|
||||||
|
MOKOGITEA_DEV_TAG="$TAG" docker compose -p gitea-dev up -d --force-recreate mokogitea-dev
|
||||||
|
|
||||||
echo 'Health check...'
|
echo 'Health check...'
|
||||||
for i in 1 2 3 4 5 6 7 8; do
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
sleep 15
|
sleep 15
|
||||||
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
|
if docker inspect --format="$HEALTH_FMT" mokogitea-dev 2>/dev/null | grep -q healthy; then
|
||||||
echo 'Dev container healthy!'
|
echo 'Dev container healthy!'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Waiting... (attempt \$i/8)"
|
echo "Waiting... (attempt $i/8)"
|
||||||
done
|
done
|
||||||
echo 'Health check failed'
|
echo 'Health check failed'
|
||||||
docker logs mokogitea-dev --tail 20
|
docker logs mokogitea-dev --tail 20
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# BRIEF: Build and deploy to the RC environment on push to the rc branch.
|
||||||
|
# Portable across Go server repos: ALL deployment config comes from repo
|
||||||
|
# Actions variables (vars.*) and secrets (secrets.*), nothing hardcoded.
|
||||||
|
# The rc branch is created by promote-rc when a PR to main opens.
|
||||||
|
# OWNER: Template-Go (canonical source; syncs to the root workflows dir). See Template-Go#3.
|
||||||
|
#
|
||||||
|
# Required repo VARIABLES (all tier-scoped so each environment is independent —
|
||||||
|
# a repo may host its rc/dev/prod tiers on different machines):
|
||||||
|
# RC_SSH_HOST, RC_SSH_PORT, RC_SSH_USERNAME - SSH deploy target for the rc tier
|
||||||
|
# RC_REGISTRY, RC_REGISTRY_USER, RC_IMAGE - container registry + login user + image
|
||||||
|
# RC_CONTAINER - compose service/container to recreate
|
||||||
|
# RC_COMPOSE_PROJECT - docker compose -p project name
|
||||||
|
# RC_COMPOSE_DIR - dir containing docker-compose.yml on host
|
||||||
|
# RC_SOURCE_DIR - build source checkout on host
|
||||||
|
# RC_TAG_ENV - compose env-var name that pins the image tag
|
||||||
|
# RC_HEALTH_URL - external URL to verify after deploy
|
||||||
|
# Required SECRETS (already configured org-wide; reused, not re-set):
|
||||||
|
# DEPLOY_SSH_KEY - deploy private key (repo/org secret)
|
||||||
|
# MOKOGITEA_TOKEN - registry/API token (org secret)
|
||||||
|
|
||||||
|
name: Deploy (RC)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- rc
|
||||||
|
# Manual trigger for isolated end-to-end tests without a full RC promotion.
|
||||||
|
# Runs on the ref it is dispatched from; that ref must carry current source
|
||||||
|
# (>= the RC database migration version) or the rebuilt image will refuse the
|
||||||
|
# newer DB. Dispatch from `rc` once `rc` is current.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-rc
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-rc:
|
||||||
|
name: "Build & Deploy to RC"
|
||||||
|
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 "rc-$(git rev-parse --short HEAD)")
|
||||||
|
echo "tag=${VERSION}-rc" >> $GITHUB_OUTPUT
|
||||||
|
echo "Version: ${VERSION}-rc"
|
||||||
|
|
||||||
|
- 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 RC via SSH
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
TAG: ${{ steps.config.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
# Runner-side values (TAG, REGISTRY_TOKEN) are injected into the remote shell
|
||||||
|
# via an env prefix; a *quoted* heredoc keeps every $var expanding once, on the
|
||||||
|
# remote. Repo variables (vars.*) are substituted inline by Actions before ssh.
|
||||||
|
ssh -i ~/.ssh/deploy_key -p ${{ vars.RC_SSH_PORT }} \
|
||||||
|
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
||||||
|
${{ vars.RC_SSH_USERNAME }}@${{ vars.RC_SSH_HOST }} \
|
||||||
|
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
|
||||||
|
set -e
|
||||||
|
echo 'SSH connected to RC environment'
|
||||||
|
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo 'ERROR: TAG is empty; refusing to build an untagged image' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEALTH_FMT='{{.State.Health.Status}}'
|
||||||
|
|
||||||
|
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='${{ vars.RC_SOURCE_DIR }}'
|
||||||
|
if [ ! -d "$SOURCE_DIR/.git" ]; then
|
||||||
|
git clone -b rc ${{ github.server_url }}/${{ github.repository }}.git "$SOURCE_DIR"
|
||||||
|
fi
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
git remote set-url origin ${{ github.server_url }}/${{ github.repository }}.git 2>/dev/null || true
|
||||||
|
git fetch origin rc
|
||||||
|
git reset --hard origin/rc
|
||||||
|
|
||||||
|
echo "Building image: ${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}:$TAG"
|
||||||
|
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
||||||
|
--tag "${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}:$TAG" \
|
||||||
|
-f Dockerfile .
|
||||||
|
|
||||||
|
echo 'Pushing to registry...'
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login ${{ vars.RC_REGISTRY }} -u ${{ vars.RC_REGISTRY_USER }} --password-stdin
|
||||||
|
docker push "${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}:$TAG"
|
||||||
|
|
||||||
|
echo 'Restarting RC container...'
|
||||||
|
cd '${{ vars.RC_COMPOSE_DIR }}'
|
||||||
|
# Drive the rc service image tag via its compose env-var (no sed on the shared
|
||||||
|
# file); remove any lingering fixed-name container first, then force-recreate.
|
||||||
|
docker rm -f '${{ vars.RC_CONTAINER }}' 2>/dev/null || true
|
||||||
|
${{ vars.RC_TAG_ENV }}="$TAG" docker compose -p '${{ vars.RC_COMPOSE_PROJECT }}' up -d --force-recreate '${{ vars.RC_CONTAINER }}'
|
||||||
|
|
||||||
|
echo 'Health check...'
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
sleep 15
|
||||||
|
if docker inspect --format="$HEALTH_FMT" '${{ vars.RC_CONTAINER }}' 2>/dev/null | grep -q healthy; then
|
||||||
|
echo 'RC container healthy!'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting... (attempt $i/8)"
|
||||||
|
done
|
||||||
|
echo 'Health check failed'
|
||||||
|
docker logs '${{ vars.RC_CONTAINER }}' --tail 20
|
||||||
|
exit 1
|
||||||
|
DEPLOY_EOF
|
||||||
|
|
||||||
|
- name: Verify RC instance
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
curl -sf "${{ vars.RC_HEALTH_URL }}" && echo " RC API healthy"
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Org branch protection: repositories now show the inherited organization rules read-only in their Branch Protection settings, with an expandable detail (direct push, force-push, branch deletion, merge restrictions, required approvals, status checks, protected files, and whitelisted teams) — like GitHub surfaces org rulesets in a repo (#727)
|
||||||
|
- Org branch protection: org-level rules can now also protect against branch deletion (`enable_delete` + delete allowlist teams), mirroring the per-repo delete allowlist (#727)
|
||||||
|
- Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727)
|
||||||
|
- Org-level push policy: one policy per org, enforced in the pre-receive hook across all its repositories — branch/tag name conventions (glob), a mandatory secret-scanning block-on-push that repos cannot disable, a max pushed-file size, and blocked file-path patterns. API at `/orgs/{org}/push_policy`. Naming is fail-closed; the content checks (blocked paths, max size) fail open on error so a policy bug can never block every push (#727)
|
||||||
|
- Org-level repository defaults: an org can force new/transferred repositories private and set default pull-request settings (allowed merge styles, default merge style, auto-delete branch after merge), applied via a notifier when a repo is created in or transferred into the org (best-effort — never blocks repo creation). API at `/orgs/{org}/repo_defaults` (#727)
|
||||||
|
- Org-level email domain policy: restrict which email domains an organization's members may have — a user can only be added to the org (via any team) if their primary email matches one of the allowed domain globs. Enforced at the single membership-add choke point (`AddTeamMember`); API at `/orgs/{org}/email_domain_policy` (#727)
|
||||||
- Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552)
|
- 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)
|
- 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)
|
- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507)
|
||||||
@@ -57,6 +63,11 @@
|
|||||||
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
|
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Fork server binary now compiles: `routers/api/v1/api.go` called `organization.HasOrgOrUserVisible`, which had been renamed to `IsOwnerVisibleToDoer`; the one missed call site broke `go build` of the entire `routers/api/v1` package (CI's Lint & Validate does not run a full build, so it went unnoticed) (#735)
|
||||||
|
- Dev deploy workflow: the build/deploy step referenced runner-side values as `\$TAG` / `\$REGISTRY_TOKEN` inside an unquoted SSH heredoc, deferring expansion to the remote shell where those names are unset — the Docker tag collapsed to an empty `mokogitea:` and every dev deploy failed with `invalid reference format`. Runner values are now injected via an ssh env-prefix and the heredoc is quoted so each `$var` expands in exactly one place (#737)
|
||||||
|
- Repaired unit-test compile and `go vet` failures: `CryptoRandomInt/String/Bytes` now return two values (updated `modules/util/util_test.go`), removed a redundant `&&` condition in `issue_comment.go`, and cleaned up isolated integration-test compile errors (#736)
|
||||||
|
- Removed a stray `package-lock.json` (13.9k lines) that a `git add -A` had accidentally swept into the org-push-policy branch (#734)
|
||||||
|
- Org-level branch protection now **layers** with per-repo rules instead of being ignored whenever a repo rule exists. When both an org rule and a repo rule match a branch, the effective rule is the most-restrictive (fail-closed) combination — the org rule is a mandatory floor a repo cannot weaken: allow flags AND'd, gate/require/block flags OR'd, required approvals max'd, status checks and protected-file patterns unioned, whitelists intersected. Previously a repo rule shadowed the org rule entirely at the enforcement choke point (`GetFirstMatchProtectedBranchRule`), letting a repo opt out of org protection (#727)
|
||||||
- Org Teams page: list now renders — the handler wrote `ctx.Data["OrgListTeams"]` but the template reads `.Teams`, so the page showed header/nav but no teams (#720)
|
- Org Teams page: list now renders — the handler wrote `ctx.Data["OrgListTeams"]` but the template reads `.Teams`, so the page showed header/nav but no teams (#720)
|
||||||
- Issue type: now editable after creation for users with issue write permission — the sidebar gated editing on a `FieldEditFlags` data key that was never populated (always read-only); now uses `HasIssuesOrPullsWritePermission` like the priority field (#721)
|
- Issue type: now editable after creation for users with issue write permission — the sidebar gated editing on a `FieldEditFlags` data key that was never populated (always read-only); now uses `HasIssuesOrPullsWritePermission` like the priority field (#721)
|
||||||
- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page
|
- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MokoGitea
|
# MokoGitea
|
||||||
|
|
||||||
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 enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org-level governance, org metadata, CI standardization, and project board API.
|
||||||
|
|
||||||
 
|
 
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cas
|
|||||||
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
|
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
|
||||||
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
|
- **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)
|
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
|
||||||
|
- **Org Governance** -- organization-wide rules that layer onto every repository: branch protection as a most-restrictive floor a repo cannot weaken, tag protection (team allowlist), push policy (branch/tag naming, mandatory secret-block, max file size, blocked paths), repository defaults (force-private, PR merge settings), and member email-domain allowlists
|
||||||
- **Project Board API** -- REST endpoints for project columns and cards
|
- **Project Board API** -- REST endpoints for project columns and cards
|
||||||
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
|
- **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
|
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrgEmailDomainPolicy restricts which email domains an organization's members may
|
||||||
|
// have. When configured, a user can only be added to the org if their primary email
|
||||||
|
// matches one of the allowed domain globs. At most one row per org. See issue #727.
|
||||||
|
type OrgEmailDomainPolicy struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
AllowedDomains string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(OrgEmailDomainPolicy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrEmailDomainNotAllowed is returned when a user's email domain is not permitted
|
||||||
|
// by the organization's email domain policy.
|
||||||
|
type ErrEmailDomainNotAllowed struct {
|
||||||
|
Email string
|
||||||
|
OrgID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrEmailDomainNotAllowed) Error() string {
|
||||||
|
return fmt.Sprintf("email %q is not in an allowed domain for organization %d", e.Email, e.OrgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrEmailDomainNotAllowed reports whether err is an ErrEmailDomainNotAllowed.
|
||||||
|
func IsErrEmailDomainNotAllowed(err error) bool {
|
||||||
|
_, ok := err.(ErrEmailDomainNotAllowed)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrgEmailDomainPolicy) domainGlobs() []glob.Glob {
|
||||||
|
var out []glob.Glob
|
||||||
|
for _, d := range strings.Split(p.AllowedDomains, ";") {
|
||||||
|
d = strings.TrimSpace(strings.ToLower(d))
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g, err := glob.Compile(d); err == nil {
|
||||||
|
out = append(out, g)
|
||||||
|
} else {
|
||||||
|
log.Warn("Invalid org email domain glob %q: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailAllowed reports whether email's domain satisfies the policy. An empty policy
|
||||||
|
// (no configured domains) allows any email.
|
||||||
|
func (p *OrgEmailDomainPolicy) EmailAllowed(email string) bool {
|
||||||
|
globs := p.domainGlobs()
|
||||||
|
if len(globs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
at := strings.LastIndexByte(email, '@')
|
||||||
|
if at < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
domain := strings.ToLower(email[at+1:])
|
||||||
|
for _, g := range globs {
|
||||||
|
if g.Match(domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgEmailDomainPolicy returns the org's email domain policy, or nil if none.
|
||||||
|
func GetOrgEmailDomainPolicy(ctx context.Context, orgID int64) (*OrgEmailDomainPolicy, error) {
|
||||||
|
policy, exist, err := db.Get[OrgEmailDomainPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !exist {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgEmailDomainAllowed reports whether email is permitted for the org. It returns
|
||||||
|
// true when the org has no policy configured.
|
||||||
|
func OrgEmailDomainAllowed(ctx context.Context, orgID int64, email string) (bool, error) {
|
||||||
|
policy, err := GetOrgEmailDomainPolicy(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if policy == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return policy.EmailAllowed(email), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertOrgEmailDomainPolicy creates or updates the single policy row for an org.
|
||||||
|
func UpsertOrgEmailDomainPolicy(ctx context.Context, policy *OrgEmailDomainPolicy) error {
|
||||||
|
existing, err := GetOrgEmailDomainPolicy(ctx, policy.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||||
|
return fmt.Errorf("Insert OrgEmailDomainPolicy: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
policy.ID = existing.ID
|
||||||
|
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||||
|
return fmt.Errorf("Update OrgEmailDomainPolicy: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgEmailDomainPolicy removes an org's email domain policy.
|
||||||
|
func DeleteOrgEmailDomainPolicy(ctx context.Context, orgID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgEmailDomainPolicy))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -28,16 +28,28 @@ type OrgProtectedBranch struct {
|
|||||||
CanPush bool `xorm:"NOT NULL DEFAULT false"`
|
CanPush bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
EnableWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
EnableWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
|
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
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"`
|
||||||
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
|
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||||
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
|
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false"`
|
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false"`
|
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
@@ -81,8 +93,9 @@ func (o *OrgProtectedBranch) Match(branchName string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToProtectedBranch converts an org-level rule to a ProtectedBranch for use
|
// ToProtectedBranch converts an org-level rule to a ProtectedBranch for use
|
||||||
// in the standard enforcement path. Fields that are user-scoped (WhitelistUserIDs etc.)
|
// in the standard enforcement path. Both team-scoped and user-scoped allowlists
|
||||||
// are left empty because org rules only reference teams.
|
// (plus the Actions-bot toggles) are carried across; deploy-key allowances remain
|
||||||
|
// repo-only because an org rule cannot express them.
|
||||||
func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
|
func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
|
||||||
return &ProtectedBranch{
|
return &ProtectedBranch{
|
||||||
ID: o.ID,
|
ID: o.ID,
|
||||||
@@ -91,16 +104,28 @@ func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
|
|||||||
CanPush: o.CanPush,
|
CanPush: o.CanPush,
|
||||||
EnableWhitelist: o.EnableWhitelist,
|
EnableWhitelist: o.EnableWhitelist,
|
||||||
WhitelistTeamIDs: o.WhitelistTeamIDs,
|
WhitelistTeamIDs: o.WhitelistTeamIDs,
|
||||||
|
WhitelistUserIDs: o.WhitelistUserIDs,
|
||||||
|
WhitelistActionsUser: o.WhitelistActionsUser,
|
||||||
EnableMergeWhitelist: o.EnableMergeWhitelist,
|
EnableMergeWhitelist: o.EnableMergeWhitelist,
|
||||||
MergeWhitelistTeamIDs: o.MergeWhitelistTeamIDs,
|
MergeWhitelistTeamIDs: o.MergeWhitelistTeamIDs,
|
||||||
|
MergeWhitelistUserIDs: o.MergeWhitelistUserIDs,
|
||||||
|
MergeWhitelistActionsUser: o.MergeWhitelistActionsUser,
|
||||||
CanForcePush: o.CanForcePush,
|
CanForcePush: o.CanForcePush,
|
||||||
EnableForcePushAllowlist: o.EnableForcePushAllowlist,
|
EnableForcePushAllowlist: o.EnableForcePushAllowlist,
|
||||||
ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs,
|
ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs,
|
||||||
|
ForcePushAllowlistUserIDs: o.ForcePushAllowlistUserIDs,
|
||||||
|
ForcePushAllowlistActionsUser: o.ForcePushAllowlistActionsUser,
|
||||||
|
CanDelete: o.CanDelete,
|
||||||
|
EnableDeleteAllowlist: o.EnableDeleteAllowlist,
|
||||||
|
DeleteAllowlistTeamIDs: o.DeleteAllowlistTeamIDs,
|
||||||
|
DeleteAllowlistUserIDs: o.DeleteAllowlistUserIDs,
|
||||||
|
DeleteAllowlistActionsUser: o.DeleteAllowlistActionsUser,
|
||||||
EnableStatusCheck: o.EnableStatusCheck,
|
EnableStatusCheck: o.EnableStatusCheck,
|
||||||
StatusCheckContexts: o.StatusCheckContexts,
|
StatusCheckContexts: o.StatusCheckContexts,
|
||||||
RequiredApprovals: o.RequiredApprovals,
|
RequiredApprovals: o.RequiredApprovals,
|
||||||
EnableApprovalsWhitelist: o.EnableApprovalsWhitelist,
|
EnableApprovalsWhitelist: o.EnableApprovalsWhitelist,
|
||||||
ApprovalsWhitelistTeamIDs: o.ApprovalsWhitelistTeamIDs,
|
ApprovalsWhitelistTeamIDs: o.ApprovalsWhitelistTeamIDs,
|
||||||
|
ApprovalsWhitelistUserIDs: o.ApprovalsWhitelistUserIDs,
|
||||||
BlockOnRejectedReviews: o.BlockOnRejectedReviews,
|
BlockOnRejectedReviews: o.BlockOnRejectedReviews,
|
||||||
BlockOnOfficialReviewRequests: o.BlockOnOfficialReviewRequests,
|
BlockOnOfficialReviewRequests: o.BlockOnOfficialReviewRequests,
|
||||||
BlockOnOutdatedBranch: o.BlockOnOutdatedBranch,
|
BlockOnOutdatedBranch: o.BlockOnOutdatedBranch,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrgProtectedTag represents an org-level tag protection rule. It cascades to all
|
||||||
|
// repositories in the organization and layers on top of each repo's own protected
|
||||||
|
// tags (a tag is controllable only if allowed at both levels). Org rules reference
|
||||||
|
// teams only (like OrgProtectedBranch). See issue #727.
|
||||||
|
type OrgProtectedTag struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||||
|
NamePattern string `xorm:"UNIQUE(s)"`
|
||||||
|
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(OrgProtectedTag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToProtectedTag converts an org-level tag rule into a repo-scoped ProtectedTag so
|
||||||
|
// the standard name-matching and allowlist logic can be reused. Org rules are
|
||||||
|
// team-only, so the user allowlist is left empty.
|
||||||
|
func (o *OrgProtectedTag) ToProtectedTag() *ProtectedTag {
|
||||||
|
return &ProtectedTag{
|
||||||
|
NamePattern: o.NamePattern,
|
||||||
|
AllowlistTeamIDs: o.AllowlistTeamIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgProtectedTagByID retrieves a single org tag rule by org ID and rule ID.
|
||||||
|
func GetOrgProtectedTagByID(ctx context.Context, orgID, id int64) (*OrgProtectedTag, error) {
|
||||||
|
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "id": id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !exist {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgProtectedTagByNamePattern retrieves a single org tag rule by its pattern.
|
||||||
|
func GetOrgProtectedTagByNamePattern(ctx context.Context, orgID int64, pattern string) (*OrgProtectedTag, error) {
|
||||||
|
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "name_pattern": pattern})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !exist {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrgProtectedTags loads all org-level tag protection rules for an organization.
|
||||||
|
func FindOrgProtectedTags(ctx context.Context, orgID int64) ([]*OrgProtectedTag, error) {
|
||||||
|
var rules []*OrgProtectedTag
|
||||||
|
err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("created_unix").Find(&rules)
|
||||||
|
return rules, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgProtectedTag creates a new org-level tag protection rule.
|
||||||
|
func CreateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(rule); err != nil {
|
||||||
|
return fmt.Errorf("Insert OrgProtectedTag: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgProtectedTag updates an existing org-level tag protection rule.
|
||||||
|
func UpdateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||||
|
if _, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule); err != nil {
|
||||||
|
return fmt.Errorf("Update OrgProtectedTag: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgProtectedTag deletes an org-level tag protection rule.
|
||||||
|
func DeleteOrgProtectedTag(ctx context.Context, orgID, id int64) error {
|
||||||
|
affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedTag))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf("org tag protection rule ID(%d) not found", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserAllowedToControlTagInRepo layers org-level tag rules on top of a repo's own
|
||||||
|
// protected tags: the user must be allowed by BOTH levels (most-restrictive). The
|
||||||
|
// repo decision is evaluated first (using the already-loaded repoTags); if it
|
||||||
|
// allows and the owner is an organization, the org-level rules must also allow.
|
||||||
|
func IsUserAllowedToControlTagInRepo(ctx context.Context, repoTags []*ProtectedTag, repo *repo_model.Repository, tagName string, userID int64) (bool, error) {
|
||||||
|
allowed, err := IsUserAllowedToControlTag(ctx, repoTags, tagName, userID)
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return allowed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !owner.IsOrganization() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgRules, err := FindOrgProtectedTags(ctx, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(orgRules) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgTags := make([]*ProtectedTag, len(orgRules))
|
||||||
|
for i, r := range orgRules {
|
||||||
|
orgTags[i] = r.ToProtectedTag()
|
||||||
|
}
|
||||||
|
return IsUserAllowedToControlTag(ctx, orgTags, tagName, userID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrgPushPolicy is a single org-wide policy enforced in the pre-receive hook on
|
||||||
|
// every repository of the organization. Unlike the branch/tag rulesets there is at
|
||||||
|
// most one policy per org. Empty pattern / zero fields mean "no constraint". See #727.
|
||||||
|
type OrgPushPolicy struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
BranchNamePattern string `xorm:"TEXT"`
|
||||||
|
TagNamePattern string `xorm:"TEXT"`
|
||||||
|
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
BlockedFilePatterns string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(OrgPushPolicy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameMatchesPattern reports whether name satisfies a glob pattern. An empty pattern
|
||||||
|
// imposes no constraint; an invalid pattern fails open (no constraint) so a
|
||||||
|
// misconfigured policy never blocks all pushes.
|
||||||
|
func nameMatchesPattern(pattern, name string) bool {
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if pattern == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
g, err := glob.Compile(pattern, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Invalid org push policy name pattern %q: %v", pattern, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return g.Match(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchNameAllowed reports whether a branch name satisfies the naming policy.
|
||||||
|
func (p *OrgPushPolicy) BranchNameAllowed(name string) bool {
|
||||||
|
return nameMatchesPattern(p.BranchNamePattern, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagNameAllowed reports whether a tag name satisfies the naming policy.
|
||||||
|
func (p *OrgPushPolicy) TagNameAllowed(name string) bool {
|
||||||
|
return nameMatchesPattern(p.TagNamePattern, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockedFileGlobs parses the ';'-separated blocked file pattern list.
|
||||||
|
func (p *OrgPushPolicy) BlockedFileGlobs() []glob.Glob {
|
||||||
|
var out []glob.Glob
|
||||||
|
for _, expr := range strings.Split(p.BlockedFilePatterns, ";") {
|
||||||
|
expr = strings.TrimSpace(strings.ToLower(expr))
|
||||||
|
if expr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g, err := glob.Compile(expr, '.', '/'); err == nil {
|
||||||
|
out = append(out, g)
|
||||||
|
} else {
|
||||||
|
log.Warn("Invalid org push policy blocked file pattern %q: %v", expr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPushPolicy returns the org's push policy, or nil if none is configured.
|
||||||
|
func GetOrgPushPolicy(ctx context.Context, orgID int64) (*OrgPushPolicy, error) {
|
||||||
|
policy, exist, err := db.Get[OrgPushPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !exist {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPushPolicyForRepo returns the push policy of the repo's owning organization,
|
||||||
|
// or nil if the owner is not an organization or has no policy.
|
||||||
|
func GetOrgPushPolicyForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgPushPolicy, error) {
|
||||||
|
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !owner.IsOrganization() {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return GetOrgPushPolicy(ctx, owner.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertOrgPushPolicy creates or updates the single push policy for an org.
|
||||||
|
func UpsertOrgPushPolicy(ctx context.Context, policy *OrgPushPolicy) error {
|
||||||
|
existing, err := GetOrgPushPolicy(ctx, policy.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||||
|
return fmt.Errorf("Insert OrgPushPolicy: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
policy.ID = existing.ID
|
||||||
|
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||||
|
return fmt.Errorf("Update OrgPushPolicy: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgPushPolicy removes an org's push policy.
|
||||||
|
func DeleteOrgPushPolicy(ctx context.Context, orgID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgPushPolicy))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrgRepoDefaults holds an organization's default repository settings, applied to a
|
||||||
|
// repository when it is created in or transferred into the org (via a notifier).
|
||||||
|
// There is at most one row per org. See issue #727.
|
||||||
|
type OrgRepoDefaults struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
DefaultMergeStyle string `xorm:"TEXT"`
|
||||||
|
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(OrgRepoDefaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgRepoDefaults returns the org's repo defaults, or nil if none are configured.
|
||||||
|
func GetOrgRepoDefaults(ctx context.Context, orgID int64) (*OrgRepoDefaults, error) {
|
||||||
|
defaults, exist, err := db.Get[OrgRepoDefaults](ctx, builder.Eq{"org_id": orgID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !exist {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return defaults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgRepoDefaultsForRepo returns the repo-defaults of the repo's owning org, or
|
||||||
|
// nil if the owner is not an organization or has none configured.
|
||||||
|
func GetOrgRepoDefaultsForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgRepoDefaults, error) {
|
||||||
|
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !owner.IsOrganization() {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return GetOrgRepoDefaults(ctx, owner.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertOrgRepoDefaults creates or updates the single repo-defaults row for an org.
|
||||||
|
func UpsertOrgRepoDefaults(ctx context.Context, defaults *OrgRepoDefaults) error {
|
||||||
|
existing, err := GetOrgRepoDefaults(ctx, defaults.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(defaults); err != nil {
|
||||||
|
return fmt.Errorf("Insert OrgRepoDefaults: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaults.ID = existing.ID
|
||||||
|
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(defaults); err != nil {
|
||||||
|
return fmt.Errorf("Update OrgRepoDefaults: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgRepoDefaults removes an org's repo defaults.
|
||||||
|
func DeleteOrgRepoDefaults(ctx context.Context, orgID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgRepoDefaults))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -85,19 +85,40 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFirstMatchProtectedBranchRule returns the first matched rule.
|
// GetFirstMatchProtectedBranchRule returns the effective protected-branch rule for a
|
||||||
// It checks repo-level rules first; if none match, it falls back to org-level rules
|
// branch. It combines the matching repo-level rule with the matching org-level rule
|
||||||
// (if the repo belongs to an organization).
|
// (when the repo belongs to an organization): if both match they are layered with
|
||||||
|
// mergeMostRestrictive so the org rule acts as a floor the repo cannot weaken; if
|
||||||
|
// only one matches that one is returned; if neither matches, nil.
|
||||||
func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
||||||
rules, err := FindRepoProtectedBranchRules(ctx, repoID)
|
rules, err := FindRepoProtectedBranchRules(ctx, repoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if matched := rules.GetFirstMatched(branchName); matched != nil {
|
repoRule := rules.GetFirstMatched(branchName)
|
||||||
return matched, nil
|
|
||||||
|
orgRule, err := getFirstMatchOrgProtectedBranchRule(ctx, repoID, branchName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to org-level rules
|
switch {
|
||||||
|
case repoRule == nil && orgRule == nil:
|
||||||
|
return nil, nil
|
||||||
|
case orgRule == nil:
|
||||||
|
return repoRule, nil
|
||||||
|
case repoRule == nil:
|
||||||
|
return orgRule, nil
|
||||||
|
default:
|
||||||
|
return mergeMostRestrictive(repoRule, orgRule), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFirstMatchOrgProtectedBranchRule returns the matching org-level rule for a
|
||||||
|
// branch expressed as a repo-scoped ProtectedBranch (RepoID set so downstream
|
||||||
|
// permission checks work), or nil if the repo's owner is not an organization or no
|
||||||
|
// org rule matches.
|
||||||
|
func getFirstMatchOrgProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
||||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -112,14 +133,16 @@ func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchN
|
|||||||
|
|
||||||
orgRule, err := FindOrgBranchRuleForBranch(ctx, owner.ID, branchName)
|
orgRule, err := FindOrgBranchRuleForBranch(ctx, owner.ID, branchName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Fail closed: propagate the error so callers keep the org floor in force
|
||||||
|
// rather than silently falling back to the repo rule on a transient error.
|
||||||
log.Error("FindOrgBranchRuleForBranch: %v", err)
|
log.Error("FindOrgBranchRuleForBranch: %v", err)
|
||||||
return nil, nil
|
return nil, err
|
||||||
}
|
}
|
||||||
if orgRule == nil {
|
if orgRule == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert org rule to a ProtectedBranch with RepoID set so callers work correctly
|
// Convert org rule to a ProtectedBranch with RepoID set so callers work correctly.
|
||||||
pb := orgRule.ToProtectedBranch()
|
pb := orgRule.ToProtectedBranch()
|
||||||
pb.RepoID = repoID
|
pb.RepoID = repoID
|
||||||
return pb, nil
|
return pb, nil
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// mergeMostRestrictive combines a repo-level and an org-level protected-branch rule
|
||||||
|
// that both match the same branch into a single effective rule, always applying the
|
||||||
|
// STRICTER constraint from each side (fail-closed). This makes an org-level rule a
|
||||||
|
// mandatory floor that a repo rule can only tighten, never weaken. See issue #727.
|
||||||
|
//
|
||||||
|
// Combination directions:
|
||||||
|
// - "Can*" / allow booleans -> AND (an action is allowed only if both allow it)
|
||||||
|
// - "Enable*/Block*/Require*" -> OR (a gate is on if either side turns it on)
|
||||||
|
// - RequiredApprovals -> max
|
||||||
|
// - required-set lists -> union (status contexts, protected files)
|
||||||
|
// - allow-set lists -> intersection (whitelists, unprotected files)
|
||||||
|
//
|
||||||
|
// Identity (ID, RepoID, RuleName, Priority) is taken from the repo rule so that
|
||||||
|
// downstream permission checks (which LoadRepo via RepoID) keep working.
|
||||||
|
func mergeMostRestrictive(repoRule, orgRule *ProtectedBranch) *ProtectedBranch {
|
||||||
|
eff := *repoRule
|
||||||
|
|
||||||
|
// Direct push.
|
||||||
|
eff.CanPush = repoRule.CanPush && orgRule.CanPush
|
||||||
|
eff.EnableWhitelist, eff.WhitelistUserIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistUserIDs, orgRule.EnableWhitelist, orgRule.WhitelistUserIDs)
|
||||||
|
_, eff.WhitelistTeamIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistTeamIDs, orgRule.EnableWhitelist, orgRule.WhitelistTeamIDs)
|
||||||
|
// Deploy-key allowances are still not expressible in an OrgProtectedBranch, so the
|
||||||
|
// org rule imposes no constraint on them; carry the repo value through unchanged.
|
||||||
|
// ANDing against the org side's always-false zero value would silently lock out
|
||||||
|
// every deploy-key push in any org that has a matching branch rule (see #727 review).
|
||||||
|
// The Actions-bot toggle IS now expressible org-side, so merge it most-restrictively.
|
||||||
|
eff.WhitelistDeployKeys = repoRule.WhitelistDeployKeys
|
||||||
|
eff.WhitelistActionsUser = mergeAllowFlag(repoRule.EnableWhitelist, repoRule.WhitelistActionsUser, orgRule.EnableWhitelist, orgRule.WhitelistActionsUser)
|
||||||
|
|
||||||
|
// Force push.
|
||||||
|
eff.CanForcePush = repoRule.CanForcePush && orgRule.CanForcePush
|
||||||
|
eff.EnableForcePushAllowlist, eff.ForcePushAllowlistUserIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistUserIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistUserIDs)
|
||||||
|
_, eff.ForcePushAllowlistTeamIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistTeamIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistTeamIDs)
|
||||||
|
eff.ForcePushAllowlistDeployKeys = repoRule.ForcePushAllowlistDeployKeys
|
||||||
|
eff.ForcePushAllowlistActionsUser = mergeAllowFlag(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistActionsUser, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistActionsUser)
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
eff.CanDelete = repoRule.CanDelete && orgRule.CanDelete
|
||||||
|
eff.EnableDeleteAllowlist, eff.DeleteAllowlistUserIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistUserIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistUserIDs)
|
||||||
|
_, eff.DeleteAllowlistTeamIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistTeamIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistTeamIDs)
|
||||||
|
eff.DeleteAllowlistDeployKeys = repoRule.DeleteAllowlistDeployKeys
|
||||||
|
eff.DeleteAllowlistActionsUser = mergeAllowFlag(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistActionsUser, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistActionsUser)
|
||||||
|
|
||||||
|
// Merge whitelist.
|
||||||
|
eff.EnableMergeWhitelist, eff.MergeWhitelistUserIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistUserIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistUserIDs)
|
||||||
|
_, eff.MergeWhitelistTeamIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistTeamIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistTeamIDs)
|
||||||
|
eff.MergeWhitelistActionsUser = mergeAllowFlag(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistActionsUser, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistActionsUser)
|
||||||
|
|
||||||
|
// Status checks.
|
||||||
|
eff.EnableStatusCheck = repoRule.EnableStatusCheck || orgRule.EnableStatusCheck
|
||||||
|
eff.StatusCheckContexts = unionStrings(repoRule.StatusCheckContexts, orgRule.StatusCheckContexts)
|
||||||
|
|
||||||
|
// Approvals and reviews.
|
||||||
|
eff.RequiredApprovals = maxInt64(repoRule.RequiredApprovals, orgRule.RequiredApprovals)
|
||||||
|
eff.EnableApprovalsWhitelist, eff.ApprovalsWhitelistUserIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistUserIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistUserIDs)
|
||||||
|
_, eff.ApprovalsWhitelistTeamIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistTeamIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistTeamIDs)
|
||||||
|
eff.BlockOnRejectedReviews = repoRule.BlockOnRejectedReviews || orgRule.BlockOnRejectedReviews
|
||||||
|
eff.BlockOnOfficialReviewRequests = repoRule.BlockOnOfficialReviewRequests || orgRule.BlockOnOfficialReviewRequests
|
||||||
|
eff.BlockOnOutdatedBranch = repoRule.BlockOnOutdatedBranch || orgRule.BlockOnOutdatedBranch
|
||||||
|
eff.DismissStaleApprovals = repoRule.DismissStaleApprovals || orgRule.DismissStaleApprovals
|
||||||
|
eff.IgnoreStaleApprovals = repoRule.IgnoreStaleApprovals || orgRule.IgnoreStaleApprovals
|
||||||
|
|
||||||
|
// Commits, files, admin override.
|
||||||
|
eff.RequireSignedCommits = repoRule.RequireSignedCommits || orgRule.RequireSignedCommits
|
||||||
|
eff.ProtectedFilePatterns = unionPatterns(repoRule.ProtectedFilePatterns, orgRule.ProtectedFilePatterns)
|
||||||
|
eff.UnprotectedFilePatterns = intersectPatterns(repoRule.UnprotectedFilePatterns, orgRule.UnprotectedFilePatterns)
|
||||||
|
eff.BlockAdminMergeOverride = repoRule.BlockAdminMergeOverride || orgRule.BlockAdminMergeOverride
|
||||||
|
|
||||||
|
return &eff
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAllowlist combines two allow-lists under most-restrictive semantics. An
|
||||||
|
// allow-list only narrows access when its Enable flag is set; a disabled list means
|
||||||
|
// "everyone", so it imposes no constraint. Therefore: if both are enabled the result
|
||||||
|
// is the intersection (a principal must be allowed by both); if only one is enabled
|
||||||
|
// its list is used as-is; if neither is enabled the list is irrelevant.
|
||||||
|
func mergeAllowlist(aEnabled bool, aIDs []int64, bEnabled bool, bIDs []int64) (bool, []int64) {
|
||||||
|
switch {
|
||||||
|
case aEnabled && bEnabled:
|
||||||
|
return true, intersectInt64(aIDs, bIDs)
|
||||||
|
case aEnabled:
|
||||||
|
return true, aIDs
|
||||||
|
case bEnabled:
|
||||||
|
return true, bIDs
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAllowFlag combines two "allow the Actions bot" toggles under most-restrictive
|
||||||
|
// semantics, mirroring mergeAllowlist. A toggle only grants access when its Enable flag
|
||||||
|
// is set; a disabled allowlist means "everyone", so it imposes no constraint. Therefore:
|
||||||
|
// if both allowlists are enabled the bot is allowed only when BOTH sides allow it; if
|
||||||
|
// only one is enabled that side's flag governs; if neither is enabled the flag is
|
||||||
|
// irrelevant (fall back to the repo/a value).
|
||||||
|
func mergeAllowFlag(aEnabled, aFlag, bEnabled, bFlag bool) bool {
|
||||||
|
switch {
|
||||||
|
case aEnabled && bEnabled:
|
||||||
|
return aFlag && bFlag
|
||||||
|
case aEnabled:
|
||||||
|
return aFlag
|
||||||
|
case bEnabled:
|
||||||
|
return bFlag
|
||||||
|
default:
|
||||||
|
return aFlag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intersectInt64(a, b []int64) []int64 {
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[int64]struct{}, len(a))
|
||||||
|
for _, x := range a {
|
||||||
|
set[x] = struct{}{}
|
||||||
|
}
|
||||||
|
var out []int64
|
||||||
|
for _, x := range b {
|
||||||
|
if _, ok := set[x]; ok {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func unionStrings(a, b []string) []string {
|
||||||
|
if len(a) == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(a)+len(b))
|
||||||
|
out := make([]string, 0, len(a)+len(b))
|
||||||
|
for _, s := range a {
|
||||||
|
if _, ok := seen[s]; !ok {
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range b {
|
||||||
|
if _, ok := seen[s]; !ok {
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unionPatterns unions two ';'-separated file-pattern lists (more patterns protected
|
||||||
|
// = more restrictive).
|
||||||
|
func unionPatterns(a, b string) string {
|
||||||
|
return strings.Join(unionStrings(splitPatterns(a), splitPatterns(b)), ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
// intersectPatterns intersects two ';'-separated file-pattern lists. Unprotected
|
||||||
|
// patterns are carve-outs that REDUCE protection, so the restrictive combination
|
||||||
|
// keeps only the exemptions present in both.
|
||||||
|
func intersectPatterns(a, b string) string {
|
||||||
|
as, bs := splitPatterns(a), splitPatterns(b)
|
||||||
|
set := make(map[string]struct{}, len(as))
|
||||||
|
for _, s := range as {
|
||||||
|
set[s] = struct{}{}
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(bs))
|
||||||
|
var out []string
|
||||||
|
for _, s := range bs {
|
||||||
|
if _, ok := set[s]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[s]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPatterns(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ";") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt64(a, b int64) int64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeAllowFlag(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
aEnabled bool
|
||||||
|
aFlag bool
|
||||||
|
bEnabled bool
|
||||||
|
bFlag bool
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// Both allowlists enabled: bot allowed only when BOTH allow it.
|
||||||
|
{"both enabled, both allow", true, true, true, true, true},
|
||||||
|
{"both enabled, a denies", true, false, true, true, false},
|
||||||
|
{"both enabled, b denies", true, true, true, false, false},
|
||||||
|
{"both enabled, both deny", true, false, true, false, false},
|
||||||
|
// Only one side enabled: that side governs.
|
||||||
|
{"only a enabled, a allows", true, true, false, false, true},
|
||||||
|
{"only a enabled, a denies", true, false, false, true, false},
|
||||||
|
{"only b enabled, b allows", false, false, true, true, true},
|
||||||
|
{"only b enabled, b denies", false, true, true, false, false},
|
||||||
|
// Neither enabled: flag irrelevant, falls back to a's flag.
|
||||||
|
{"neither enabled, a true", false, true, false, false, true},
|
||||||
|
{"neither enabled, a false", false, false, false, true, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.expected, mergeAllowFlag(c.aEnabled, c.aFlag, c.bEnabled, c.bFlag))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeMostRestrictiveUserIDsIntersect(t *testing.T) {
|
||||||
|
repoRule := &ProtectedBranch{
|
||||||
|
EnableWhitelist: true,
|
||||||
|
WhitelistUserIDs: []int64{1, 2, 3},
|
||||||
|
// Deploy keys are repo-only and must always pass through unchanged.
|
||||||
|
WhitelistDeployKeys: true,
|
||||||
|
ForcePushAllowlistDeployKeys: true,
|
||||||
|
DeleteAllowlistDeployKeys: true,
|
||||||
|
}
|
||||||
|
orgRule := &ProtectedBranch{
|
||||||
|
EnableWhitelist: true,
|
||||||
|
WhitelistUserIDs: []int64{2, 3, 4},
|
||||||
|
// Org rule cannot express deploy keys; these zero values must NOT override repo.
|
||||||
|
}
|
||||||
|
|
||||||
|
eff := mergeMostRestrictive(repoRule, orgRule)
|
||||||
|
|
||||||
|
// User IDs are intersected when both allowlists are enabled.
|
||||||
|
assert.True(t, eff.EnableWhitelist)
|
||||||
|
assert.ElementsMatch(t, []int64{2, 3}, eff.WhitelistUserIDs)
|
||||||
|
|
||||||
|
// Deploy-key allowances still pass through from the repo rule.
|
||||||
|
assert.True(t, eff.WhitelistDeployKeys)
|
||||||
|
assert.True(t, eff.ForcePushAllowlistDeployKeys)
|
||||||
|
assert.True(t, eff.DeleteAllowlistDeployKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeMostRestrictiveActionsUserFlags(t *testing.T) {
|
||||||
|
// Both sides enable their allowlist and both allow the bot -> allowed.
|
||||||
|
repoRule := &ProtectedBranch{
|
||||||
|
EnableWhitelist: true,
|
||||||
|
WhitelistActionsUser: true,
|
||||||
|
EnableForcePushAllowlist: true,
|
||||||
|
// force-push: org denies -> effective deny.
|
||||||
|
ForcePushAllowlistActionsUser: true,
|
||||||
|
EnableDeleteAllowlist: true,
|
||||||
|
DeleteAllowlistActionsUser: true,
|
||||||
|
// merge: repo allowlist disabled, org enabled+denies -> org governs (deny).
|
||||||
|
EnableMergeWhitelist: false,
|
||||||
|
MergeWhitelistActionsUser: true,
|
||||||
|
}
|
||||||
|
orgRule := &ProtectedBranch{
|
||||||
|
EnableWhitelist: true,
|
||||||
|
WhitelistActionsUser: true,
|
||||||
|
EnableForcePushAllowlist: true,
|
||||||
|
ForcePushAllowlistActionsUser: false,
|
||||||
|
EnableDeleteAllowlist: true,
|
||||||
|
DeleteAllowlistActionsUser: true,
|
||||||
|
EnableMergeWhitelist: true,
|
||||||
|
MergeWhitelistActionsUser: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
eff := mergeMostRestrictive(repoRule, orgRule)
|
||||||
|
|
||||||
|
assert.True(t, eff.WhitelistActionsUser, "push: both allow")
|
||||||
|
assert.False(t, eff.ForcePushAllowlistActionsUser, "force-push: org denies")
|
||||||
|
assert.True(t, eff.DeleteAllowlistActionsUser, "delete: both allow")
|
||||||
|
assert.False(t, eff.MergeWhitelistActionsUser, "merge: org enabled and denies")
|
||||||
|
}
|
||||||
@@ -439,6 +439,12 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
|
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
|
||||||
newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch),
|
newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch),
|
||||||
newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable),
|
newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable),
|
||||||
|
newMigration(362, "Add delete allowlist to org protected branch", v1_27.AddDeleteAllowlistToOrgProtectedBranch),
|
||||||
|
newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable),
|
||||||
|
newMigration(364, "Add org push policy table", v1_27.AddOrgPushPolicyTable),
|
||||||
|
newMigration(365, "Add org repo defaults table", v1_27.AddOrgRepoDefaultsTable),
|
||||||
|
newMigration(366, "Add org email domain policy table", v1_27.AddOrgEmailDomainPolicyTable),
|
||||||
|
newMigration(367, "Add user allowlists to org protected branch", v1_27.AddUserAllowlistsToOrgProtectedBranch),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import "xorm.io/xorm"
|
||||||
|
|
||||||
|
// AddDeleteAllowlistToOrgProtectedBranch adds branch-deletion protection columns to
|
||||||
|
// org-level branch protection rules, mirroring the per-repo delete allowlist so an
|
||||||
|
// org rule can also protect branches from deletion. See issue #727.
|
||||||
|
func AddDeleteAllowlistToOrgProtectedBranch(x *xorm.Engine) error {
|
||||||
|
type OrgProtectedBranch struct {
|
||||||
|
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgProtectedBranch))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddOrgProtectedTagTable creates the org-level tag protection table. Org tag rules
|
||||||
|
// cascade to all repositories in the organization and layer on top of each repo's
|
||||||
|
// own protected tags. See issue #727.
|
||||||
|
func AddOrgProtectedTagTable(x *xorm.Engine) error {
|
||||||
|
type OrgProtectedTag struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||||
|
NamePattern string `xorm:"UNIQUE(s)"`
|
||||||
|
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgProtectedTag))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddOrgPushPolicyTable creates the org-level push policy table (one row per org),
|
||||||
|
// enforced in the pre-receive hook across all repositories of the org. See #727.
|
||||||
|
func AddOrgPushPolicyTable(x *xorm.Engine) error {
|
||||||
|
type OrgPushPolicy struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
BranchNamePattern string `xorm:"TEXT"`
|
||||||
|
TagNamePattern string `xorm:"TEXT"`
|
||||||
|
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
BlockedFilePatterns string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgPushPolicy))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddOrgRepoDefaultsTable creates the org repository-defaults table (one row per
|
||||||
|
// org), applied to repositories created in or transferred into the org. See #727.
|
||||||
|
func AddOrgRepoDefaultsTable(x *xorm.Engine) error {
|
||||||
|
type OrgRepoDefaults struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
DefaultMergeStyle string `xorm:"TEXT"`
|
||||||
|
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgRepoDefaults))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddOrgEmailDomainPolicyTable creates the org email-domain policy table (one row
|
||||||
|
// per org) restricting the email domains of members added to the org. See #727.
|
||||||
|
func AddOrgEmailDomainPolicyTable(x *xorm.Engine) error {
|
||||||
|
type OrgEmailDomainPolicy struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
AllowedDomains string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgEmailDomainPolicy))
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import "xorm.io/xorm"
|
||||||
|
|
||||||
|
// AddUserAllowlistsToOrgProtectedBranch adds per-user allowlist columns (username or
|
||||||
|
// email resolved to user IDs) plus the "allow Actions bot" toggles to org-level branch
|
||||||
|
// protection rules for the push, merge, force-push and delete categories, mirroring the
|
||||||
|
// per-repo user allowlists so an org rule can also grant access to individual users and
|
||||||
|
// the Actions bot. See issue #727.
|
||||||
|
func AddUserAllowlistsToOrgProtectedBranch(x *xorm.Engine) error {
|
||||||
|
type OrgProtectedBranch struct {
|
||||||
|
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(OrgProtectedBranch))
|
||||||
|
}
|
||||||
@@ -14,16 +14,28 @@ type OrgBranchProtection struct {
|
|||||||
EnablePush bool `json:"enable_push"`
|
EnablePush bool `json:"enable_push"`
|
||||||
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||||
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistActionsUser bool `json:"push_whitelist_actions_user"`
|
||||||
EnableForcePush bool `json:"enable_force_push"`
|
EnableForcePush bool `json:"enable_force_push"`
|
||||||
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
|
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistActionsUser bool `json:"merge_whitelist_actions_user"`
|
||||||
EnableStatusCheck bool `json:"enable_status_check"`
|
EnableStatusCheck bool `json:"enable_status_check"`
|
||||||
StatusCheckContexts []string `json:"status_check_contexts"`
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
RequiredApprovals int64 `json:"required_approvals"`
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
||||||
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
||||||
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
||||||
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
||||||
@@ -46,16 +58,28 @@ type CreateOrgBranchProtectionOption struct {
|
|||||||
EnablePush bool `json:"enable_push"`
|
EnablePush bool `json:"enable_push"`
|
||||||
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||||
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistActionsUser bool `json:"push_whitelist_actions_user"`
|
||||||
EnableForcePush bool `json:"enable_force_push"`
|
EnableForcePush bool `json:"enable_force_push"`
|
||||||
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
|
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistActionsUser bool `json:"merge_whitelist_actions_user"`
|
||||||
EnableStatusCheck bool `json:"enable_status_check"`
|
EnableStatusCheck bool `json:"enable_status_check"`
|
||||||
StatusCheckContexts []string `json:"status_check_contexts"`
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
RequiredApprovals int64 `json:"required_approvals"`
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
||||||
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
||||||
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
||||||
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
||||||
@@ -73,16 +97,28 @@ type EditOrgBranchProtectionOption struct {
|
|||||||
EnablePush *bool `json:"enable_push"`
|
EnablePush *bool `json:"enable_push"`
|
||||||
EnablePushWhitelist *bool `json:"enable_push_whitelist"`
|
EnablePushWhitelist *bool `json:"enable_push_whitelist"`
|
||||||
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistActionsUser *bool `json:"push_whitelist_actions_user"`
|
||||||
EnableForcePush *bool `json:"enable_force_push"`
|
EnableForcePush *bool `json:"enable_force_push"`
|
||||||
EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"`
|
EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
|
ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete *bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistActionsUser *bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistActionsUser *bool `json:"merge_whitelist_actions_user"`
|
||||||
EnableStatusCheck *bool `json:"enable_status_check"`
|
EnableStatusCheck *bool `json:"enable_status_check"`
|
||||||
StatusCheckContexts []string `json:"status_check_contexts"`
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
RequiredApprovals *int64 `json:"required_approvals"`
|
RequiredApprovals *int64 `json:"required_approvals"`
|
||||||
EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"`
|
EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"`
|
||||||
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
|
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
|
||||||
BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"`
|
BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"`
|
||||||
BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"`
|
BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"`
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
// OrgEmailDomainPolicy represents an organization's email domain policy
|
||||||
|
type OrgEmailDomainPolicy struct {
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
AllowedDomains string `json:"allowed_domains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgEmailDomainPolicyOption options for editing an org's email domain policy
|
||||||
|
type EditOrgEmailDomainPolicyOption struct {
|
||||||
|
AllowedDomains *string `json:"allowed_domains"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OrgPushPolicy represents an organization's push policy (one per org)
|
||||||
|
type OrgPushPolicy struct {
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
BranchNamePattern string `json:"branch_name_pattern"`
|
||||||
|
TagNamePattern string `json:"tag_name_pattern"`
|
||||||
|
RequireSecretBlock bool `json:"require_secret_block"`
|
||||||
|
MaxFileSize int64 `json:"max_file_size"`
|
||||||
|
BlockedFilePatterns string `json:"blocked_file_patterns"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgPushPolicyOption options for editing an organization's push policy. Only
|
||||||
|
// fields that are set will be changed.
|
||||||
|
type EditOrgPushPolicyOption struct {
|
||||||
|
BranchNamePattern *string `json:"branch_name_pattern"`
|
||||||
|
TagNamePattern *string `json:"tag_name_pattern"`
|
||||||
|
RequireSecretBlock *bool `json:"require_secret_block"`
|
||||||
|
MaxFileSize *int64 `json:"max_file_size"`
|
||||||
|
BlockedFilePatterns *string `json:"blocked_file_patterns"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
// OrgRepoDefaults represents an organization's default repository settings
|
||||||
|
type OrgRepoDefaults struct {
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
ForcePrivate bool `json:"force_private"`
|
||||||
|
ApplyPRDefaults bool `json:"apply_pr_defaults"`
|
||||||
|
AllowMerge bool `json:"allow_merge"`
|
||||||
|
AllowRebase bool `json:"allow_rebase"`
|
||||||
|
AllowRebaseMerge bool `json:"allow_rebase_merge"`
|
||||||
|
AllowSquash bool `json:"allow_squash"`
|
||||||
|
AllowFastForwardOnly bool `json:"allow_fast_forward_only"`
|
||||||
|
DefaultMergeStyle string `json:"default_merge_style"`
|
||||||
|
DeleteBranchAfterMerge bool `json:"delete_branch_after_merge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgRepoDefaultsOption options for editing an org's repo defaults. Only fields
|
||||||
|
// that are set will be changed.
|
||||||
|
type EditOrgRepoDefaultsOption struct {
|
||||||
|
ForcePrivate *bool `json:"force_private"`
|
||||||
|
ApplyPRDefaults *bool `json:"apply_pr_defaults"`
|
||||||
|
AllowMerge *bool `json:"allow_merge"`
|
||||||
|
AllowRebase *bool `json:"allow_rebase"`
|
||||||
|
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
||||||
|
AllowSquash *bool `json:"allow_squash"`
|
||||||
|
AllowFastForwardOnly *bool `json:"allow_fast_forward_only"`
|
||||||
|
DefaultMergeStyle *string `json:"default_merge_style"`
|
||||||
|
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OrgTagProtection represents an org-level tag protection rule
|
||||||
|
type OrgTagProtection struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
NamePattern string `json:"name_pattern"`
|
||||||
|
WhitelistTeams []string `json:"whitelist_teams"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgTagProtectionOption options for creating an org-level tag protection
|
||||||
|
type CreateOrgTagProtectionOption struct {
|
||||||
|
NamePattern string `json:"name_pattern" binding:"Required"`
|
||||||
|
WhitelistTeams []string `json:"whitelist_teams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgTagProtectionOption options for editing an org-level tag protection
|
||||||
|
type EditOrgTagProtectionOption struct {
|
||||||
|
NamePattern *string `json:"name_pattern"`
|
||||||
|
WhitelistTeams []string `json:"whitelist_teams"`
|
||||||
|
}
|
||||||
+18
-10
@@ -86,31 +86,35 @@ func Test_NormalizeEOL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_RandomInt(t *testing.T) {
|
func Test_RandomInt(t *testing.T) {
|
||||||
randInt := CryptoRandomInt(255)
|
randInt, err := CryptoRandomInt(255)
|
||||||
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, randInt, int64(0))
|
assert.GreaterOrEqual(t, randInt, int64(0))
|
||||||
assert.LessOrEqual(t, randInt, int64(255))
|
assert.LessOrEqual(t, randInt, int64(255))
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_RandomString(t *testing.T) {
|
func Test_RandomString(t *testing.T) {
|
||||||
str1 := CryptoRandomString(32)
|
str1, err := CryptoRandomString(32)
|
||||||
var err error
|
assert.NoError(t, err)
|
||||||
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, matches)
|
assert.True(t, matches)
|
||||||
|
|
||||||
str2 := CryptoRandomString(32)
|
str2, err := CryptoRandomString(32)
|
||||||
|
assert.NoError(t, err)
|
||||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, matches)
|
assert.True(t, matches)
|
||||||
|
|
||||||
assert.NotEqual(t, str1, str2)
|
assert.NotEqual(t, str1, str2)
|
||||||
|
|
||||||
str3 := CryptoRandomString(256)
|
str3, err := CryptoRandomString(256)
|
||||||
|
assert.NoError(t, err)
|
||||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
|
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, matches)
|
assert.True(t, matches)
|
||||||
|
|
||||||
str4 := CryptoRandomString(256)
|
str4, err := CryptoRandomString(256)
|
||||||
|
assert.NoError(t, err)
|
||||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
|
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, matches)
|
assert.True(t, matches)
|
||||||
@@ -119,15 +123,19 @@ func Test_RandomString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_RandomBytes(t *testing.T) {
|
func Test_RandomBytes(t *testing.T) {
|
||||||
bytes1 := CryptoRandomBytes(32)
|
bytes1, err := CryptoRandomBytes(32)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
bytes2 := CryptoRandomBytes(32)
|
bytes2, err := CryptoRandomBytes(32)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEqual(t, bytes1, bytes2)
|
assert.NotEqual(t, bytes1, bytes2)
|
||||||
|
|
||||||
bytes3 := CryptoRandomBytes(256)
|
bytes3, err := CryptoRandomBytes(256)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
bytes4 := CryptoRandomBytes(256)
|
bytes4, err := CryptoRandomBytes(256)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEqual(t, bytes3, bytes4)
|
assert.NotEqual(t, bytes3, bytes4)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2411,6 +2411,31 @@
|
|||||||
"repo.settings.protected_branch": "Branch Protection",
|
"repo.settings.protected_branch": "Branch Protection",
|
||||||
"repo.settings.protected_branch.save_rule": "Save Rule",
|
"repo.settings.protected_branch.save_rule": "Save Rule",
|
||||||
"repo.settings.protected_branch.delete_rule": "Delete Rule",
|
"repo.settings.protected_branch.delete_rule": "Delete Rule",
|
||||||
|
"repo.settings.org_protected_branch": "Organization Branch Protection",
|
||||||
|
"repo.settings.org_protected_branch_desc": "These rules are defined by the organization and are enforced on top of this repository's own rules — the stricter of the two applies. They cannot be edited here.",
|
||||||
|
"repo.settings.org_protected_branch.inherited": "Organization",
|
||||||
|
"repo.settings.org_protected_branch.read_only": "Read-only",
|
||||||
|
"repo.settings.org_protected_branch.approvals": "Required approvals",
|
||||||
|
"repo.settings.org_protected_branch.signed": "Signed commits",
|
||||||
|
"repo.settings.org_protected_branch.status_check": "Required status checks",
|
||||||
|
"repo.settings.org_protected_branch.direct_push": "Direct push",
|
||||||
|
"repo.settings.org_protected_branch.force_push": "Force push",
|
||||||
|
"repo.settings.org_protected_branch.deletion": "Branch deletion",
|
||||||
|
"repo.settings.org_protected_branch.merge": "Merge restricted to",
|
||||||
|
"repo.settings.org_protected_branch.protected_files": "Protected files",
|
||||||
|
"repo.settings.org_protected_branch.also": "Also enforces",
|
||||||
|
"repo.settings.org_protected_branch.blocked": "Blocked",
|
||||||
|
"repo.settings.org_protected_branch.allowed": "Allowed",
|
||||||
|
"repo.settings.org_protected_branch.restricted": "Restricted to specific teams",
|
||||||
|
"repo.settings.org_protected_branch.write_access": "Anyone with write access",
|
||||||
|
"repo.settings.org_protected_branch.teams": "Teams: %s",
|
||||||
|
"repo.settings.org_protected_branch.any": "Any configured checks",
|
||||||
|
"repo.settings.org_protected_branch.block_outdated": "Block on outdated branch",
|
||||||
|
"repo.settings.org_protected_branch.block_rejected": "Block on rejected reviews",
|
||||||
|
"repo.settings.org_protected_branch.block_admin": "Block admin merge override",
|
||||||
|
"repo.settings.org_protected_tag": "Organization Tag Protection",
|
||||||
|
"repo.settings.org_protected_tag_desc": "These tag protection rules are defined by the organization and are enforced on top of this repository's own rules. They cannot be edited here.",
|
||||||
|
"repo.settings.org_protected_tag.read_only": "Read-only",
|
||||||
"repo.settings.protected_branch_can_push": "Allow push?",
|
"repo.settings.protected_branch_can_push": "Allow push?",
|
||||||
"repo.settings.protected_branch_can_push_yes": "You can push",
|
"repo.settings.protected_branch_can_push_yes": "You can push",
|
||||||
"repo.settings.protected_branch_can_push_no": "You cannot push",
|
"repo.settings.protected_branch_can_push_no": "You cannot push",
|
||||||
@@ -2439,7 +2464,7 @@
|
|||||||
"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_branch_deletion": "Branch Deletion",
|
||||||
"repo.settings.protect_disable_delete": "Disable Deletion",
|
"repo.settings.protect_disable_delete": "Disable Deletion",
|
||||||
"repo.settings.protect_disable_delete_desc": "This branch cannot be deleted.",
|
"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": "Enable Deletion",
|
||||||
|
|||||||
+52
-24
@@ -516,7 +516,7 @@ func reqOrgVisible() func(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
if !organization.IsOwnerVisibleToDoer(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||||
ctx.APIErrorNotFound()
|
ctx.APIErrorNotFound()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1698,29 +1698,29 @@ func Routes() *web.Router {
|
|||||||
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
|
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
|
||||||
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
|
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
|
||||||
})
|
})
|
||||||
// m.Group("/projects", func() {
|
// m.Group("/projects", func() {
|
||||||
// m.Combo("").Get(repo.ListProjects).
|
// m.Combo("").Get(repo.ListProjects).
|
||||||
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectOption{}), repo.CreateProject)
|
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectOption{}), repo.CreateProject)
|
||||||
// m.Group("/{id}", func() {
|
// m.Group("/{id}", func() {
|
||||||
// m.Combo("").Get(repo.GetProject).
|
// m.Combo("").Get(repo.GetProject).
|
||||||
// Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject).
|
// Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject).
|
||||||
// Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject)
|
// Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject)
|
||||||
// m.Post("/{action}", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ChangeProjectStatus)
|
// m.Post("/{action}", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ChangeProjectStatus)
|
||||||
// m.Group("/columns", func() {
|
// m.Group("/columns", func() {
|
||||||
// m.Combo("").Get(repo.ListProjectColumns).
|
// m.Combo("").Get(repo.ListProjectColumns).
|
||||||
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
|
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
|
||||||
// m.Group("/{columnId}", func() {
|
// m.Group("/{columnId}", func() {
|
||||||
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn)
|
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn)
|
||||||
// m.Combo("/issues").Get(repo.ListProjectColumnIssues).
|
// m.Combo("/issues").Get(repo.ListProjectColumnIssues).
|
||||||
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.AddProjectColumnIssueOption{}), repo.AddIssueToColumn)
|
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.AddProjectColumnIssueOption{}), repo.AddIssueToColumn)
|
||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
// m.Group("/issues/{issueId}", func() {
|
// m.Group("/issues/{issueId}", func() {
|
||||||
// m.Patch("/move", reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.MoveProjectColumnIssueOption{}), repo.MoveIssueOnColumn)
|
// m.Patch("/move", reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.MoveProjectColumnIssueOption{}), repo.MoveIssueOnColumn)
|
||||||
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.RemoveIssueFromProject)
|
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.RemoveIssueFromProject)
|
||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
// Repo custom fields (repo-scoped key-value metadata)
|
// Repo custom fields (repo-scoped key-value metadata)
|
||||||
m.Group("/custom-fields", func() {
|
m.Group("/custom-fields", func() {
|
||||||
m.Get("", repo.GetRepoCustomFields)
|
m.Get("", repo.GetRepoCustomFields)
|
||||||
@@ -1823,6 +1823,34 @@ func Routes() *web.Router {
|
|||||||
})
|
})
|
||||||
}, reqToken(), reqOrgOwnership())
|
}, reqToken(), reqOrgOwnership())
|
||||||
|
|
||||||
|
m.Group("/tag_protections", func() {
|
||||||
|
m.Combo("").Get(org.ListOrgTagProtections).
|
||||||
|
Post(bind(api.CreateOrgTagProtectionOption{}), org.CreateOrgTagProtection)
|
||||||
|
m.Group("/{id}", func() {
|
||||||
|
m.Get("", org.GetOrgTagProtection)
|
||||||
|
m.Patch("", bind(api.EditOrgTagProtectionOption{}), org.EditOrgTagProtection)
|
||||||
|
m.Delete("", org.DeleteOrgTagProtection)
|
||||||
|
})
|
||||||
|
}, reqToken(), reqOrgOwnership())
|
||||||
|
|
||||||
|
m.Group("/push_policy", func() {
|
||||||
|
m.Combo("").Get(org.GetOrgPushPolicy).
|
||||||
|
Patch(bind(api.EditOrgPushPolicyOption{}), org.EditOrgPushPolicy).
|
||||||
|
Delete(org.DeleteOrgPushPolicy)
|
||||||
|
}, reqToken(), reqOrgOwnership())
|
||||||
|
|
||||||
|
m.Group("/repo_defaults", func() {
|
||||||
|
m.Combo("").Get(org.GetOrgRepoDefaults).
|
||||||
|
Patch(bind(api.EditOrgRepoDefaultsOption{}), org.EditOrgRepoDefaults).
|
||||||
|
Delete(org.DeleteOrgRepoDefaults)
|
||||||
|
}, reqToken(), reqOrgOwnership())
|
||||||
|
|
||||||
|
m.Group("/email_domain_policy", func() {
|
||||||
|
m.Combo("").Get(org.GetOrgEmailDomainPolicy).
|
||||||
|
Patch(bind(api.EditOrgEmailDomainPolicyOption{}), org.EditOrgEmailDomainPolicy).
|
||||||
|
Delete(org.DeleteOrgEmailDomainPolicy)
|
||||||
|
}, reqToken(), reqOrgOwnership())
|
||||||
|
|
||||||
m.Group("/blocks", func() {
|
m.Group("/blocks", func() {
|
||||||
m.Get("", org.ListBlocks)
|
m.Get("", org.ListBlocks)
|
||||||
m.Group("/{username}", func() {
|
m.Group("/{username}", func() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||||
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
@@ -36,6 +37,18 @@ func toAPIOrgBranchProtection(ctx *context.APIContext, rule *git_model.OrgProtec
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveUserNames := func(ids []int64) []string {
|
||||||
|
names := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, u.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
return &api.OrgBranchProtection{
|
return &api.OrgBranchProtection{
|
||||||
ID: rule.ID,
|
ID: rule.ID,
|
||||||
OrgID: rule.OrgID,
|
OrgID: rule.OrgID,
|
||||||
@@ -44,16 +57,28 @@ func toAPIOrgBranchProtection(ctx *context.APIContext, rule *git_model.OrgProtec
|
|||||||
EnablePush: rule.CanPush,
|
EnablePush: rule.CanPush,
|
||||||
EnablePushWhitelist: rule.EnableWhitelist,
|
EnablePushWhitelist: rule.EnableWhitelist,
|
||||||
PushWhitelistTeams: resolveTeamNames(rule.WhitelistTeamIDs),
|
PushWhitelistTeams: resolveTeamNames(rule.WhitelistTeamIDs),
|
||||||
|
PushWhitelistUsernames: resolveUserNames(rule.WhitelistUserIDs),
|
||||||
|
PushWhitelistActionsUser: rule.WhitelistActionsUser,
|
||||||
EnableForcePush: rule.CanForcePush,
|
EnableForcePush: rule.CanForcePush,
|
||||||
EnableForcePushAllowlist: rule.EnableForcePushAllowlist,
|
EnableForcePushAllowlist: rule.EnableForcePushAllowlist,
|
||||||
ForcePushAllowlistTeams: resolveTeamNames(rule.ForcePushAllowlistTeamIDs),
|
ForcePushAllowlistTeams: resolveTeamNames(rule.ForcePushAllowlistTeamIDs),
|
||||||
|
ForcePushAllowlistUsernames: resolveUserNames(rule.ForcePushAllowlistUserIDs),
|
||||||
|
ForcePushAllowlistActionsUser: rule.ForcePushAllowlistActionsUser,
|
||||||
|
EnableDelete: rule.CanDelete,
|
||||||
|
EnableDeleteAllowlist: rule.EnableDeleteAllowlist,
|
||||||
|
DeleteAllowlistTeams: resolveTeamNames(rule.DeleteAllowlistTeamIDs),
|
||||||
|
DeleteAllowlistUsernames: resolveUserNames(rule.DeleteAllowlistUserIDs),
|
||||||
|
DeleteAllowlistActionsUser: rule.DeleteAllowlistActionsUser,
|
||||||
EnableMergeWhitelist: rule.EnableMergeWhitelist,
|
EnableMergeWhitelist: rule.EnableMergeWhitelist,
|
||||||
MergeWhitelistTeams: resolveTeamNames(rule.MergeWhitelistTeamIDs),
|
MergeWhitelistTeams: resolveTeamNames(rule.MergeWhitelistTeamIDs),
|
||||||
|
MergeWhitelistUsernames: resolveUserNames(rule.MergeWhitelistUserIDs),
|
||||||
|
MergeWhitelistActionsUser: rule.MergeWhitelistActionsUser,
|
||||||
EnableStatusCheck: rule.EnableStatusCheck,
|
EnableStatusCheck: rule.EnableStatusCheck,
|
||||||
StatusCheckContexts: rule.StatusCheckContexts,
|
StatusCheckContexts: rule.StatusCheckContexts,
|
||||||
RequiredApprovals: rule.RequiredApprovals,
|
RequiredApprovals: rule.RequiredApprovals,
|
||||||
EnableApprovalsWhitelist: rule.EnableApprovalsWhitelist,
|
EnableApprovalsWhitelist: rule.EnableApprovalsWhitelist,
|
||||||
ApprovalsWhitelistTeams: resolveTeamNames(rule.ApprovalsWhitelistTeamIDs),
|
ApprovalsWhitelistTeams: resolveTeamNames(rule.ApprovalsWhitelistTeamIDs),
|
||||||
|
ApprovalsWhitelistUsernames: resolveUserNames(rule.ApprovalsWhitelistUserIDs),
|
||||||
BlockOnRejectedReviews: rule.BlockOnRejectedReviews,
|
BlockOnRejectedReviews: rule.BlockOnRejectedReviews,
|
||||||
BlockOnOfficialReviewRequests: rule.BlockOnOfficialReviewRequests,
|
BlockOnOfficialReviewRequests: rule.BlockOnOfficialReviewRequests,
|
||||||
BlockOnOutdatedBranch: rule.BlockOnOutdatedBranch,
|
BlockOnOutdatedBranch: rule.BlockOnOutdatedBranch,
|
||||||
@@ -85,6 +110,41 @@ func resolveTeamIDs(ctx *context.APIContext, orgID int64, names []string) ([]int
|
|||||||
return ids, true
|
return ids, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveUserIDs converts a list of user identifiers (each a username or an email
|
||||||
|
// address) to deduped user IDs, returning 422 on the first unknown identifier. Each
|
||||||
|
// entry is looked up by username first, then falls back to an activated email address.
|
||||||
|
func resolveUserIDs(ctx *context.APIContext, names []string) ([]int64, bool) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(names))
|
||||||
|
ids := make([]int64, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
u, err := user_model.GetUserByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
if !user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
u, err = user_model.GetUserByEmail(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "unknown user: "+name)
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, dup := seen[u.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[u.ID] = struct{}{}
|
||||||
|
ids = append(ids, u.ID)
|
||||||
|
}
|
||||||
|
return ids, true
|
||||||
|
}
|
||||||
|
|
||||||
// ListOrgBranchProtections list org-level branch protection rules
|
// ListOrgBranchProtections list org-level branch protection rules
|
||||||
func ListOrgBranchProtections(ctx *context.APIContext) {
|
func ListOrgBranchProtections(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /orgs/{org}/branch_protections organization orgListBranchProtections
|
// swagger:operation GET /orgs/{org}/branch_protections organization orgListBranchProtections
|
||||||
@@ -211,6 +271,30 @@ func CreateOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteTeams, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushUsers, ok := resolveUserIDs(ctx, form.PushWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forcePushUsers, ok := resolveUserIDs(ctx, form.ForcePushAllowlistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeUsers, ok := resolveUserIDs(ctx, form.MergeWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
approvalsUsers, ok := resolveUserIDs(ctx, form.ApprovalsWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteUsers, ok := resolveUserIDs(ctx, form.DeleteAllowlistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rule := &git_model.OrgProtectedBranch{
|
rule := &git_model.OrgProtectedBranch{
|
||||||
OrgID: orgID,
|
OrgID: orgID,
|
||||||
@@ -219,16 +303,28 @@ func CreateOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
CanPush: form.EnablePush,
|
CanPush: form.EnablePush,
|
||||||
EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
|
EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
|
||||||
WhitelistTeamIDs: pushTeams,
|
WhitelistTeamIDs: pushTeams,
|
||||||
|
WhitelistUserIDs: pushUsers,
|
||||||
|
WhitelistActionsUser: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistActionsUser,
|
||||||
CanForcePush: form.EnablePush && form.EnableForcePush,
|
CanForcePush: form.EnablePush && form.EnableForcePush,
|
||||||
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
||||||
ForcePushAllowlistTeamIDs: forcePushTeams,
|
ForcePushAllowlistTeamIDs: forcePushTeams,
|
||||||
|
ForcePushAllowlistUserIDs: forcePushUsers,
|
||||||
|
ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser,
|
||||||
|
CanDelete: form.EnableDelete,
|
||||||
|
EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist,
|
||||||
|
DeleteAllowlistTeamIDs: deleteTeams,
|
||||||
|
DeleteAllowlistUserIDs: deleteUsers,
|
||||||
|
DeleteAllowlistActionsUser: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistActionsUser,
|
||||||
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
||||||
MergeWhitelistTeamIDs: mergeTeams,
|
MergeWhitelistTeamIDs: mergeTeams,
|
||||||
|
MergeWhitelistUserIDs: mergeUsers,
|
||||||
|
MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser,
|
||||||
EnableStatusCheck: form.EnableStatusCheck,
|
EnableStatusCheck: form.EnableStatusCheck,
|
||||||
StatusCheckContexts: form.StatusCheckContexts,
|
StatusCheckContexts: form.StatusCheckContexts,
|
||||||
RequiredApprovals: form.RequiredApprovals,
|
RequiredApprovals: form.RequiredApprovals,
|
||||||
EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
|
EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
|
||||||
ApprovalsWhitelistTeamIDs: approvalsTeams,
|
ApprovalsWhitelistTeamIDs: approvalsTeams,
|
||||||
|
ApprovalsWhitelistUserIDs: approvalsUsers,
|
||||||
BlockOnRejectedReviews: form.BlockOnRejectedReviews,
|
BlockOnRejectedReviews: form.BlockOnRejectedReviews,
|
||||||
BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests,
|
BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests,
|
||||||
BlockOnOutdatedBranch: form.BlockOnOutdatedBranch,
|
BlockOnOutdatedBranch: form.BlockOnOutdatedBranch,
|
||||||
@@ -310,6 +406,16 @@ func EditOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
rule.WhitelistTeamIDs = ids
|
rule.WhitelistTeamIDs = ids
|
||||||
}
|
}
|
||||||
|
if form.PushWhitelistUsernames != nil {
|
||||||
|
ids, ok := resolveUserIDs(ctx, form.PushWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.WhitelistUserIDs = ids
|
||||||
|
}
|
||||||
|
if form.PushWhitelistActionsUser != nil {
|
||||||
|
rule.WhitelistActionsUser = *form.PushWhitelistActionsUser
|
||||||
|
}
|
||||||
if form.EnableForcePush != nil {
|
if form.EnableForcePush != nil {
|
||||||
rule.CanForcePush = *form.EnableForcePush
|
rule.CanForcePush = *form.EnableForcePush
|
||||||
}
|
}
|
||||||
@@ -323,6 +429,39 @@ func EditOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
rule.ForcePushAllowlistTeamIDs = ids
|
rule.ForcePushAllowlistTeamIDs = ids
|
||||||
}
|
}
|
||||||
|
if form.ForcePushAllowlistUsernames != nil {
|
||||||
|
ids, ok := resolveUserIDs(ctx, form.ForcePushAllowlistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.ForcePushAllowlistUserIDs = ids
|
||||||
|
}
|
||||||
|
if form.ForcePushAllowlistActionsUser != nil {
|
||||||
|
rule.ForcePushAllowlistActionsUser = *form.ForcePushAllowlistActionsUser
|
||||||
|
}
|
||||||
|
if form.EnableDelete != nil {
|
||||||
|
rule.CanDelete = *form.EnableDelete
|
||||||
|
}
|
||||||
|
if form.EnableDeleteAllowlist != nil {
|
||||||
|
rule.EnableDeleteAllowlist = *form.EnableDeleteAllowlist
|
||||||
|
}
|
||||||
|
if form.DeleteAllowlistTeams != nil {
|
||||||
|
ids, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.DeleteAllowlistTeamIDs = ids
|
||||||
|
}
|
||||||
|
if form.DeleteAllowlistUsernames != nil {
|
||||||
|
ids, ok := resolveUserIDs(ctx, form.DeleteAllowlistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.DeleteAllowlistUserIDs = ids
|
||||||
|
}
|
||||||
|
if form.DeleteAllowlistActionsUser != nil {
|
||||||
|
rule.DeleteAllowlistActionsUser = *form.DeleteAllowlistActionsUser
|
||||||
|
}
|
||||||
if form.EnableMergeWhitelist != nil {
|
if form.EnableMergeWhitelist != nil {
|
||||||
rule.EnableMergeWhitelist = *form.EnableMergeWhitelist
|
rule.EnableMergeWhitelist = *form.EnableMergeWhitelist
|
||||||
}
|
}
|
||||||
@@ -333,6 +472,16 @@ func EditOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
rule.MergeWhitelistTeamIDs = ids
|
rule.MergeWhitelistTeamIDs = ids
|
||||||
}
|
}
|
||||||
|
if form.MergeWhitelistUsernames != nil {
|
||||||
|
ids, ok := resolveUserIDs(ctx, form.MergeWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.MergeWhitelistUserIDs = ids
|
||||||
|
}
|
||||||
|
if form.MergeWhitelistActionsUser != nil {
|
||||||
|
rule.MergeWhitelistActionsUser = *form.MergeWhitelistActionsUser
|
||||||
|
}
|
||||||
if form.EnableStatusCheck != nil {
|
if form.EnableStatusCheck != nil {
|
||||||
rule.EnableStatusCheck = *form.EnableStatusCheck
|
rule.EnableStatusCheck = *form.EnableStatusCheck
|
||||||
}
|
}
|
||||||
@@ -352,6 +501,13 @@ func EditOrgBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
rule.ApprovalsWhitelistTeamIDs = ids
|
rule.ApprovalsWhitelistTeamIDs = ids
|
||||||
}
|
}
|
||||||
|
if form.ApprovalsWhitelistUsernames != nil {
|
||||||
|
ids, ok := resolveUserIDs(ctx, form.ApprovalsWhitelistUsernames)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.ApprovalsWhitelistUserIDs = ids
|
||||||
|
}
|
||||||
if form.BlockOnRejectedReviews != nil {
|
if form.BlockOnRejectedReviews != nil {
|
||||||
rule.BlockOnRejectedReviews = *form.BlockOnRejectedReviews
|
rule.BlockOnRejectedReviews = *form.BlockOnRejectedReviews
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toAPIOrgEmailDomainPolicy(policy *git_model.OrgEmailDomainPolicy, orgID int64) *api.OrgEmailDomainPolicy {
|
||||||
|
if policy == nil {
|
||||||
|
return &api.OrgEmailDomainPolicy{OrgID: orgID}
|
||||||
|
}
|
||||||
|
return &api.OrgEmailDomainPolicy{
|
||||||
|
OrgID: policy.OrgID,
|
||||||
|
AllowedDomains: policy.AllowedDomains,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgEmailDomainPolicy get the organization's email domain policy
|
||||||
|
func GetOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/email_domain_policy organization orgGetEmailDomainPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Get the organization's email domain policy
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgEmailDomainPolicy"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgEmailDomainPolicy create or update the organization's email domain policy
|
||||||
|
func EditOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /orgs/{org}/email_domain_policy organization orgEditEmailDomainPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Create or update the organization's email domain policy. Only fields that are set will be changed
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditOrgEmailDomainPolicyOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgEmailDomainPolicy"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditOrgEmailDomainPolicyOption)
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
|
||||||
|
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if policy == nil {
|
||||||
|
policy = &git_model.OrgEmailDomainPolicy{OrgID: orgID}
|
||||||
|
}
|
||||||
|
if form.AllowedDomains != nil {
|
||||||
|
policy.AllowedDomains = *form.AllowedDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := git_model.UpsertOrgEmailDomainPolicy(ctx, policy); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgEmailDomainPolicy remove the organization's email domain policy
|
||||||
|
func DeleteOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/email_domain_policy organization orgDeleteEmailDomainPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Remove the organization's email domain policy
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if err := git_model.DeleteOrgEmailDomainPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toAPIOrgPushPolicy converts the model to its API representation. A nil policy is
|
||||||
|
// rendered as an all-empty policy so clients always get a consistent shape.
|
||||||
|
func toAPIOrgPushPolicy(policy *git_model.OrgPushPolicy, orgID int64) *api.OrgPushPolicy {
|
||||||
|
if policy == nil {
|
||||||
|
return &api.OrgPushPolicy{OrgID: orgID}
|
||||||
|
}
|
||||||
|
return &api.OrgPushPolicy{
|
||||||
|
OrgID: policy.OrgID,
|
||||||
|
BranchNamePattern: policy.BranchNamePattern,
|
||||||
|
TagNamePattern: policy.TagNamePattern,
|
||||||
|
RequireSecretBlock: policy.RequireSecretBlock,
|
||||||
|
MaxFileSize: policy.MaxFileSize,
|
||||||
|
BlockedFilePatterns: policy.BlockedFilePatterns,
|
||||||
|
Created: policy.CreatedUnix.AsTime(),
|
||||||
|
Updated: policy.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPushPolicy get the organization's push policy
|
||||||
|
func GetOrgPushPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/push_policy organization orgGetPushPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Get the organization's push policy
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPushPolicy"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgPushPolicy create or update the organization's push policy
|
||||||
|
func EditOrgPushPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /orgs/{org}/push_policy organization orgEditPushPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Create or update the organization's push policy. Only fields that are set will be changed
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditOrgPushPolicyOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPushPolicy"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditOrgPushPolicyOption)
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
|
||||||
|
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if policy == nil {
|
||||||
|
policy = &git_model.OrgPushPolicy{OrgID: orgID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.BranchNamePattern != nil {
|
||||||
|
policy.BranchNamePattern = *form.BranchNamePattern
|
||||||
|
}
|
||||||
|
if form.TagNamePattern != nil {
|
||||||
|
policy.TagNamePattern = *form.TagNamePattern
|
||||||
|
}
|
||||||
|
if form.RequireSecretBlock != nil {
|
||||||
|
policy.RequireSecretBlock = *form.RequireSecretBlock
|
||||||
|
}
|
||||||
|
if form.MaxFileSize != nil {
|
||||||
|
policy.MaxFileSize = *form.MaxFileSize
|
||||||
|
}
|
||||||
|
if form.BlockedFilePatterns != nil {
|
||||||
|
policy.BlockedFilePatterns = *form.BlockedFilePatterns
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := git_model.UpsertOrgPushPolicy(ctx, policy); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgPushPolicy remove the organization's push policy
|
||||||
|
func DeleteOrgPushPolicy(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/push_policy organization orgDeletePushPolicy
|
||||||
|
// ---
|
||||||
|
// summary: Remove the organization's push policy
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if err := git_model.DeleteOrgPushPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toAPIOrgRepoDefaults converts the model to its API representation. A nil value is
|
||||||
|
// rendered as the effective defaults (all merge styles allowed) so clients always
|
||||||
|
// get a consistent shape.
|
||||||
|
func toAPIOrgRepoDefaults(d *git_model.OrgRepoDefaults, orgID int64) *api.OrgRepoDefaults {
|
||||||
|
if d == nil {
|
||||||
|
return &api.OrgRepoDefaults{
|
||||||
|
OrgID: orgID,
|
||||||
|
AllowMerge: true,
|
||||||
|
AllowRebase: true,
|
||||||
|
AllowRebaseMerge: true,
|
||||||
|
AllowSquash: true,
|
||||||
|
AllowFastForwardOnly: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &api.OrgRepoDefaults{
|
||||||
|
OrgID: d.OrgID,
|
||||||
|
ForcePrivate: d.ForcePrivate,
|
||||||
|
ApplyPRDefaults: d.ApplyPRDefaults,
|
||||||
|
AllowMerge: d.AllowMerge,
|
||||||
|
AllowRebase: d.AllowRebase,
|
||||||
|
AllowRebaseMerge: d.AllowRebaseMerge,
|
||||||
|
AllowSquash: d.AllowSquash,
|
||||||
|
AllowFastForwardOnly: d.AllowFastForwardOnly,
|
||||||
|
DefaultMergeStyle: d.DefaultMergeStyle,
|
||||||
|
DeleteBranchAfterMerge: d.DeleteBranchAfterMerge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgRepoDefaults get the organization's default repository settings
|
||||||
|
func GetOrgRepoDefaults(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/repo_defaults organization orgGetRepoDefaults
|
||||||
|
// ---
|
||||||
|
// summary: Get the organization's default repository settings
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgRepoDefaults"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgRepoDefaults create or update the organization's default repository settings
|
||||||
|
func EditOrgRepoDefaults(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /orgs/{org}/repo_defaults organization orgEditRepoDefaults
|
||||||
|
// ---
|
||||||
|
// summary: Create or update the organization's default repository settings. Only fields that are set will be changed
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditOrgRepoDefaultsOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgRepoDefaults"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditOrgRepoDefaultsOption)
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
|
||||||
|
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if defaults == nil {
|
||||||
|
defaults = &git_model.OrgRepoDefaults{
|
||||||
|
OrgID: orgID,
|
||||||
|
AllowMerge: true,
|
||||||
|
AllowRebase: true,
|
||||||
|
AllowRebaseMerge: true,
|
||||||
|
AllowSquash: true,
|
||||||
|
AllowFastForwardOnly: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.ForcePrivate != nil {
|
||||||
|
defaults.ForcePrivate = *form.ForcePrivate
|
||||||
|
}
|
||||||
|
if form.ApplyPRDefaults != nil {
|
||||||
|
defaults.ApplyPRDefaults = *form.ApplyPRDefaults
|
||||||
|
}
|
||||||
|
if form.AllowMerge != nil {
|
||||||
|
defaults.AllowMerge = *form.AllowMerge
|
||||||
|
}
|
||||||
|
if form.AllowRebase != nil {
|
||||||
|
defaults.AllowRebase = *form.AllowRebase
|
||||||
|
}
|
||||||
|
if form.AllowRebaseMerge != nil {
|
||||||
|
defaults.AllowRebaseMerge = *form.AllowRebaseMerge
|
||||||
|
}
|
||||||
|
if form.AllowSquash != nil {
|
||||||
|
defaults.AllowSquash = *form.AllowSquash
|
||||||
|
}
|
||||||
|
if form.AllowFastForwardOnly != nil {
|
||||||
|
defaults.AllowFastForwardOnly = *form.AllowFastForwardOnly
|
||||||
|
}
|
||||||
|
if form.DefaultMergeStyle != nil {
|
||||||
|
defaults.DefaultMergeStyle = *form.DefaultMergeStyle
|
||||||
|
}
|
||||||
|
if form.DeleteBranchAfterMerge != nil {
|
||||||
|
defaults.DeleteBranchAfterMerge = *form.DeleteBranchAfterMerge
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := git_model.UpsertOrgRepoDefaults(ctx, defaults); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgRepoDefaults remove the organization's default repository settings
|
||||||
|
func DeleteOrgRepoDefaults(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/repo_defaults organization orgDeleteRepoDefaults
|
||||||
|
// ---
|
||||||
|
// summary: Remove the organization's default repository settings
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if err := git_model.DeleteOrgRepoDefaults(ctx, ctx.Org.Organization.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toAPIOrgTagProtection converts an org tag rule to its API representation.
|
||||||
|
func toAPIOrgTagProtection(ctx *context.APIContext, rule *git_model.OrgProtectedTag) *api.OrgTagProtection {
|
||||||
|
teams, err := organization.FindOrgTeams(ctx, rule.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
teams = nil
|
||||||
|
}
|
||||||
|
teamNamesByID := make(map[int64]string, len(teams))
|
||||||
|
for _, t := range teams {
|
||||||
|
teamNamesByID[t.ID] = t.Name
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(rule.AllowlistTeamIDs))
|
||||||
|
for _, id := range rule.AllowlistTeamIDs {
|
||||||
|
if name, ok := teamNamesByID[id]; ok {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &api.OrgTagProtection{
|
||||||
|
ID: rule.ID,
|
||||||
|
OrgID: rule.OrgID,
|
||||||
|
NamePattern: rule.NamePattern,
|
||||||
|
WhitelistTeams: names,
|
||||||
|
Created: rule.CreatedUnix.AsTime(),
|
||||||
|
Updated: rule.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgTagProtections list org-level tag protection rules
|
||||||
|
func ListOrgTagProtections(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/tag_protections organization orgListTagProtections
|
||||||
|
// ---
|
||||||
|
// summary: List an organization's tag protection rules
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgTagProtectionList"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
rules, err := git_model.FindOrgProtectedTags(ctx, ctx.Org.Organization.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiRules := make([]*api.OrgTagProtection, len(rules))
|
||||||
|
for i, rule := range rules {
|
||||||
|
apiRules[i] = toAPIOrgTagProtection(ctx, rule)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, apiRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgTagProtection get a specific org-level tag protection rule
|
||||||
|
func GetOrgTagProtection(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/tag_protections/{id} organization orgGetTagProtection
|
||||||
|
// ---
|
||||||
|
// summary: Get a specific org-level tag protection rule
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the tag protection rule
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgTagProtection"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
rule, err := git_model.GetOrgProtectedTagByID(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgTagProtection create an org-level tag protection rule
|
||||||
|
func CreateOrgTagProtection(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /orgs/{org}/tag_protections organization orgCreateTagProtection
|
||||||
|
// ---
|
||||||
|
// summary: Create an org-level tag protection rule
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/CreateOrgTagProtectionOption"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/OrgTagProtection"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.CreateOrgTagProtectionOption)
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
|
||||||
|
existing, err := git_model.GetOrgProtectedTagByNamePattern(ctx, orgID, form.NamePattern)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
ctx.APIError(http.StatusForbidden, "org tag protection rule already exists for this pattern")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := &git_model.OrgProtectedTag{
|
||||||
|
OrgID: orgID,
|
||||||
|
NamePattern: form.NamePattern,
|
||||||
|
AllowlistTeamIDs: teams,
|
||||||
|
}
|
||||||
|
if err := git_model.CreateOrgProtectedTag(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusCreated, toAPIOrgTagProtection(ctx, rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgTagProtection edit an org-level tag protection rule
|
||||||
|
func EditOrgTagProtection(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /orgs/{org}/tag_protections/{id} organization orgEditTagProtection
|
||||||
|
// ---
|
||||||
|
// summary: Edit an org-level tag protection rule. Only fields that are set will be changed
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the tag protection rule
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditOrgTagProtectionOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgTagProtection"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditOrgTagProtectionOption)
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
|
||||||
|
rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.NamePattern != nil {
|
||||||
|
rule.NamePattern = *form.NamePattern
|
||||||
|
}
|
||||||
|
if form.WhitelistTeams != nil {
|
||||||
|
ids, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.AllowlistTeamIDs = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := git_model.UpdateOrgProtectedTag(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgTagProtection delete an org-level tag protection rule
|
||||||
|
func DeleteOrgTagProtection(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/tag_protections/{id} organization orgDeleteTagProtection
|
||||||
|
// ---
|
||||||
|
// summary: Delete an org-level tag protection rule
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the tag protection rule
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
orgID := ctx.Org.Organization.ID
|
||||||
|
rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := git_model.DeleteOrgProtectedTag(ctx, orgID, rule.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
activities_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/activities"
|
activities_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/activities"
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||||
access_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
access_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||||
@@ -491,6 +492,8 @@ func AddTeamMember(ctx *context.APIContext) {
|
|||||||
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
||||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||||
ctx.APIError(http.StatusForbidden, err)
|
ctx.APIError(http.StatusForbidden, err)
|
||||||
|
} else if git_model.IsErrEmailDomainNotAllowed(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
} else {
|
} else {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type apiMetadata struct {
|
|||||||
TargetVersion string `json:"target_version"`
|
TargetVersion string `json:"target_version"`
|
||||||
PHPMinimum string `json:"php_minimum"`
|
PHPMinimum string `json:"php_minimum"`
|
||||||
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
|
// deploy
|
||||||
@@ -44,6 +44,13 @@ type apiMetadata struct {
|
|||||||
HealthURL string `json:"health_url,omitempty"`
|
HealthURL string `json:"health_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manifest
|
||||||
|
// swagger:response Manifest
|
||||||
|
type swaggerResponseManifest struct {
|
||||||
|
// in:body
|
||||||
|
Body apiMetadata `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetRepoMetadata returns the manifest settings for a repository.
|
// GetRepoMetadata returns the manifest settings for a repository.
|
||||||
func GetRepoMetadata(ctx *context.APIContext) {
|
func GetRepoMetadata(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /repos/{owner}/{repo}/manifest repository repoGetManifest
|
// swagger:operation GET /repos/{owner}/{repo}/manifest repository repoGetManifest
|
||||||
@@ -51,6 +58,17 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
// summary: Get repo manifest settings
|
// summary: Get repo manifest settings
|
||||||
// produces:
|
// produces:
|
||||||
// - application/json
|
// - 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
|
||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/Manifest"
|
// "$ref": "#/responses/Manifest"
|
||||||
@@ -71,9 +89,9 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, &apiMetadata{
|
ctx.JSON(http.StatusOK, &apiMetadata{
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
Org: m.Org,
|
Org: m.Org,
|
||||||
Description: m.Description,
|
Description: m.Description,
|
||||||
|
|
||||||
LicenseSPDX: m.LicenseSPDX,
|
LicenseSPDX: m.LicenseSPDX,
|
||||||
LicenseName: m.LicenseName,
|
LicenseName: m.LicenseName,
|
||||||
@@ -89,7 +107,7 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
TargetVersion: m.TargetVersion,
|
TargetVersion: m.TargetVersion,
|
||||||
PHPMinimum: m.PHPMinimum,
|
PHPMinimum: m.PHPMinimum,
|
||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
DeployHost: m.DeployHost,
|
DeployHost: m.DeployHost,
|
||||||
DeployPort: m.DeployPort,
|
DeployPort: m.DeployPort,
|
||||||
@@ -111,9 +129,21 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
// - application/json
|
// - application/json
|
||||||
// produces:
|
// produces:
|
||||||
// - application/json
|
// - 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
|
||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/Manifest"
|
// "$ref": "#/responses/Manifest"
|
||||||
|
|
||||||
// Decode into a map to detect which fields were actually sent.
|
// Decode into a map to detect which fields were actually sent.
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
|
||||||
@@ -173,9 +203,9 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, &apiMetadata{
|
ctx.JSON(http.StatusOK, &apiMetadata{
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
Org: m.Org,
|
Org: m.Org,
|
||||||
Description: m.Description,
|
Description: m.Description,
|
||||||
|
|
||||||
LicenseSPDX: m.LicenseSPDX,
|
LicenseSPDX: m.LicenseSPDX,
|
||||||
LicenseName: m.LicenseName,
|
LicenseName: m.LicenseName,
|
||||||
@@ -191,7 +221,7 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
TargetVersion: m.TargetVersion,
|
TargetVersion: m.TargetVersion,
|
||||||
PHPMinimum: m.PHPMinimum,
|
PHPMinimum: m.PHPMinimum,
|
||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
DeployHost: m.DeployHost,
|
DeployHost: m.DeployHost,
|
||||||
DeployPort: m.DeployPort,
|
DeployPort: m.DeployPort,
|
||||||
|
|||||||
@@ -159,6 +159,44 @@ type swaggerParameterBodies struct {
|
|||||||
// in:body
|
// in:body
|
||||||
UpdateBranchProtectionPriories api.UpdateBranchProtectionPriories
|
UpdateBranchProtectionPriories api.UpdateBranchProtectionPriories
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
CreateOrgBranchProtectionOption api.CreateOrgBranchProtectionOption
|
||||||
|
// in:body
|
||||||
|
EditOrgBranchProtectionOption api.EditOrgBranchProtectionOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
CreateOrgTagProtectionOption api.CreateOrgTagProtectionOption
|
||||||
|
// in:body
|
||||||
|
EditOrgTagProtectionOption api.EditOrgTagProtectionOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
EditOrgPushPolicyOption api.EditOrgPushPolicyOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
EditOrgRepoDefaultsOption api.EditOrgRepoDefaultsOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
EditOrgEmailDomainPolicyOption api.EditOrgEmailDomainPolicyOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
EditAccessTokenOption api.EditAccessTokenOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
IssueBulkAssigneesOption api.IssueBulkAssigneesOption
|
||||||
|
// in:body
|
||||||
|
IssueBulkLabelsOption api.IssueBulkLabelsOption
|
||||||
|
// in:body
|
||||||
|
IssueBulkMilestoneOption api.IssueBulkMilestoneOption
|
||||||
|
// in:body
|
||||||
|
IssueBulkStateOption api.IssueBulkStateOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
IssuePriorityDef api.IssuePriorityDef
|
||||||
|
// in:body
|
||||||
|
IssueStatusDef api.IssueStatusDef
|
||||||
|
// in:body
|
||||||
|
IssueTypeDef api.IssueTypeDef
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions
|
CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,52 @@ type swaggerResponseOrganizationPermissions struct {
|
|||||||
// in:body
|
// in:body
|
||||||
Body api.OrganizationPermissions `json:"body"`
|
Body api.OrganizationPermissions `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgBranchProtection
|
||||||
|
// swagger:response OrgBranchProtection
|
||||||
|
type swaggerResponseOrgBranchProtection struct {
|
||||||
|
// in:body
|
||||||
|
Body api.OrgBranchProtection `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgBranchProtectionList
|
||||||
|
// swagger:response OrgBranchProtectionList
|
||||||
|
type swaggerResponseOrgBranchProtectionList struct {
|
||||||
|
// in:body
|
||||||
|
Body []*api.OrgBranchProtection `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgTagProtection
|
||||||
|
// swagger:response OrgTagProtection
|
||||||
|
type swaggerResponseOrgTagProtection struct {
|
||||||
|
// in:body
|
||||||
|
Body api.OrgTagProtection `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgTagProtectionList
|
||||||
|
// swagger:response OrgTagProtectionList
|
||||||
|
type swaggerResponseOrgTagProtectionList struct {
|
||||||
|
// in:body
|
||||||
|
Body []*api.OrgTagProtection `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgPushPolicy
|
||||||
|
// swagger:response OrgPushPolicy
|
||||||
|
type swaggerResponseOrgPushPolicy struct {
|
||||||
|
// in:body
|
||||||
|
Body api.OrgPushPolicy `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgRepoDefaults
|
||||||
|
// swagger:response OrgRepoDefaults
|
||||||
|
type swaggerResponseOrgRepoDefaults struct {
|
||||||
|
// in:body
|
||||||
|
Body api.OrgRepoDefaults `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgEmailDomainPolicy
|
||||||
|
// swagger:response OrgEmailDomainPolicy
|
||||||
|
type swaggerResponseOrgEmailDomainPolicy struct {
|
||||||
|
// in:body
|
||||||
|
Body api.OrgEmailDomainPolicy `json:"body"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import (
|
|||||||
repo_migrations "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/migrations"
|
repo_migrations "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/migrations"
|
||||||
mirror_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mirror"
|
mirror_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mirror"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/oauth2_provider"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/oauth2_provider"
|
||||||
|
org_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/org"
|
||||||
packages_spec "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/packages/pkgspec"
|
packages_spec "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/packages/pkgspec"
|
||||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||||
release_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/release"
|
release_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/release"
|
||||||
@@ -155,6 +156,7 @@ func InitWebInstalled(ctx context.Context) {
|
|||||||
mustInit(pull_service.Init)
|
mustInit(pull_service.Init)
|
||||||
mustInit(automerge.Init)
|
mustInit(automerge.Init)
|
||||||
cascade.Init()
|
cascade.Init()
|
||||||
|
org_service.Init()
|
||||||
mustInit(task.Init)
|
mustInit(task.Init)
|
||||||
mustInit(repo_migrations.Init)
|
mustInit(repo_migrations.Init)
|
||||||
eventsource.GetManager().Init()
|
eventsource.GetManager().Init()
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
asymkey_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/asymkey"
|
asymkey_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/asymkey"
|
||||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
@@ -160,6 +162,10 @@ 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 ctx.checkOrgPushPolicyBranch(oldCommitID, newCommitID, branchName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if newCommitID != objectFormat.EmptyObjectID().String() {
|
if newCommitID != objectFormat.EmptyObjectID().String() {
|
||||||
newCommit, err := gitRepo.GetCommit(newCommitID)
|
newCommit, err := gitRepo.GetCommit(newCommitID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,6 +461,10 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
|||||||
|
|
||||||
tagName := refFullName.TagName()
|
tagName := refFullName.TagName()
|
||||||
|
|
||||||
|
if ctx.checkOrgPushPolicyTag(tagName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !ctx.gotProtectedTags {
|
if !ctx.gotProtectedTags {
|
||||||
var err error
|
var err error
|
||||||
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
|
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
|
||||||
@@ -468,7 +478,7 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
|||||||
ctx.gotProtectedTags = true
|
ctx.gotProtectedTags = true
|
||||||
}
|
}
|
||||||
|
|
||||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
|
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, ctx.protectedTags, ctx.Repo.Repository, tagName, ctx.opts.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||||
Err: err.Error(),
|
Err: err.Error(),
|
||||||
@@ -596,3 +606,158 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
|||||||
ctx.loadedPusher = true
|
ctx.loadedPusher = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOrgPushPolicyBranch enforces the owning organization's push policy on a
|
||||||
|
// branch push. It writes a 403 response and returns true when the push is rejected.
|
||||||
|
// Content checks (blocked paths, max file size) fail open on unexpected errors so a
|
||||||
|
// policy or parsing bug can never block every push in the organization.
|
||||||
|
func (ctx *preReceiveContext) checkOrgPushPolicyBranch(oldCommitID, newCommitID, branchName string) bool {
|
||||||
|
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, ctx.Repo.Repository)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetOrgPushPolicyForRepo for %-v: %v", ctx.Repo.Repository, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if policy == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !policy.BranchNameAllowed(branchName) {
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: fmt.Sprintf("Branch name %q is not allowed by the organization push policy (pattern: %s)", branchName, policy.BranchNamePattern),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletions have no content to inspect.
|
||||||
|
if newCommitID == ctx.Repo.GetObjectFormat().EmptyObjectID().String() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if globs := policy.BlockedFileGlobs(); len(globs) > 0 {
|
||||||
|
if _, err := pull_service.CheckFileProtection(ctx.Repo.GitRepo, branchName, oldCommitID, newCommitID, globs, 10, ctx.env); err != nil {
|
||||||
|
if pull_service.IsErrFilePathProtected(err) {
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: "Push rejected by the organization push policy: a changed file matches a blocked path pattern",
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Error("org push policy blocked-path check for %-v: %v", ctx.Repo.Repository, err) // fail open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.MaxFileSize > 0 {
|
||||||
|
if path, size := ctx.largestBlobOverLimit(oldCommitID, newCommitID, policy.MaxFileSize); path != "" {
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: fmt.Sprintf("Push rejected by the organization push policy: %q is %d bytes, over the %d-byte limit", path, size, policy.MaxFileSize),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOrgPushPolicyTag enforces the organization tag naming policy. Returns true
|
||||||
|
// (with a 403 written) when the tag name is rejected.
|
||||||
|
func (ctx *preReceiveContext) checkOrgPushPolicyTag(tagName string) bool {
|
||||||
|
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, ctx.Repo.Repository)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetOrgPushPolicyForRepo for %-v: %v", ctx.Repo.Repository, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if policy == nil || policy.TagNameAllowed(tagName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: fmt.Sprintf("Tag name %q is not allowed by the organization push policy (pattern: %s)", tagName, policy.TagNamePattern),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// largestBlobOverLimit returns the first file (and its size) added or modified by the
|
||||||
|
// push (oldCommitID..newCommitID) whose blob exceeds limit bytes, or ("", 0) if none —
|
||||||
|
// or on any error (fail open). Only the pushed delta is inspected so a pre-existing
|
||||||
|
// oversized file committed before the policy existed cannot permanently block unrelated
|
||||||
|
// pushes. For a new branch (no old commit) the full new tree is scanned, since every
|
||||||
|
// blob is effectively introduced by the push.
|
||||||
|
func (ctx *preReceiveContext) largestBlobOverLimit(oldCommitID, newCommitID string, limit int64) (string, int64) {
|
||||||
|
emptyID := ctx.Repo.GetObjectFormat().EmptyObjectID().String()
|
||||||
|
|
||||||
|
// New branch: no base commit to diff against, so scan the full tip tree. ls-tree
|
||||||
|
// --long carries the blob size inline, so no extra sizing pass is needed.
|
||||||
|
if oldCommitID == "" || oldCommitID == emptyID {
|
||||||
|
output, _, err := gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
|
||||||
|
gitcmd.NewCommand("ls-tree", "-r", "--long").AddDynamicArguments(newCommitID).WithEnv(ctx.env))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("org push policy ls-tree for %-v: %v", ctx.Repo.Repository, err)
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
tab := strings.IndexByte(line, '\t')
|
||||||
|
if tab < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line[:tab]) // mode, type, hash, size
|
||||||
|
if len(fields) < 4 || fields[1] != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size, perr := strconv.ParseInt(fields[3], 10, 64); perr == nil && size > limit {
|
||||||
|
return line[tab+1:], size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing branch: inspect only blobs added or modified by this push. diff-tree
|
||||||
|
// gives the changed paths and their new object ids but not sizes, so collect the
|
||||||
|
// candidate blob ids and size them in a single cat-file --batch-check pass.
|
||||||
|
output, _, err := gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
|
||||||
|
gitcmd.NewCommand("diff-tree", "--no-commit-id", "-r").AddDynamicArguments(oldCommitID, newCommitID).WithEnv(ctx.env))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("org push policy diff-tree for %-v: %v", ctx.Repo.Repository, err)
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
shaToPath := make(map[string]string)
|
||||||
|
var order []string
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
tab := strings.IndexByte(line, '\t')
|
||||||
|
if tab < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line[:tab]) // ":oldmode newmode oldsha newsha status"
|
||||||
|
if len(fields) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newMode, newSha, status := fields[1], fields[3], fields[4]
|
||||||
|
// Only regular, executable, or symlink blobs; skip submodule gitlinks (160000).
|
||||||
|
if newMode != "100644" && newMode != "100755" && newMode != "120000" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status == "D" || newSha == emptyID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := shaToPath[newSha]; !seen {
|
||||||
|
order = append(order, newSha)
|
||||||
|
}
|
||||||
|
shaToPath[newSha] = line[tab+1:]
|
||||||
|
}
|
||||||
|
if len(order) == 0 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _, err = gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
|
||||||
|
gitcmd.NewCommand("cat-file", "--batch-check").WithStdinBytes([]byte(strings.Join(order, "\n")+"\n")).WithEnv(ctx.env))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("org push policy cat-file for %-v: %v", ctx.Repo.Repository, err)
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
fields := strings.Fields(line) // "<sha> <type> <size>" or "<sha> missing"
|
||||||
|
if len(fields) != 3 || fields[1] != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size, perr := strconv.ParseInt(fields[2], 10, 64); perr == nil && size > limit {
|
||||||
|
return shaToPath[fields[0]], size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func NewComment(ctx *context.Context) {
|
|||||||
} // end if: handle close or reopen
|
} // end if: handle close or reopen
|
||||||
|
|
||||||
// Handle custom status from the status dropdown (replaces close button for issues with org statuses).
|
// Handle custom status from the status dropdown (replaces close button for issues with org statuses).
|
||||||
if statusIDStr := ctx.Req.FormValue("status_id"); statusIDStr != "" && statusIDStr != "" {
|
if statusIDStr := ctx.Req.FormValue("status_id"); statusIDStr != "" {
|
||||||
if statusIDStr == "reopen" {
|
if statusIDStr == "reopen" {
|
||||||
// Reopen via dropdown
|
// Reopen via dropdown
|
||||||
if issue.IsClosed {
|
if issue.IsClosed {
|
||||||
|
|||||||
@@ -34,6 +34,64 @@ const (
|
|||||||
tplProtectedBranch templates.TplName = "repo/settings/protected_branch"
|
tplProtectedBranch templates.TplName = "repo/settings/protected_branch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// orgBranchProtectionView is a read-only presentation of an org-level branch
|
||||||
|
// protection rule for the repo settings page, with team IDs resolved to names.
|
||||||
|
type orgBranchProtectionView struct {
|
||||||
|
Rule *git_model.OrgProtectedBranch
|
||||||
|
PushTeams string
|
||||||
|
ForcePushTeams string
|
||||||
|
DeleteTeams string
|
||||||
|
MergeTeams string
|
||||||
|
ApprovalTeams string
|
||||||
|
StatusContexts string
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareOrgProtectedBranches loads the owning organization's branch protection
|
||||||
|
// rules and exposes them (with team IDs resolved to names) as read-only view models
|
||||||
|
// under the "OrgProtectedBranches" template key.
|
||||||
|
func prepareOrgProtectedBranches(ctx *context.Context) error {
|
||||||
|
orgRules, err := git_model.FindOrgProtectedBranchRules(ctx, ctx.Repo.Owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(orgRules) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teamNames := make(map[int64]string, len(teams))
|
||||||
|
for _, t := range teams {
|
||||||
|
teamNames[t.ID] = t.Name
|
||||||
|
}
|
||||||
|
join := func(ids []int64) string {
|
||||||
|
names := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if n, ok := teamNames[id]; ok {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]*orgBranchProtectionView, len(orgRules))
|
||||||
|
for i, r := range orgRules {
|
||||||
|
views[i] = &orgBranchProtectionView{
|
||||||
|
Rule: r,
|
||||||
|
PushTeams: join(r.WhitelistTeamIDs),
|
||||||
|
ForcePushTeams: join(r.ForcePushAllowlistTeamIDs),
|
||||||
|
DeleteTeams: join(r.DeleteAllowlistTeamIDs),
|
||||||
|
MergeTeams: join(r.MergeWhitelistTeamIDs),
|
||||||
|
ApprovalTeams: join(r.ApprovalsWhitelistTeamIDs),
|
||||||
|
StatusContexts: strings.Join(r.StatusCheckContexts, ", "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Data["OrgProtectedBranches"] = views
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProtectedBranchRules render the page to protect the repository
|
// ProtectedBranchRules render the page to protect the repository
|
||||||
func ProtectedBranchRules(ctx *context.Context) {
|
func ProtectedBranchRules(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("repo.settings.branches")
|
ctx.Data["Title"] = ctx.Tr("repo.settings.branches")
|
||||||
@@ -46,6 +104,16 @@ func ProtectedBranchRules(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["ProtectedBranches"] = rules
|
ctx.Data["ProtectedBranches"] = rules
|
||||||
|
|
||||||
|
// Surface the organization-level rules that also apply to this repo (read-only),
|
||||||
|
// so admins can see the org "floor" that is layered on top of the repo's own
|
||||||
|
// rules at enforcement time. See issue #727.
|
||||||
|
if ctx.Repo.Owner.IsOrganization() {
|
||||||
|
if err := prepareOrgProtectedBranches(ctx); err != nil {
|
||||||
|
ctx.ServerError("prepareOrgProtectedBranches", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repo.PrepareBranchList(ctx)
|
repo.PrepareBranchList(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ func DeleteProtectedTagPost(ctx *context.Context) {
|
|||||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orgProtectedTagView is a read-only presentation of an org-level tag rule for the
|
||||||
|
// repo settings page, with allowlist team IDs resolved to names.
|
||||||
|
type orgProtectedTagView struct {
|
||||||
|
Rule *git_model.OrgProtectedTag
|
||||||
|
Teams string
|
||||||
|
}
|
||||||
|
|
||||||
func setTagsContext(ctx *context.Context) error {
|
func setTagsContext(ctx *context.Context) error {
|
||||||
ctx.Data["Title"] = ctx.Tr("repo.settings.tags")
|
ctx.Data["Title"] = ctx.Tr("repo.settings.tags")
|
||||||
ctx.Data["PageIsSettingsTags"] = true
|
ctx.Data["PageIsSettingsTags"] = true
|
||||||
@@ -163,6 +170,36 @@ func setTagsContext(ctx *context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx.Data["Teams"] = teams
|
ctx.Data["Teams"] = teams
|
||||||
|
|
||||||
|
// Surface the organization's tag protection rules read-only, so admins can see
|
||||||
|
// the org "floor" layered on top of this repo's own protected tags (#727).
|
||||||
|
orgRules, err := git_model.FindOrgProtectedTags(ctx, ctx.Repo.Owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("FindOrgProtectedTags", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(orgRules) > 0 {
|
||||||
|
allTeams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("FindOrgTeams", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teamNames := make(map[int64]string, len(allTeams))
|
||||||
|
for _, t := range allTeams {
|
||||||
|
teamNames[t.ID] = t.Name
|
||||||
|
}
|
||||||
|
views := make([]*orgProtectedTagView, len(orgRules))
|
||||||
|
for i, r := range orgRules {
|
||||||
|
names := make([]string, 0, len(r.AllowlistTeamIDs))
|
||||||
|
for _, id := range r.AllowlistTeamIDs {
|
||||||
|
if n, ok := teamNames[id]; ok {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views[i] = &orgProtectedTagView{Rule: r, Teams: strings.Join(names, ", ")}
|
||||||
|
}
|
||||||
|
ctx.Data["OrgProtectedTags"] = views
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
||||||
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify"
|
||||||
|
repo_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init registers the org notifier so organization repository defaults are applied to
|
||||||
|
// repositories as they are created in or transferred into the org. The notifier
|
||||||
|
// pattern avoids the services/repository -> services/org import cycle.
|
||||||
|
func Init() {
|
||||||
|
notify_service.RegisterNotifier(&repoDefaultsNotifier{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoDefaultsNotifier struct {
|
||||||
|
notify_service.NullNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *repoDefaultsNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
|
||||||
|
applyOrgRepoDefaults(ctx, u, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *repoDefaultsNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
|
||||||
|
applyOrgRepoDefaults(ctx, nil, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOrgRepoDefaults applies the owning organization's default repository settings
|
||||||
|
// to a repo that has just joined the org. Best-effort: errors are logged and never
|
||||||
|
// propagated, so a defaults bug can never break repository creation or transfer.
|
||||||
|
func applyOrgRepoDefaults(ctx context.Context, owner *user_model.User, repo *repo_model.Repository) {
|
||||||
|
if owner == nil {
|
||||||
|
if err := repo.LoadOwner(ctx); err != nil {
|
||||||
|
log.Error("org repo defaults: load owner of repo %d: %v", repo.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner = repo.Owner
|
||||||
|
}
|
||||||
|
if owner == nil || !owner.IsOrganization() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults, err := git_model.GetOrgRepoDefaults(ctx, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("org repo defaults: load for org %d: %v", owner.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if defaults == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaults.ForcePrivate && !repo.IsPrivate {
|
||||||
|
repo.IsPrivate = true
|
||||||
|
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||||
|
log.Error("org repo defaults: force private on repo %d: %v", repo.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaults.ApplyPRDefaults {
|
||||||
|
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
|
||||||
|
if err != nil {
|
||||||
|
// The repository may not have pull requests enabled; nothing to apply.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := prUnit.PullRequestsConfig()
|
||||||
|
cfg.AllowMerge = defaults.AllowMerge
|
||||||
|
cfg.AllowRebase = defaults.AllowRebase
|
||||||
|
cfg.AllowRebaseMerge = defaults.AllowRebaseMerge
|
||||||
|
cfg.AllowSquash = defaults.AllowSquash
|
||||||
|
cfg.AllowFastForwardOnly = defaults.AllowFastForwardOnly
|
||||||
|
cfg.DefaultDeleteBranchAfterMerge = defaults.DeleteBranchAfterMerge
|
||||||
|
if defaults.DefaultMergeStyle != "" {
|
||||||
|
cfg.DefaultMergeStyle = repo_model.MergeStyle(defaults.DefaultMergeStyle)
|
||||||
|
}
|
||||||
|
if err := repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Type: unit.TypePullRequests,
|
||||||
|
Config: cfg,
|
||||||
|
}}, nil); err != nil {
|
||||||
|
log.Error("org repo defaults: update PR unit on repo %d: %v", repo.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -220,6 +220,13 @@ func AddTeamMember(ctx context.Context, team *organization.Team, user *user_mode
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce the organization email domain policy for new members.
|
||||||
|
if allowed, err := git_model.OrgEmailDomainAllowed(ctx, team.OrgID, user.Email); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !allowed {
|
||||||
|
return git_model.ErrEmailDomainNotAllowed{Email: user.Email, OrgID: team.OrgID}
|
||||||
|
}
|
||||||
|
|
||||||
if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil {
|
if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
|
|
||||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
|
||||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
@@ -92,7 +92,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
|
|||||||
|
|
||||||
// Trim '--' prefix to prevent command line argument vulnerability.
|
// Trim '--' prefix to prevent command line argument vulnerability.
|
||||||
rel.TagName = strings.TrimPrefix(rel.TagName, "--")
|
rel.TagName = strings.TrimPrefix(rel.TagName, "--")
|
||||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, rel.PublisherID)
|
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, rel.Repo, rel.TagName, rel.PublisherID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("GetProtectedTags: %w", err)
|
return fmt.Errorf("GetProtectedTags: %w", err)
|
||||||
}
|
}
|
||||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, doer.ID)
|
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, repo, rel.TagName, doer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ var DefaultCodeRules = []CodeRule{
|
|||||||
{
|
{
|
||||||
ID: "deserialize-yaml-py", Title: "Insecure Deserialization: yaml.load() (Python)",
|
ID: "deserialize-yaml-py", Title: "Insecure Deserialization: yaml.load() (Python)",
|
||||||
Severity: security_model.SeverityHigh, CWE: "CWE-502",
|
Severity: security_model.SeverityHigh, CWE: "CWE-502",
|
||||||
Pattern: regexp.MustCompile(`yaml\.load\s*\([^)]*(?:Loader\s*=\s*yaml\.(?:Unsafe|Full)Loader|[^)]*\)(?!\s*#))`),
|
Pattern: regexp.MustCompile(`(?i)yaml\.load\s*\(`),
|
||||||
Description: "yaml.load() without SafeLoader allows arbitrary code execution — use yaml.safe_load()",
|
Description: "yaml.load() without SafeLoader allows arbitrary code execution — use yaml.safe_load()",
|
||||||
Languages: []string{".py"},
|
Languages: []string{".py"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package security
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestDefaultCodeRulesCompile forces the package init, which builds every scanner
|
||||||
|
// rule's regexp via regexp.MustCompile, and asserts each pattern is present. Go's
|
||||||
|
// regexp engine is RE2 and rejects Perl-only constructs (lookahead/lookbehind/
|
||||||
|
// backreferences) by panicking in MustCompile at init — which crash-loops the
|
||||||
|
// server at startup. This test executes that init so such a pattern fails CI here
|
||||||
|
// instead of on a live deploy.
|
||||||
|
func TestDefaultCodeRulesCompile(t *testing.T) {
|
||||||
|
if len(DefaultCodeRules) == 0 {
|
||||||
|
t.Fatal("DefaultCodeRules is empty")
|
||||||
|
}
|
||||||
|
for _, r := range DefaultCodeRules {
|
||||||
|
if r.Pattern == nil {
|
||||||
|
t.Errorf("rule %q has a nil Pattern", r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package security
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
@@ -21,7 +22,11 @@ func ScanPushForSecrets(ctx context.Context, repoID int64, commit *git.Commit) [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || !cfg.BlockOnPush || !cfg.SecretScanner {
|
if !cfg.Enabled || !cfg.BlockOnPush || !cfg.SecretScanner {
|
||||||
return nil
|
// The owning organization may mandate secret blocking regardless of the
|
||||||
|
// repository's own scanner config (org push policy).
|
||||||
|
if !orgRequiresSecretBlock(ctx, repoID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := NewSecretScanner()
|
scanner := NewSecretScanner()
|
||||||
@@ -33,6 +38,22 @@ func ScanPushForSecrets(ctx context.Context, repoID int64, commit *git.Commit) [
|
|||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orgRequiresSecretBlock reports whether the repo's owning organization mandates
|
||||||
|
// secret blocking on push via its org push policy.
|
||||||
|
func orgRequiresSecretBlock(ctx context.Context, repoID int64) bool {
|
||||||
|
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("orgRequiresSecretBlock: GetRepositoryByID: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("orgRequiresSecretBlock: GetOrgPushPolicyForRepo: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return policy != nil && policy.RequireSecretBlock
|
||||||
|
}
|
||||||
|
|
||||||
// ScanOnPush runs enabled scanners against a commit pushed to the default branch.
|
// ScanOnPush runs enabled scanners against a commit pushed to the default branch.
|
||||||
// Called from services/repository/push.go on default branch pushes.
|
// Called from services/repository/push.go on default branch pushes.
|
||||||
func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) {
|
func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) {
|
||||||
|
|||||||
@@ -62,6 +62,88 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .OrgProtectedBranches}}
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.org_protected_branch"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<p class="tw-mb-3">{{ctx.Locale.Tr "repo.settings.org_protected_branch_desc"}}</p>
|
||||||
|
<div class="flex-divided-list items-with-main">
|
||||||
|
{{range .OrgProtectedBranches}}
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-main tw-w-full">
|
||||||
|
<details class="tw-w-full">
|
||||||
|
<summary class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-cursor-pointer">
|
||||||
|
<div class="ui basic label">{{.Rule.RuleName}}</div>
|
||||||
|
<span class="ui tiny label">{{svg "octicon-organization" 12}} {{ctx.Locale.Tr "repo.settings.org_protected_branch.inherited"}}</span>
|
||||||
|
<span class="text grey tw-text-sm">{{svg "octicon-lock" 12}} {{ctx.Locale.Tr "repo.settings.org_protected_branch.read_only"}}</span>
|
||||||
|
</summary>
|
||||||
|
<table class="ui very basic compact table tw-mt-2 tw-w-auto tw-ml-4">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.direct_push"}}</td>
|
||||||
|
<td>{{if not .Rule.CanPush}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableWhitelist}}{{if .PushTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .PushTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.write_access"}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.force_push"}}</td>
|
||||||
|
<td>{{if not .Rule.CanForcePush}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableForcePushAllowlist}}{{if .ForcePushTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .ForcePushTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.allowed"}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.deletion"}}</td>
|
||||||
|
<td>{{if not .Rule.CanDelete}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.blocked"}}{{else if .Rule.EnableDeleteAllowlist}}{{if .DeleteTeams}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.teams" .DeleteTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.allowed"}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{if gt .Rule.RequiredApprovals 0}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.approvals"}}</td>
|
||||||
|
<td>{{.Rule.RequiredApprovals}}{{if .ApprovalTeams}} ({{.ApprovalTeams}}){{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if .Rule.EnableMergeWhitelist}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.merge"}}</td>
|
||||||
|
<td>{{if .MergeTeams}}{{.MergeTeams}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.restricted"}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if .Rule.EnableStatusCheck}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.status_check"}}</td>
|
||||||
|
<td>{{if .StatusContexts}}{{.StatusContexts}}{{else}}{{ctx.Locale.Tr "repo.settings.org_protected_branch.any"}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if .Rule.RequireSignedCommits}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold">{{ctx.Locale.Tr "repo.settings.org_protected_branch.signed"}}</td>
|
||||||
|
<td>{{svg "octicon-check" 14}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if .Rule.ProtectedFilePatterns}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.protected_files"}}</td>
|
||||||
|
<td><code>{{.Rule.ProtectedFilePatterns}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if or .Rule.BlockOnOutdatedBranch .Rule.BlockOnRejectedReviews .Rule.BlockAdminMergeOverride}}
|
||||||
|
<tr>
|
||||||
|
<td class="tw-font-semibold tw-align-top">{{ctx.Locale.Tr "repo.settings.org_protected_branch.also"}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="tw-flex tw-gap-1 tw-flex-wrap">
|
||||||
|
{{if .Rule.BlockOnOutdatedBranch}}<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_outdated"}}</span>{{end}}
|
||||||
|
{{if .Rule.BlockOnRejectedReviews}}<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_rejected"}}</span>{{end}}
|
||||||
|
{{if .Rule.BlockAdminMergeOverride}}<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.settings.org_protected_branch.block_admin"}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_delete"}}</h5>
|
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.protect_branch_deletion"}}</h5>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui radio checkbox">
|
<div class="ui radio checkbox">
|
||||||
<input type="radio" name="enable_delete" value="none" class="toggle-target-disabled" data-target="#delete_allowlist_box" {{if not .Rule.CanDelete}}checked{{end}}>
|
<input type="radio" name="enable_delete" value="none" class="toggle-target-disabled" data-target="#delete_allowlist_box" {{if not .Rule.CanDelete}}checked{{end}}>
|
||||||
|
|||||||
@@ -116,6 +116,30 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{if .OrgProtectedTags}}
|
||||||
|
<h5 class="ui top attached header tw-mt-4">
|
||||||
|
{{svg "octicon-organization" 14}} {{ctx.Locale.Tr "repo.settings.org_protected_tag"}}
|
||||||
|
</h5>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<p class="tw-mb-3">{{ctx.Locale.Tr "repo.settings.org_protected_tag_desc"}}</p>
|
||||||
|
<table class="ui single line table">
|
||||||
|
<thead>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.settings.tags.protection.pattern"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.settings.tags.protection.allowed"}}</th>
|
||||||
|
<th class="tw-text-right">{{ctx.Locale.Tr "repo.settings.org_protected_tag.read_only"}}</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .OrgProtectedTags}}
|
||||||
|
<tr>
|
||||||
|
<td><pre>{{.Rule.NamePattern}}</pre></td>
|
||||||
|
<td>{{if .Teams}}{{.Teams}}{{else}}{{ctx.Locale.Tr "repo.settings.tags.protection.allowed.noone"}}{{end}}</td>
|
||||||
|
<td class="tw-text-right"><span class="text grey">{{svg "octicon-lock" 14}}</span></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+2709
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ package integration
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||||
@@ -36,7 +37,7 @@ func TestAPILicensePackages(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("CreatePackage", func(t *testing.T) {
|
t.Run("CreatePackage", func(t *testing.T) {
|
||||||
body := `{"name":"Test Pro Annual","description":"Annual pro subscription","duration_days":365,"max_sites":5}`
|
body := `{"name":"Test Pro Annual","description":"Annual pro subscription","duration_days":365,"max_sites":5}`
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
@@ -51,7 +52,7 @@ func TestAPILicensePackages(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("CreatePackageNoName", func(t *testing.T) {
|
t.Run("CreatePackageNoName", func(t *testing.T) {
|
||||||
body := `{"description":"Missing name"}`
|
body := `{"description":"Missing name"}`
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||||
@@ -68,7 +69,7 @@ func TestAPILicenseKeys(t *testing.T) {
|
|||||||
|
|
||||||
// Create a package first.
|
// Create a package first.
|
||||||
body := `{"name":"Test Package","duration_days":30}`
|
body := `{"name":"Test Package","duration_days":30}`
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
@@ -80,7 +81,7 @@ func TestAPILicenseKeys(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("CreateKey", func(t *testing.T) {
|
t.Run("CreateKey", func(t *testing.T) {
|
||||||
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"John Doe","licensee_email":"john@example.com"}`, pkg.ID)
|
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"John Doe","licensee_email":"john@example.com"}`, pkg.ID)
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
@@ -104,7 +105,7 @@ func TestAPILicenseKeys(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("EditKey", func(t *testing.T) {
|
t.Run("EditKey", func(t *testing.T) {
|
||||||
body := `{"licensee_name":"Jane Doe","domain_restriction":"example.com,test.com"}`
|
body := `{"licensee_name":"Jane Doe","domain_restriction":"example.com,test.com"}`
|
||||||
req := NewRequestWithBody(t, "PATCH", fmt.Sprintf("%s/license-keys/%d", urlPrefix, createdKeyID), []byte(body)).
|
req := NewRequestWithBody(t, "PATCH", fmt.Sprintf("%s/license-keys/%d", urlPrefix, createdKeyID), strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
@@ -124,7 +125,7 @@ func TestAPILicenseKeys(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ValidateKey", func(t *testing.T) {
|
t.Run("ValidateKey", func(t *testing.T) {
|
||||||
body := fmt.Sprintf(`{"key":"%s","domain":"example.com"}`, rawKey)
|
body := fmt.Sprintf(`{"key":"%s","domain":"example.com"}`, rawKey)
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", strings.NewReader(body)).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
// Note: no token — this is a public endpoint.
|
// Note: no token — this is a public endpoint.
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
@@ -136,7 +137,7 @@ func TestAPILicenseKeys(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ValidateInvalidKey", func(t *testing.T) {
|
t.Run("ValidateInvalidKey", func(t *testing.T) {
|
||||||
body := `{"key":"MOKO-XXXX-XXXX-XXXX-XXXX","domain":"example.com"}`
|
body := `{"key":"MOKO-XXXX-XXXX-XXXX-XXXX","domain":"example.com"}`
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", strings.NewReader(body)).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
var result api.ValidateLicenseKeyResponse
|
var result api.ValidateLicenseKeyResponse
|
||||||
@@ -161,7 +162,7 @@ func TestAPILicensePurchaseWebhook(t *testing.T) {
|
|||||||
|
|
||||||
// Create a package.
|
// Create a package.
|
||||||
body := `{"name":"Purchase Test","duration_days":90}`
|
body := `{"name":"Purchase Test","duration_days":90}`
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
@@ -170,7 +171,7 @@ func TestAPILicensePurchaseWebhook(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("PurchaseNewKey", func(t *testing.T) {
|
t.Run("PurchaseNewKey", func(t *testing.T) {
|
||||||
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","licensee_email":"buyer@shop.com","domain":"shop.com","payment_ref":"stripe_pi_test123"}`, pkg.ID)
|
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","licensee_email":"buyer@shop.com","domain":"shop.com","payment_ref":"stripe_pi_test123"}`, pkg.ID)
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
@@ -183,7 +184,7 @@ func TestAPILicensePurchaseWebhook(t *testing.T) {
|
|||||||
t.Run("PurchaseIdempotent", func(t *testing.T) {
|
t.Run("PurchaseIdempotent", func(t *testing.T) {
|
||||||
// Same payment_ref should return existing key without raw_key.
|
// Same payment_ref should return existing key without raw_key.
|
||||||
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","payment_ref":"stripe_pi_test123"}`, pkg.ID)
|
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","payment_ref":"stripe_pi_test123"}`, pkg.ID)
|
||||||
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", []byte(body)).
|
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", strings.NewReader(body)).
|
||||||
AddTokenAuth(token).
|
AddTokenAuth(token).
|
||||||
SetHeader("Content-Type", "application/json")
|
SetHeader("Content-Type", "application/json")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func TestOAuth2CallbackReactivationGating(t *testing.T) {
|
|||||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||||
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
|
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
|
||||||
|
|
||||||
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: "test-sub", Email: "test@example.com", Name: "Test User"})
|
srv := newFakeOIDCServerWithConfig(t, FakeOIDCConfig{Sub: "test-sub", Email: "test@example.com", Name: "Test User"})
|
||||||
addOAuth2Source(t, "test-oauth-source", oauth2.Source{
|
addOAuth2Source(t, "test-oauth-source", oauth2.Source{
|
||||||
Provider: "openidConnect",
|
Provider: "openidConnect",
|
||||||
ClientID: "test-client-id",
|
ClientID: "test-client-id",
|
||||||
@@ -308,7 +308,7 @@ type FakeOIDCConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newFakeOIDCServer starts a httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow
|
// newFakeOIDCServer starts a httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow
|
||||||
func newFakeOIDCServer(t *testing.T, cfg FakeOIDCConfig) *httptest.Server {
|
func newFakeOIDCServerWithConfig(t *testing.T, cfg FakeOIDCConfig) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var srv *httptest.Server
|
var srv *httptest.Server
|
||||||
|
|||||||
Reference in New Issue
Block a user