Compare commits

..

30 Commits

Author SHA1 Message Date
jmiller 4b68853f08 feat(org): add branch-deletion protection + expandable inherited-rule view (#727)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 39s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m3s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 1m8s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m18s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Two related additions:

1. Branch deletion as an org-level ability. OrgProtectedBranch gained
   CanDelete / EnableDeleteAllowlist / DeleteAllowlistTeamIDs (migration 362),
   ToProtectedBranch maps them, and the API (create/edit/response DTOs +
   handlers) exposes enable_delete / enable_delete_allowlist /
   delete_allowlist_teams. The layering merge already combined delete fields, so
   org delete-protection now enforces once ToProtectedBranch populates them.

2. The repo Branch Protection view now renders each inherited org rule as an
   expandable detail (direct push, force-push, branch deletion, merge, required
   approvals, status checks, protected files) with team names resolved, instead
   of three headline badges. Still read-only.

Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here.
Verified by hand: struct-field gofmt alignment, template block nesting balances,
every .Rule field exists on OrgProtectedBranch, and all locale keys referenced
in the template are defined.

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
2026-07-04 21:16:24 -05:00
jmiller 86bd8a2cad feat(org): show inherited org branch-protection rules in repo settings (#727)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 42s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m18s
PR RC Release / Build RC Release (pull_request) Successful in 1m17s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 1m32s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The org "floor" is enforced implicitly at the choke point, so a repo admin
couldn't see which org-level rules apply to their repo. Surface them in the
repo's Branch Protection settings page (read-only), the way GitHub shows
organization rulesets in a repository.

- ProtectedBranchRules handler: when the owner is an org, load
  FindOrgProtectedBranchRules and expose them as OrgProtectedBranches.
- branches.tmpl: new read-only "Organization Branch Protection" section listing
  each org rule with an "Organization" badge, a lock/read-only marker, and
  compact indicators (required approvals, signed commits, status checks). No
  edit/delete controls — these are managed at the org level.
- en-US locale strings.

Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here.

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
2026-07-04 20:25:24 -05:00
jmiller 24b3516c1d fix(org): layer org-level branch protection with repo rules, most-restrictive wins (#727)
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 38s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m8s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 3m15s
Universal: PR Check / Secret Scan (pull_request) Successful in 3m5s
Generic: Project CI / Tests (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Org-level branch protection was already consulted at the single enforcement
choke point `GetFirstMatchProtectedBranchRule`, but only as a FALLBACK: if any
repo-level rule matched the branch, the org rule was ignored entirely. That let
a repo define a looser rule for a pattern and effectively opt out of the org's
protection.

Make the choke point LAYER the two rules instead: when both an org rule and a
repo rule match a branch, return their most-restrictive (fail-closed)
combination, so the org rule is a mandatory floor a repo can only tighten.

- models/git/protected_branch_merge.go: mergeMostRestrictive + helpers. Allow
  flags AND'd; gate/require/block flags OR'd; RequiredApprovals max'd; required
  sets (status contexts, protected files) unioned; allow sets (whitelists,
  unprotected files) intersected. A disabled allowlist means "everyone", so it
  only constrains when enabled.
- models/git/protected_branch_list.go: GetFirstMatchProtectedBranchRule now
  fetches both the repo rule and the org rule and merges when both match;
  returns whichever exists when only one matches. Org lookup factored into
  getFirstMatchOrgProtectedBranchRule.

Supersedes the materialization approach previously proposed for this issue —
the org fallback already existed, so only this one function needed to change.

Fail-closed by design: any merge edge errs toward MORE protection (over-restrict)
rather than less, so it cannot open a hole.

Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here —
relying on CI to validate build, formatting, and tests.

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
2026-07-04 19:42:08 -05:00
jmiller 377dd019be chore: sync auto-release.yml from Template-Generic [skip ci] 2026-07-04 23:27:10 +00:00
jmiller 5ffed39449 Merge pull request 'fix: render org teams list and make issue type editable (#720, #721)' (#726) from fix/team-list-and-issue-type-editable into main
Deploy MokoGitea / deploy (push) Failing after 5m1s
2026-07-04 21:31:56 +00:00
jmiller 0cc569aef6 fix: render org teams list and make issue type editable (#720, #721)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 14s
Generic: Project CI / Lint & Validate (pull_request) Successful in 57s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m15s
PR RC Release / Build RC Release (pull_request) Successful in 2m18s
Universal: PR Check / Secret Scan (pull_request) Successful in 2m19s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m10s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 9m20s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
#720: org Teams page wrote ctx.Data["OrgListTeams"] but the template iterates .Teams, so no teams rendered. Use the canonical Teams key (matches org/home.go). #721: issue type sidebar gated editing on a FieldEditFlags data key that no handler sets (always nil -> always read-only). Use HasIssuesOrPullsWritePermission like the priority field; the /custom-type endpoint is already protected by reqRepoIssuesOrPullsWriter.
2026-07-04 16:27:32 -05:00
jmiller 4ed45a5916 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 20:27:57 +00:00
jmiller ea87b3db97 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 20:22:43 +00:00
jmiller d77b82a8af chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-07-04 20:22:34 +00:00
jmiller 22b0b14ea8 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 19:38:21 +00:00
jmiller 1256f57d40 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-07-04 19:38:11 +00:00
jmiller 05763eb661 chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-07-04 19:37:58 +00:00
jmiller 0c45a2fda3 chore: sync sync-on-merge.yml from Template-Generic [skip ci] 2026-07-04 19:05:30 +00:00
jmiller 8680fd1cab chore: sync pre-release.yml from Template-Generic [skip ci] 2026-07-04 19:05:10 +00:00
jmiller adf0e923df chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 19:04:47 +00:00
jmiller 296df7792a chore: sync notify.yml from Template-Generic [skip ci] 2026-07-04 19:04:37 +00:00
jmiller 51332a587d chore: sync cleanup.yml from Template-Generic [skip ci] 2026-07-04 19:04:27 +00:00
jmiller 8fc2700c16 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-07-04 19:04:20 +00:00
jmiller 844b4b6e81 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-07-04 19:04:10 +00:00
jmiller cb9516b79b Merge pull request 'fix: support radio inputs in admin system config form' (#724) from fix/admin-config-radio into main
Deploy MokoGitea / deploy (push) Failing after 3m57s
2026-07-04 19:01:33 +00:00
jmiller aea4370845 ci: no-op PR RC Release when updates.xml is absent
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 33s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 49s
PR RC Release / Build RC Release (pull_request) Successful in 1m34s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m36s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 55s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 8m57s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The RC release workflow drives a Joomla-style updates.xml update stream. On a generic repo with no updates.xml, the Determine RC version step ran sed on a missing file and aborted under set -e (exit 2). Detect updates.xml presence and gate the update-stream steps (edit/create-release/commit) on it so the job succeeds and no-ops when there is nothing to package.
2026-07-04 13:53:15 -05:00
jmiller fc234bc911 fix: remove dangling mcp-mokogitea-api submodule gitlink
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 39s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 42s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 1m11s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m12s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The tree carried a gitlink at mcp-mokogitea-api (mode 160000) with no .gitmodules entry, so git submodule update --init --recursive failed with exit 128 at checkout, breaking every PR build/release job. mcp-mokogitea-api is a separate repo, not a submodule; remove the gitlink from the index (keeping the local working-tree clone) and gitignore the path so it can't be re-added.
2026-07-04 13:46:35 -05:00
jmiller b252e9569f ci: allow fix/patch branches to target main and guard missing manifest
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 36s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 1m0s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 57s
Universal: PR Check / Secret Scan (pull_request) Successful in 57s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Branch policy in pr-check.yml only allowed fix/* and patch/* to target dev/rc, blocking fix/* PRs to main despite the documented policy. Allow fix/* -> main and patch/* -> main. Also guard the Detect platform step for a missing .mokogitea/manifest.xml (removed in favor of the metadata API) so it no longer aborts the Validate PR job under set -e.
2026-07-04 13:36:47 -05:00
jmiller efb0433412 fix: preserve server-rendered radio default when config value is empty
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 37s
PR RC Release / Build RC Release (pull_request) Failing after 1m3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 1m5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 1m9s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
LandingPageType.Mode defaults to "" (Go zero value), and the template renders the home radio as checked for an empty Mode. The initial radio fill would evaluate home.checked = ("home" === "") = false, unchecking the default on a fresh install. Skip assignment when the config value is empty so the server-rendered selection is preserved. Adds a test for the empty-value case.
2026-07-04 13:15:10 -05:00
jmiller 982e45a56e fix: support radio inputs in admin system config form
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 38s
Universal: Build & Release / Promote to RC (pull_request) Failing after 8s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 39s
Universal: PR Check / Secret Scan (pull_request) Successful in 43s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The system config form JS (config.ts) only mapped checkbox, text, textarea, and datetime-local elements. The fork landing_page.tmpl uses radio inputs for the Mode field, so fillFromSystemConfig() hit unsupportedElement() and threw, aborting all JS init on the admin settings page.

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