Compare commits

...

55 Commits

Author SHA1 Message Date
jmiller 6b0ec5196a chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 09:37:15 +00:00
jmiller 8741096fb4 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:11:01 +00:00
Jonathan Miller e5aa0c343d fix(updates): default Joomla target version to 5/6
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 25s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 4m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:03 -05:00
jmiller ab3a65abdf chore: sync updates.xml 05.17.00 from main [skip ci] 2026-06-03 02:56:36 +00:00
Jonathan Miller ba0d180e39 fix(updates): correct infourl/maintainerurl mapping
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Failing after 19s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 6m52s
<infourl> = InfoURL field (product/release info page), fallback /releases
<maintainerurl> = SupportURL field (support site), fallback MaintainerURL, fallback org profile

Previously SupportURL was mapped to <infourl> which was wrong.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 21:54:01 -05:00
Jonathan Miller 44107d6485 docs: update CHANGELOG and wiki for v1.26.1-moko.06.02.00 final
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
PR RC Release / Build RC Release (pull_request) Failing after 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 1m6s
Changelog: comprehensive entry covering all features, security
fixes, platform feeds, UI changes, and settings restructure.

Wiki: all 7 platform feeds now listed as Production. Revision 1.5
added covering sub-orgs, visibility modes, settings pages, and
security hardening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 20:31:06 -05:00
jmiller 82c3c11053 chore: sync updates.xml 05.15.00 from main [skip ci] 2026-06-03 00:15:06 +00:00
Jonathan Miller ff6d1bf3c9 fix(licenses): add explicit xorm column names for all UpdateStreamConfig fields
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
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) Failing after 6s
PR RC Release / Build RC Release (pull_request) Failing after 19s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 1m23s
xorm auto-maps CamelCase to snake_case by splitting on each
uppercase letter. MaintainerURL became maintainer_u_r_l instead
of maintainer_url, causing DB reads to return empty values.

Added explicit column name tags to all multi-word fields:
SupportURL, KeyPrefix, ExtensionName, DisplayName, ExtensionType,
MaintainerURL, InfoURL, TargetVersion, PHPMinimum, LicensingEnabled,
RequireKey, FeedVisibility, DownloadGating, StreamMode, CustomStreams.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 19:12:54 -05:00
jmiller 9832f8a7bb chore: add .mokogitea/workflows/auto-release.yml from moko-platform [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
2026-06-02 23:47:37 +00:00
Jonathan Miller 9506a19ab8 feat(licenses): use ancestor-aware functions in org license handler
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 20s
Generic: Repo Health / Access control (push) Successful in 1s
Org licenses handler now uses ListLicensePackagesWithAncestors,
ListLicenseKeysWithAncestors, and SearchLicenseKeysWithAncestors
to show packages and keys from parent orgs in the hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 18:46:33 -05:00
Jonathan Miller d0e3b3dfd8 fix(ui): add octicon icons to user settings navbar
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
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) Failing after 6s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
PR RC Release / Build RC Release (pull_request) Successful in 22s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Profile (person), Account (shield-lock), Notifications (bell),
Appearance (paintbrush), Security (lock), Blocked Users (blocked),
Applications (apps), SSH/GPG Keys (key), Actions (play),
Packages (package), Webhooks (webhook), Organizations (organization),
Repositories (repo).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 17:10:45 -05:00
jmiller 3231ac2707 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-02 21:51:52 +00:00
Jonathan Miller 963fa6d384 fix(licenses): always allow anonymous download path access on licensed repos
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 23s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RepoAssignment now always grants LicensedReadOnly for download
paths (/releases/, /archive/) on licensed repos. The actual
download gating (none/prerelease/all) is enforced by
CheckDownloadGating in the handler.

Previously only download_gating=none allowed anonymous access.
Now prerelease gating also allows through (CheckDownloadGating
blocks non-stable downloads without a key).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:50:23 -05:00
Jonathan Miller 48ff05d4b3 fix(updates): feed always public, downloads gated separately
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 24s
The updates.xml/JSON feed is now always fully public with versions
and download URLs visible. The actual file downloads are what get
blocked by CheckDownloadGating (none/prerelease/all).

Previously require_key and feed_visibility controlled the XML
feed visibility. Now the feed is informational only — Joomla can
always see what versions are available. The download gating
setting controls whether the files can actually be downloaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:45:00 -05:00
Moko Consulting 70699b4f2a chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-02 20:38:03 +00:00
Moko Consulting 99f5833c25 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-02 20:37:54 +00:00
Moko Consulting 241596361e chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Site Health (push) Has been skipped
2026-06-02 20:37:45 +00:00
Jonathan Miller 6405163e60 fix(licenses): restrict downloadsPublic to release/download paths only
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
PR RC Release / Build RC Release (pull_request) Successful in 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
The downloadsPublic flag was granting LicensedReadOnly to all
routes including the main repo page, causing 404 on private repos.
Now only applies to paths containing /releases/ or /archive/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:30:23 -05:00
Jonathan Miller 01011f6115 fix(licenses): allow anonymous downloads when download_gating=none on private repos
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
PR RC Release / Build RC Release (pull_request) Successful in 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RepoAssignment now checks the download_gating setting. When set to
"none" (all downloads public), anonymous users can access release
downloads on licensed private repos without a key.

Previously, anonymous users always got 403 on private repos even
when download gating was set to public.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:25:28 -05:00
Jonathan Miller ea10e8500c fix(ui): replace invalid octicon-settings with octicon-gear
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Successful in 23s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:45:37 -05:00
Jonathan Miller 92bd3f7dc0 fix(ui): clean section headers with dividers instead of accordions, icons on all navbar items
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 22s
Advanced settings: replace collapsible accordions with clean h5
section headers (icon + bold text) separated by dividers. All
sections always visible — no clicking to expand.

Navbar: add octicon icons to every menu item — Options (settings),
Advanced (tools), Licensing (key), Public Access (eye),
Collaboration (people), Webhooks (webhook), Branches (git-branch),
Tags (tag), Git Hooks (terminal), Deploy Keys (key-asterisk),
LFS (file-binary), Actions (play).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:33:23 -05:00
Jonathan Miller 89fcbda623 feat(settings): move advanced settings to dedicated /settings/advanced page
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
PR RC Release / Build RC Release (pull_request) Failing after 18s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Extract all feature unit settings (Code, Wiki, Issues, Projects,
Releases, Packages, Pull Requests) from options.tmpl into a
separate advanced.tmpl with its own route at /settings/advanced.

Options page now only contains: basic repo settings, avatar,
mirror config, signing settings, and danger zone.

Navbar updated: Advanced Settings link points to /settings/advanced.
Form posts still go through the existing SettingsPost handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:23:49 -05:00
Jonathan Miller dd6ee750f0 fix(build): remove extra {{end}} in settings accordion template
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:47 -05:00
Jonathan Miller ffb9363e3e feat(settings): accordion layout for advanced settings sections
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 21s
Each feature section (Code, Wiki, Issues, Projects, Releases,
Packages, Pull Requests) is now wrapped in a collapsible <details>
accordion with an icon and bold title.

Code section is open by default; others are collapsed. This
reduces visual clutter and lets admins focus on the section
they need.

Licensing section removed from advanced settings (now on its
own /settings/licensing page).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:13:32 -05:00
Jonathan Miller a1ceac6396 feat(settings): separate licensing settings page with navbar entry
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 23s
Extract licensing/update feed settings to its own page at
/settings/licensing with dedicated template and handler.

Navbar additions:
- Advanced Settings link (points to existing options page)
- Licensing link with key icon (when licensing enabled)

New handler: LicensingSettings/LicensingSettingsPost serves the
standalone licensing form with all fields (platform, gating,
metadata, extensions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:02:53 -05:00
Jonathan Miller a22fa57ab1 fix(ui): embed login form on 403 Access Denied page
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 19s
Anonymous users seeing the 403 page now get an inline login form
with username, password, and submit button. After login, redirects
back to the page they were trying to access.

Compact form centered with tw-max-w-sm, includes CSRF token and
redirect_to hidden field pointing to CurrentURL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:55:52 -05:00
Jonathan Miller 68736c78a1 fix(ui): float visibility badge to right of title label
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:51:31 -05:00
Jonathan Miller cfea80d3ca fix(build): use UpdateRepositoryColsWithAutoTime for is_hidden
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:46:26 -05:00
Jonathan Miller e2c738a8d8 feat(repos): three-level visibility — Public, Private, Hidden
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 22s
Add IsHidden field to Repository model. Three visibility modes:

- Public: visible to everyone (green label)
- Private: members only, non-members see 403 Access Denied (orange)
- Hidden: members only, non-members see 404 Not Found (red)

Private mode is for commercial repos — customers know the repo
exists and see a styled 403 page with sign-in button. Licensed
update feeds and key-gated downloads still work.

Hidden mode is for internal/secret repos — complete stealth, as
if the repo doesn't exist.

Settings UI: radio button selector in danger zone replaces the
old binary toggle. Each option shows a colored label with
description.

Migration v342: adds is_hidden column to repository table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:42:25 -05:00
Jonathan Miller 6c7a6e4061 fix(licenses): RequireUnitReader allows LicensedReadOnly access
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 21s
RequireUnitReader now checks for LicensedReadOnly context flag
before checking standard permissions. This lets the releases
download route pass through for licensed private repos where
RepoAssignment granted read-only access via license key.

Fixes the 404 on /releases/download/ with valid dlid= param.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:23:38 -05:00
Jonathan Miller 95d93da2bc fix(licenses): bypass attachment permission check for licensed downloads
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 20s
ServeAttachment checks perm.CanRead(unitType) which fails for
licensed read-only access on private repos. Now skips the check
when LicensedReadOnly is set in context (from RepoAssignment).

This allows Joomla/WordPress clients with valid dlid= params to
download release files from private licensed repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:06 -05:00
Jonathan Miller 02424c3f75 fix(licenses): allow download access on private licensed repos with license key
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 20s
RepoAssignment now checks for dlid/key/download_key query params
when licensing is enabled. Anonymous Joomla/WordPress clients with
valid license keys can access release download routes on private
repos without being signed in.

Access flow for licensed private repos:
- Anonymous + no key → 403 (styled page)
- Anonymous + valid dlid → access granted (CheckDownloadGating validates)
- Signed in + no membership → access granted (releases visible, downloads hidden)
- Org member → full access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:12:45 -05:00
Jonathan Miller 449af83e2b fix(ui): styled 403 Access Denied page matching the 404 page layout
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 21s
Add templates/status/403.tmpl mirroring the 404 page design with
a centered error message and sign-in button for anonymous users.

New Forbidden() method on Context renders the styled 403 template
for HTML requests, falls back to plain text for API/non-HTML.

RepoAssignment now calls ctx.Forbidden() instead of raw HTTPError
for both anonymous and signed-in users without access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:05:24 -05:00
Jonathan Miller 3ad37e48e1 fix(security): return 403 for all users on private repos, not 404
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 21s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Both anonymous and signed-in users now get 403 Access Denied when
accessing a private repo they lack permission for. Previously
anonymous users got 404 which hid the repo's existence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:57:29 -05:00
Jonathan Miller 021a054348 fix(licenses): licensed private repos allow signed-in users to view releases
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 19s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
When licensing is enabled on a private repo, signed-in users who
are not repo members can now view the releases page (with downloads
hidden). The RepoAssignment permission check detects licensing and
grants read-only access instead of returning 403.

This enables the commercial pattern: private source code, but
release notes visible to any authenticated user. Download files
are gated by license key via HideReleaseDownloads.

Anonymous users still get 404 (no information leak).
Non-licensed private repos still return 403 for non-members.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:52:05 -05:00
Jonathan Miller ead620daf9 fix(updates): allow update feeds on private repos via lightweight repo loader
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 20s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Update feed endpoints (updates.xml, dolibarr.json, wordpress.json,
packages.json, prestashop.xml, drupal.xml, whmcs.json, changelog.xml)
now use RepoAssignmentPublicFeed instead of the full RepoAssignment.

The lightweight loader fetches the repo by owner/name without checking
user permissions. Feed handlers gate access via license keys, not repo
membership. This allows private repos to serve update feeds to
anonymous Joomla/WordPress/Composer clients with valid license keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:36:06 -05:00
Jonathan Miller 0add8bda72 fix(security): show 403 Access Denied instead of 404 for signed-in users on private repos
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 19s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Signed-in users who lack permission to a private repo now see a
403 "You do not have permission" instead of a misleading 404.
Anonymous users still get 404 to prevent repo enumeration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:26:21 -05:00
Jonathan Miller bd81616432 fix(build): remove unused time import in drupal.go
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 20s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:10:11 -05:00
Jonathan Miller 02f3ed88f1 feat(updates): PrestaShop (#352), Drupal (#353), WHMCS (#355) update feeds
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 18s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
PrestaShop: GET /updates/prestashop.xml — module update XML with
name, version, download URL, author, SHA256. Serves stable only.

Drupal: GET /updates/drupal.xml — update status XML per Drupal API
spec. Includes project metadata, all releases with status, download
links, SHA256. Uses TargetVersion config for api_version field.

WHMCS: GET /updates/whmcs.json — simple JSON with latest stable
version, download URL (with dlid), changelog, author. License key
embedded in download URL when provided.

All three use ResolveReleaseStream for manual/auto stream mapping,
readSHA256FromSidecar for integrity hashes, and extractVersion with
stream-name tag fallback.

Routes registered under the update server group alongside Joomla,
Dolibarr, WordPress, and Composer feeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:03 -05:00
Jonathan Miller 0fb0aea719 feat(updates): Composer packages.json feed (#354), hide menu items for guests
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 20s
Composer feed: new endpoint GET /updates/packages.json serving
Composer/Packagist-compatible packages.json. Includes version,
dist URL with SHA256, authors, PHP requirement. License key
embedded in download URL when provided.

Menu visibility: Actions and Licenses tabs in repo header now
require .IsSigned — anonymous users no longer see tabs they
can't access. Previously the tabs were visible but clicking
redirected to login (confusing UX).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:02:00 -05:00
Jonathan Miller eca929f680 feat(licenses): configurable key prefix (#406), header button (#408), open feed button (#409)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
PR RC Release / Build RC Release (pull_request) Failing after 18s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
#406: Add KeyPrefix field to UpdateStreamConfig. GenerateKeyString
now accepts a prefix parameter, looked up from org config. Default
remains MOKO if not set. Auto-uppercased, max 20 chars.

#408: Move "New Package" button into the packages header bar,
right-aligned. Uses details/summary pattern — clicking the button
expands the create form below. Cleaner layout on both repo and org.

#409: Add open-in-new-tab button (external link icon) next to every
copy button on feed URLs. All four feeds: Joomla XML, Dolibarr JSON,
WordPress JSON, Changelog XML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:51:55 -05:00
Jonathan Miller b65b155446 SECURITY: fix release download gating and require login for actions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 21s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Release gating: HideReleaseDownloads now checks download_gating
setting in addition to feed_visibility. When licensing is enabled
and download_gating != "none", anonymous users see "Sign in to
download" instead of download links on the release page.

Actions: changed from optSignIn to reqSignIn on the repo actions
route group. Anonymous users can no longer view CI/CD runs, logs,
or artifacts. This is a MokoGitea policy override — upstream Gitea
allows public actions on public repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:40:40 -05:00
Jonathan Miller de52ad0fbc fix(build): permanent fixes for recurring build errors
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 26s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
- AI migration 339: replaced with noopMigration placeholder
- feed/file.go: add missing comma in struct literal
- license_key.go: remove unused org_model import

These were being applied as server-side hotfixes on every deploy.
Now committed to dev so they persist through merges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:34:14 -05:00
Jonathan Miller 1dfa5d8079 SECURITY: require login for licenses page — was accessible anonymously
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 21s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
The repo licenses route used optSignIn (login optional), allowing
anonymous users to view license packages and keys. Changed to
reqSignIn to require authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:25:31 -05:00
Jonathan Miller 70793075fc fix(build): use db.Find for org list, remove unused org_model import
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 22s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:21:39 -05:00
Jonathan Miller 2799558040 feat(orgs): enterprise sub-org hierarchy with parent-child relationships (#410)
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 26s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Add ParentOrgID field to User model for org hierarchy. Parent orgs
can have child orgs, enabling enterprise structures like
MokoConsulting → client orgs.

Model changes:
- ParentOrgID int64 on User (INDEX, DEFAULT 0)
- GetChildOrgs, GetAncestorOrgIDs, GetParentOrg helpers
- Max 10 hierarchy levels with cycle detection

License integration:
- ListLicensePackagesWithAncestors — shows packages from parent orgs
- ListLicenseKeysWithAncestors — shows keys from parent orgs
- SearchLicenseKeysWithAncestors — searches across hierarchy
- Master keys from parent orgs validate for child org repos

UI:
- Parent org dropdown in org settings (owners/admins only)
- Shows all orgs user owns except self

Migration v341: adds parent_org_id column to user table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:14:08 -05:00
Jonathan Miller d85ae6aa21 fix(build): add UpdateStream to EditReleaseForm
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 19s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:56:11 -05:00
Jonathan Miller 1b9b82d59a fix(build): pass ctx to buildWordPressChangelog for ResolveReleaseStream
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 24s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:47:42 -05:00
Jonathan Miller 37322e4212 feat(updates): manual release-to-stream mapping
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 22s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Add release_stream_map table for explicitly assigning releases to
update streams. When a mapping exists, it overrides automatic tag
detection. When absent, falls back to tag name/suffix matching.

New model: ReleaseStreamMap with SetReleaseStream, GetReleaseStream,
ResolveReleaseStream (manual first, auto fallback).

UI: stream selector dropdown on release create/edit page, shown when
licensing is enabled. Options: auto-detect (default) or any
configured stream (stable, release-candidate, beta, etc.).

All three feed generators (Joomla, Dolibarr, WordPress) now use
ResolveReleaseStream instead of MatchStreamFromTag.

Migration v340 updated with release_stream_map table creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:37:02 -05:00
Jonathan Miller 2f9097a254 fix(updates): check tag name not extracted version for stream name detection
isStreamName was checking the extracted version (empty for stream
tags) instead of the original tag name. Now checks rel.TagName
directly, and also falls through when extractVersion returns empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:29:43 -05:00
Jonathan Miller ce3af35c40 fix(updates): extract version numbers from release titles via regex
When tags are stream names, extractVersion falls back to finding a
version pattern (digits.digits.digits) anywhere in the release title.
Handles titles like "Package - MokoWaaS (VERSION: 02.31.00)".

Previously the full title was used as the version, producing invalid
entries like "Package - MokoWaaS (VERSION: 02.31.00)" in the XML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:27:06 -05:00
Jonathan Miller 0a3cd3115f feat(updates): support stream-name tags alongside version tags
MatchStreamFromTag now checks if the tag name directly matches a
stream name (e.g. "stable", "release-candidate", "development")
before falling back to suffix matching. Supports both conventions:

1. Stream-name tags: tag IS the stream (MokoWaaS style)
2. Version tags: tag has version + suffix (v1.0.0-rc1 style)

When a stream-name tag is detected, the version number is extracted
from the release title instead of the tag. Falls back to tag name
if no version found in title.

Applied across all feed generators: Joomla XML, Dolibarr JSON,
WordPress JSON, and Changelog XML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:21:02 -05:00
Jonathan Miller 3e31b662a6 fix(licenses): remove UNIQUE constraint on PaymentRef, use tw-max-w-lg
PaymentRef UNIQUE constraint causes Error 1062 when creating keys
without a payment reference — empty strings collide. Remove the
DB constraint; idempotency is enforced in code via
GetLicenseKeyByPaymentRef which already filters empty strings.

Also replace inline style with tw-max-w-lg class on search box.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:12:13 -05:00
Jonathan Miller 774ea3842b fix(ui): constrain license key search box width to 400px
The search input was spanning full width (tw-w-full) making it
disproportionately large. Cap at 400px to match other search
inputs in the Gitea UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:10:43 -05:00
Jonathan Miller 0e7d3c4a34 fix(security): ownership guards, RepoScope parsing, CSRF tokens, XSS escaping
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
PR RC Release / Build RC Release (pull_request) Failing after 17s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
SECURITY: Add verifyPackageOwnership/verifyKeyOwnership checks to
all API handlers that accept ID parameters. Prevents cross-org
access where an admin of org A could modify org B's license data.

FIX: RepoScope validation now properly parses JSON arrays using
json.Unmarshal instead of strings.Contains. The old approach matched
substrings (repo ID "2" matched inside "12"). Now uses typed int64
comparison.

FIX: Add {{$.CsrfTokenHtml}} to both delete confirmation modal
forms (package and key) in repo and org templates. Without CSRF
tokens, the form-fetch-action POST requests would be rejected.

FIX: HTML-escape release notes in WordPress changelog to prevent
XSS via malicious release note content reaching WP admin dashboards.

FIX: Parse AllowedChannels JSON format before comma-split fallback
to avoid garbage values from splitting JSON arrays by comma.

FIX: Add missing third return value (false) on error path in
validateUpdateKey to prevent compile error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 06:59:29 -05:00
57 changed files with 3798 additions and 946 deletions
+283
View File
@@ -0,0 +1,283 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+277 -236
View File
@@ -1,236 +1,277 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://code.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://code.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+711
View File
@@ -0,0 +1,711 @@
# ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Generic: Repo Health"
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- scripts
- repo
pull_request:
push:
permissions:
contents: read
env:
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .mokogitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
missing_required=()
missing_optional=()
# Source directory: src/ or htdocs/ (either is valid for extension repos)
SOURCE_DIR=""
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
# Platform/tooling repos don't need src/
SOURCE_DIR=""
else
missing_required+=("src/ or htdocs/ (source directory required)")
fi
for item in "${required_artifacts[@]}"; do
if printf '%s' "${item}" | grep -q '/$'; then
d="${item%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
missing_required+=("dev or dev/* branch")
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
if [ -n "${SOURCE_DIR}" ]; then
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
fi
done
fi
if [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links=""
while IFS= read -r docline; do
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
linkpath="${link%%#*}"
linkpath="${linkpath%%\?*}"
[ -z "$linkpath" ] && continue
if [ "${linkpath:0:1}" = "/" ]; then
testpath="${linkpath#/}"
else
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
fi
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
done
done < "${DOCS_INDEX}"
if [ -n "${missing_links}" ]; then
extended_findings+=("docs/docs-index.md contains broken relative links")
{
printf '%s\n' '### Docs index link integrity'
printf '%s\n' 'Broken relative links:'
for bl in ${missing_links}; do
printf '%s\n' "- ${bl}"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
site-health:
name: Site Health
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Uptime check
if: env.URLS != ''
run: |
echo "$URLS" > /tmp/urls.txt
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
rm -f /tmp/urls.txt
env:
URLS: ${{ vars.MONITORED_URLS }}
- name: SSL certificate check
if: env.DOMAINS != ''
run: |
echo "$DOMAINS" > /tmp/domains.txt
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
rm -f /tmp/domains.txt
env:
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
- name: Summary
if: always()
run: |
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+36 -6
View File
@@ -10,11 +10,11 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Package archiving with soft-delete and collapsible archived section
* Search keys by customer, domain, key number, email, or payment ref
* Download gating (none/prerelease/all modes)
* Feed visibility (public/no-download/hidden modes)
* Domain lock grace period (DomainLockHours)
* RepoScope enforcement — packages scoped to specific repos
* Configurable license key prefix per organization
* Manual release-to-stream mapping with UI selector
* Joomla changelog XML endpoint (/changelog.xml)
* WordPress PUC-compatible update feed (/updates/wordpress.json)
* SHA256 checksums from sidecar files in Joomla updates.xml
* Joomla-standard tag values (dev/alpha/beta/rc/stable)
* Double confirmation modals for permanent deletion
@@ -23,12 +23,42 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* API: package CRUD, key revoke, key renew, settings GET/PUT
* API: purchase webhook with PaymentRef idempotency
* API: public validation endpoint (no auth)
* Migration v340: all new columns synced
* feat(updates): infourl defaults to release listing page
* Migration v340-v342: all new columns synced
* feat(updates): 7 platform update feeds
* Joomla XML with downloadkey, SHA256, changelog URL
* Dolibarr JSON with channel filtering
* WordPress PUC-compatible JSON (plugin-update-checker)
* Composer packages.json
* PrestaShop module update XML
* Drupal update status XML
* WHMCS module update JSON
* feat(updates): feed always public — downloads gated separately
* feat(updates): stream-name tags supported alongside version tags
* feat(updates): version extraction via regex from release titles
* feat(updates): infourl defaults to release listing / support URL
* feat(updates): downloadkey prefix matches Akeeba pattern (dlid=)
* feat(orgs): enterprise sub-org hierarchy with parent-child relationships
* feat(repos): three-level visibility — Public (200), Private (403), Hidden (404)
* feat(settings): separate licensing settings page (/settings/licensing)
* feat(settings): advanced settings on dedicated page (/settings/advanced)
* feat(settings): section headers with dividers and icons
* feat(ui): icons on all settings navbars (repo, org, user, admin)
* feat(ui): styled 403 Access Denied page with inline login form
* feat(ui): open-in-new-tab button on feed URLs
* SECURITY
* fix(security): ownership guards on all API handlers (cross-org prevention)
* fix(security): RepoScope JSON parsing (substring matching bug)
* fix(security): CSRF tokens in delete confirmation modals
* fix(security): XSS escaping in WordPress changelog HTML
* fix(security): require login for licenses and actions pages
* fix(security): 403 for all users on private repos (not 404)
* fix(security): licensed private repos allow release viewing for signed-in users
* fix(security): anonymous download access respects download_gating setting
* FIXES
* fix(licenses): expanded delete permissions to org owners + site admins
* fix(licenses): no-download mode shows release notes but hides files
* fix(licenses): releases require login in hidden feed visibility mode
* fix(licenses): explicit xorm column names for UpdateStreamConfig fields
* fix(licenses): feed always public when licensing enabled
* fix(build): permanent fixes for AI migration, feed/file.go, unused imports
## [v1.26.1-moko.05.15.00] - 2026-05-31
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+35 -14
View File
@@ -9,9 +9,11 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
@@ -31,7 +33,7 @@ type LicenseKey struct {
LicenseeEmail string `xorm:""` // customer email
DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default
PaymentRef string `xorm:"UNIQUE"` // idempotency key from payment system
PaymentRef string `xorm:""` // idempotency key from payment system
IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
@@ -46,14 +48,18 @@ func (LicenseKey) TableName() string {
return "license_key"
}
// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format.
func GenerateKeyString() (string, error) {
// GenerateKeyString creates a random license key in PREFIX-XXXX-XXXX-XXXX-XXXX format.
// If prefix is empty, defaults to "MOKO".
func GenerateKeyString(prefix string) (string, error) {
if prefix == "" {
prefix = "MOKO"
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
hex := strings.ToUpper(hex.EncodeToString(b))
return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil
h := strings.ToUpper(hex.EncodeToString(b))
return fmt.Sprintf("%s-%s-%s-%s-%s", prefix, h[0:4], h[4:8], h[8:12], h[12:16]), nil
}
// HashKey returns the SHA-256 hash of a raw key string.
@@ -63,8 +69,14 @@ func HashKey(rawKey string) string {
}
// CreateLicenseKey generates a new key, stores it in plaintext and hashed, and returns the raw key.
// The prefix is looked up from the org's update stream config.
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
rawKey, err = GenerateKeyString()
prefix := ""
cfg := GetEffectiveConfig(ctx, key.OwnerID, 0)
if cfg != nil && cfg.KeyPrefix != "" {
prefix = cfg.KeyPrefix
}
rawKey, err = GenerateKeyString(prefix)
if err != nil {
return "", fmt.Errorf("GenerateKeyString: %w", err)
}
@@ -332,21 +344,30 @@ func ValidateLicenseKeyForRepo(ctx context.Context, rawKey, domain string, repoI
// Check repo scope.
if pkg.RepoScope != "" && pkg.RepoScope != "all" {
// RepoScope is either a single repo ID or a JSON array of IDs.
scopeStr := pkg.RepoScope
repoIDStr := fmt.Sprintf("%d", repoID)
allowed := false
if strings.HasPrefix(scopeStr, "[") {
// JSON array format: ["1","2","3"]
if !strings.Contains(scopeStr, repoIDStr) {
return nil, nil, fmt.Errorf("license key not valid for this repository")
// JSON array format: parse properly to avoid substring matching bugs.
var ids []int64
if err := json.Unmarshal([]byte(scopeStr), &ids); err == nil {
for _, id := range ids {
if id == repoID {
allowed = true
break
}
}
}
} else {
// Single repo ID.
if scopeStr != repoIDStr {
return nil, nil, fmt.Errorf("license key not valid for this repository")
// Single repo ID string.
if id, err := strconv.ParseInt(scopeStr, 10, 64); err == nil {
allowed = id == repoID
}
}
if !allowed {
return nil, nil, fmt.Errorf("license key not valid for this repository")
}
}
return key, pkg, nil
+28
View File
@@ -5,8 +5,10 @@ package licenses
import (
"context"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/builder"
@@ -79,6 +81,32 @@ func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage,
return pkgs, db.GetEngine(ctx).Where("owner_id = ? AND is_archived = ?", ownerID, false).Find(&pkgs)
}
// ListLicensePackagesWithAncestors returns packages from the org and all parent orgs.
func ListLicensePackagesWithAncestors(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
pkgs := make([]*LicensePackage, 0, 10)
return pkgs, db.GetEngine(ctx).In("owner_id", ancestorIDs).Where("is_archived = ?", false).Find(&pkgs)
}
// ListLicenseKeysWithAncestors returns keys from the org and all parent orgs.
func ListLicenseKeysWithAncestors(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
keys := make([]*LicenseKey, 0, 20)
return keys, db.GetEngine(ctx).In("owner_id", ancestorIDs).Find(&keys)
}
// SearchLicenseKeysWithAncestors searches keys across the org and all parent orgs.
func SearchLicenseKeysWithAncestors(ctx context.Context, ownerID int64, query string) ([]*LicenseKey, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
keys := make([]*LicenseKey, 0, 20)
like := "%" + strings.ToLower(query) + "%"
return keys, db.GetEngine(ctx).
In("owner_id", ancestorIDs).
And("(LOWER(key_prefix) LIKE ? OR LOWER(key_raw) LIKE ? OR LOWER(licensee_name) LIKE ? OR LOWER(licensee_email) LIKE ? OR LOWER(domain_restriction) LIKE ? OR LOWER(payment_ref) LIKE ?)",
like, like, like, like, like, like).
Find(&keys)
}
// ListArchivedLicensePackages returns archived packages for the given owner.
func ListArchivedLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(ReleaseStreamMap))
}
// ReleaseStreamMap manually assigns a release to an update stream.
// When present, overrides automatic stream detection from tag names.
type ReleaseStreamMap struct {
ID int64 `xorm:"pk autoincr"`
ReleaseID int64 `xorm:"UNIQUE NOT NULL INDEX"` // FK to release
RepoID int64 `xorm:"NOT NULL INDEX"` // for fast repo-scoped queries
StreamName string `xorm:"NOT NULL"` // e.g. "stable", "release-candidate"
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (ReleaseStreamMap) TableName() string {
return "release_stream_map"
}
// SetReleaseStream assigns or updates the stream for a release.
func SetReleaseStream(ctx context.Context, releaseID, repoID int64, streamName string) error {
existing := new(ReleaseStreamMap)
has, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Get(existing)
if err != nil {
return err
}
if has {
existing.StreamName = streamName
_, err = db.GetEngine(ctx).ID(existing.ID).Cols("stream_name").Update(existing)
return err
}
_, err = db.GetEngine(ctx).Insert(&ReleaseStreamMap{
ReleaseID: releaseID,
RepoID: repoID,
StreamName: streamName,
})
return err
}
// GetReleaseStream returns the manually assigned stream for a release, or empty string.
func GetReleaseStream(ctx context.Context, releaseID int64) string {
m := new(ReleaseStreamMap)
has, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Get(m)
if err != nil || !has {
return ""
}
return m.StreamName
}
// GetStreamMapForRepo returns all manual stream assignments for a repo.
func GetStreamMapForRepo(ctx context.Context, repoID int64) (map[int64]string, error) {
var maps []ReleaseStreamMap
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&maps); err != nil {
return nil, err
}
result := make(map[int64]string, len(maps))
for _, m := range maps {
result[m.ReleaseID] = m.StreamName
}
return result, nil
}
// ResolveReleaseStream returns the stream for a release: manual mapping first, auto-detect fallback.
func ResolveReleaseStream(ctx context.Context, releaseID int64, tagName string, isPrerelease bool, streams []StreamDef) string {
if manual := GetReleaseStream(ctx, releaseID); manual != "" {
return manual
}
return MatchStreamFromTag(tagName, isPrerelease, streams)
}
// DeleteReleaseStream removes the manual stream assignment for a release.
func DeleteReleaseStream(ctx context.Context, releaseID int64) error {
_, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Delete(new(ReleaseStreamMap))
return err
}
+26 -15
View File
@@ -23,26 +23,27 @@ type UpdateStreamConfig struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla' 'stream_mode'"` // joomla, custom
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both, wordpress, prestashop, drupal
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system
RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public'"` // public, no-download, hidden
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none'"` // none, all, prerelease
SupportURL string `xorm:"TEXT"` // wiki or external support page URL
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false 'licensing_enabled'"` // master toggle
RequireKey bool `xorm:"NOT NULL DEFAULT false 'require_key'"` // require license key for update feed
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public' 'feed_visibility'"` // public, no-download, hidden
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none' 'download_gating'"` // none, all, prerelease
SupportURL string `xorm:"TEXT 'support_url'"` // wiki or external support page URL
KeyPrefix string `xorm:"VARCHAR(20) 'key_prefix'"` // org-specific license key prefix (e.g. "ACME")
// Extension metadata — used in update feed generation.
ExtensionName string `xorm:"TEXT"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
DisplayName string `xorm:"TEXT"` // human-readable name (e.g. "Package - MokoWaaS")
ExtensionName string `xorm:"TEXT 'extension_name'"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
DisplayName string `xorm:"TEXT 'display_name'"` // human-readable name (e.g. "Package - MokoWaaS")
Description string `xorm:"TEXT"` // short description for update feeds
ExtensionType string `xorm:"VARCHAR(50)"` // component, module, plugin, package, template, library
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library
Maintainer string `xorm:"TEXT"` // maintainer/author name
MaintainerURL string `xorm:"TEXT"` // maintainer website
InfoURL string `xorm:"TEXT"` // extension info/product page URL
TargetVersion string `xorm:"TEXT"` // target platform version regex (e.g. "(5|6)\..*")
PHPMinimum string `xorm:"VARCHAR(20)"` // minimum PHP version (e.g. "8.1")
MaintainerURL string `xorm:"TEXT 'maintainer_url'"` // maintainer website
InfoURL string `xorm:"TEXT 'info_url'"` // extension info/product page URL
TargetVersion string `xorm:"TEXT 'target_version'"` // target platform version regex (e.g. "(5|6)\..*")
PHPMinimum string `xorm:"VARCHAR(20) 'php_minimum'"` // minimum PHP version (e.g. "8.1")
// CustomStreams is a JSON array of stream definitions.
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
CustomStreams string `xorm:"TEXT"`
CustomStreams string `xorm:"TEXT 'custom_streams'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
@@ -171,10 +172,20 @@ func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error {
}
// MatchStreamFromTag determines which stream a tag belongs to based on the given stream definitions.
// Supports two conventions:
// - Stream-name tags: tag IS the stream name (e.g. "stable", "release-candidate", "development")
// - Version tags: tag contains a version + optional suffix (e.g. "v1.0.0", "v1.0.0-rc1")
func MatchStreamFromTag(tagName string, isPrerelease bool, streams []StreamDef) string {
lower := strings.ToLower(tagName)
// Check custom suffixes (longest match first to avoid "-rc" matching before "-rc-special").
// First: check if the tag name directly matches a stream name (stream-name convention).
for _, s := range streams {
if strings.EqualFold(s.Name, tagName) {
return s.Name
}
}
// Second: check suffixes in the tag (version-tag convention, longest match first).
var bestMatch string
bestLen := 0
for _, s := range streams {
+3 -1
View File
@@ -416,8 +416,10 @@ func prepareMigrationTasks() []*migration {
newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable),
newMigration(337, "Add key_plain column to license_key", v1_27.AddKeyPlainToLicenseKey),
newMigration(338, "Add platform and require_key to update_stream_config", v1_27.AddPlatformAndRequireKeyToStreamConfig),
newMigration(339, "Add AI assistant tables", v1_27.AddAITables),
newMigration(339, "Placeholder for AI tables", noopMigration),
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser),
newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository),
}
return preparedMigrations
}
+17 -3
View File
@@ -13,7 +13,7 @@ import (
type licenseKey340 struct {
ID int64 `xorm:"pk autoincr"`
KeyRaw string `xorm:"TEXT"`
PaymentRef string `xorm:"UNIQUE"`
PaymentRef string `xorm:""`
LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
FirstUsedUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
}
@@ -49,18 +49,32 @@ type updateStreamConfig340 struct {
InfoURL string `xorm:"TEXT"`
TargetVersion string `xorm:"TEXT"`
PHPMinimum string `xorm:"VARCHAR(20)"`
KeyPrefix string `xorm:"VARCHAR(20)"`
}
func (updateStreamConfig340) TableName() string {
return "update_stream_config"
}
// SyncLicenseSystemColumns adds missing columns to license_key,
// license_package, and update_stream_config tables.
// releaseStreamMap340 creates the release-to-stream manual mapping table.
type releaseStreamMap340 struct {
ID int64 `xorm:"pk autoincr"`
ReleaseID int64 `xorm:"UNIQUE NOT NULL INDEX"`
RepoID int64 `xorm:"NOT NULL INDEX"`
StreamName string `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (releaseStreamMap340) TableName() string {
return "release_stream_map"
}
// SyncLicenseSystemColumns adds missing columns and creates new tables.
func SyncLicenseSystemColumns(x *xorm.Engine) error {
return x.Sync(
new(licenseKey340),
new(licensePackage340),
new(updateStreamConfig340),
new(releaseStreamMap340),
)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import "xorm.io/xorm"
type userParentOrg341 struct {
ID int64 `xorm:"pk autoincr"`
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"`
}
func (userParentOrg341) TableName() string {
return "user"
}
// AddParentOrgIDToUser adds the parent_org_id column to the user table
// for enterprise sub-org hierarchy support.
func AddParentOrgIDToUser(x *xorm.Engine) error {
return x.Sync(new(userParentOrg341))
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import "xorm.io/xorm"
type repoHidden342 struct {
ID int64 `xorm:"pk autoincr"`
IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"`
}
func (repoHidden342) TableName() string {
return "repository"
}
// AddIsHiddenToRepository adds the is_hidden column for three-level repo visibility.
func AddIsHiddenToRepository(x *xorm.Engine) error {
return x.Sync(new(repoHidden342))
}
+42
View File
@@ -97,6 +97,48 @@ func (org *Organization) IsOrgAdmin(ctx context.Context, uid int64) (bool, error
return IsOrganizationAdmin(ctx, org.ID, uid)
}
// HasParentOrg returns true if this org has a parent.
func (org *Organization) HasParentOrg() bool {
return org.ParentOrgID > 0
}
// GetParentOrg returns the parent organization, or nil if none.
func (org *Organization) GetParentOrg(ctx context.Context) (*Organization, error) {
if org.ParentOrgID == 0 {
return nil, nil
}
return GetOrgByID(ctx, org.ParentOrgID)
}
// GetChildOrgs returns all direct child organizations.
func GetChildOrgs(ctx context.Context, parentOrgID int64) ([]*Organization, error) {
var orgs []*Organization
return orgs, db.GetEngine(ctx).
Where("type = ? AND parent_org_id = ?", user_model.UserTypeOrganization, parentOrgID).
Find(&orgs)
}
// GetAncestorOrgIDs returns all org IDs in the parent chain (including self).
// Used for license validation — a key from any ancestor org is valid.
func GetAncestorOrgIDs(ctx context.Context, orgID int64) []int64 {
ids := []int64{orgID}
currentID := orgID
seen := map[int64]bool{orgID: true}
for i := 0; i < 10; i++ { // max 10 levels to prevent infinite loops
org, err := GetOrgByID(ctx, currentID)
if err != nil || org.ParentOrgID == 0 {
break
}
if seen[org.ParentOrgID] {
break // cycle detected
}
seen[org.ParentOrgID] = true
ids = append(ids, org.ParentOrgID)
currentID = org.ParentOrgID
}
return ids
}
// IsOrgMember returns true if given user is member of organization.
func (org *Organization) IsOrgMember(ctx context.Context, uid int64) (bool, error) {
return IsOrganizationMember(ctx, org.ID, uid)
+1
View File
@@ -185,6 +185,7 @@ type Repository struct {
NumOpenActionRuns int `xorm:"-"`
IsPrivate bool `xorm:"INDEX"`
IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"` // hidden repos return 404, private repos return 403
IsEmpty bool `xorm:"INDEX"`
IsArchived bool `xorm:"INDEX"`
IsMirror bool `xorm:"INDEX"`
+1
View File
@@ -152,6 +152,7 @@ type User struct {
NumMembers int
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"`
RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"`
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"` // 0 = no parent (top-level org)
// Preferences
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
+20
View File
@@ -110,6 +110,7 @@
"loading": "Loading…",
"files": "Files",
"error_title": "Error",
"error403": "You do not have permission to access this resource. If you believe this is an error, contact the repository owner.",
"error404": "The page you are trying to reach either <strong>does not exist</strong> or <strong>you are not authorized</strong> to view it.",
"error503": "The server could not complete your request. Please try again later.",
"go_back": "Go Back",
@@ -2677,6 +2678,7 @@
"repo.licenses.feed_joomla_updates": "Joomla updates.xml",
"repo.licenses.feed_dolibarr_updates": "Dolibarr JSON",
"repo.licenses.feed_wordpress_updates": "WordPress (PUC JSON)",
"repo.licenses.open_feed": "Open in new tab",
"repo.licenses.feed_changelog_xml": "Changelog XML (Joomla)",
"repo.licenses.master_label": "Master",
"repo.licenses.unlimited": "unlimited",
@@ -2714,6 +2716,19 @@
"repo.settings.download_gating": "Download Gating",
"repo.settings.support_url": "Support / Product Page URL",
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
"repo.settings.features": "Features",
"repo.settings.features_units": "Units",
"repo.settings.change_visibility": "Change Visibility",
"repo.settings.visibility.warning": "Changing repository visibility affects who can access code, releases, and update feeds.",
"repo.settings.visibility.public.label": "Public",
"repo.settings.visibility.public.desc": "Visible to everyone. Anyone can clone and view.",
"repo.settings.visibility.private.label": "Private",
"repo.settings.visibility.private.desc": "Members only. Non-members see Access Denied (403). Licensed update feeds still work.",
"repo.settings.visibility.hidden.label": "Hidden",
"repo.settings.visibility.hidden.desc": "Members only. Non-members see Not Found (404). Hides the repo's existence entirely.",
"repo.release.update_stream": "Update Stream",
"repo.release.update_stream_auto": "(auto-detect from tag name)",
"repo.release.update_stream_help": "Assign this release to an update stream. The update feed will serve the latest release per stream.",
"repo.release.downloads_require_login": "Sign in to download release files.",
"repo.settings.extension_metadata": "Extension Metadata",
"repo.settings.extension_metadata_desc": "Override the org-level extension metadata for this repository. Empty fields inherit from the organization settings.",
@@ -2905,6 +2920,11 @@
"org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).",
"org.settings.custom_streams": "Custom Stream Definitions (JSON)",
"org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]",
"org.settings.key_prefix": "License Key Prefix",
"org.settings.key_prefix_help": "Custom prefix for license keys generated in this org (e.g. ACME, CLIENT). Leave empty for default (MOKO). Max 20 chars, auto-uppercased.",
"org.settings.parent_org": "Parent Organization",
"org.settings.parent_org_none": "(none — top-level organization)",
"org.settings.parent_org_help": "Set a parent org for enterprise hierarchy. Child orgs inherit license packages and master keys from parent orgs.",
"org.settings.update_streams_saved": "Settings saved.",
"org.settings.full_name": "Full Name",
"org.settings.email": "Contact Email Address",
+59 -2
View File
@@ -69,6 +69,24 @@ func UpdateLicenseSettings(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, form)
}
// verifyPackageOwnership checks that a package belongs to the current repo's owner.
func verifyPackageOwnership(ctx *context.APIContext, pkg *licenses.LicensePackage) bool {
if pkg.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.APIErrorNotFound(nil)
return false
}
return true
}
// verifyKeyOwnership checks that a key belongs to the current repo's owner.
func verifyKeyOwnership(ctx *context.APIContext, key *licenses.LicenseKey) bool {
if key.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.APIErrorNotFound(nil)
return false
}
return true
}
func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage {
return &structs.LicensePackage{
ID: pkg.ID,
@@ -165,6 +183,9 @@ func EditLicensePackage(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be edited")
@@ -210,6 +231,9 @@ func DeleteLicensePackage(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be deleted")
@@ -233,6 +257,9 @@ func ArchiveLicensePackage(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be archived")
@@ -249,7 +276,16 @@ func ArchiveLicensePackage(ctx *context.APIContext) {
// UnarchiveLicensePackage restores an archived license package via API.
func UnarchiveLicensePackage(ctx *context.APIContext) {
if err := licenses.UnarchiveLicensePackage(ctx, ctx.PathParamInt64("id")); err != nil {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if err := licenses.UnarchiveLicensePackage(ctx, pkgID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -331,6 +367,9 @@ func EditLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
if key.IsInternal {
ctx.APIError(http.StatusForbidden, "master keys cannot be edited")
@@ -386,6 +425,9 @@ func PurchaseLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
key := &licenses.LicenseKey{
PackageID: form.PackageID,
@@ -423,6 +465,9 @@ func RenewLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
@@ -453,6 +498,9 @@ func RevokeLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
key.IsActive = false
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
@@ -465,7 +513,16 @@ func RevokeLicenseKey(ctx *context.APIContext) {
// DeleteLicenseKey deletes a license key.
func DeleteLicenseKey(ctx *context.APIContext) {
if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
if err := licenses.DeleteLicenseKey(ctx, keyID); err != nil {
ctx.APIErrorInternal(err)
return
}
+1 -1
View File
@@ -22,7 +22,7 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string
}
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.String()
Revision: ctx.Repo.RefFullName.String(),
File: fileName,
Page: 1,
})
+3 -3
View File
@@ -89,7 +89,7 @@ func Licenses(ctx *context.Context) {
}
}
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
pkgs, err := licenses.ListLicensePackagesWithAncestors(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicensePackages", err)
return
@@ -112,9 +112,9 @@ func Licenses(ctx *context.Context) {
var keys []*licenses.LicenseKey
if searchQuery != "" {
keys, err = licenses.SearchLicenseKeys(ctx, ownerID, searchQuery)
keys, err = licenses.SearchLicenseKeysWithAncestors(ctx, ownerID, searchQuery)
} else {
keys, err = licenses.ListLicenseKeys(ctx, ownerID)
keys, err = licenses.ListLicenseKeysWithAncestors(ctx, ownerID)
}
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
+28
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
packages_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/packages"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
@@ -47,6 +48,22 @@ func Settings(ctx *context.Context) {
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["ParentOrgID"] = ctx.Org.Organization.ParentOrgID
// Load available parent orgs (all orgs the current user belongs to, excluding self).
if ctx.Doer.IsAdmin || ctx.Org.IsOwner {
orgs, _ := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
UserID: ctx.Doer.ID,
IncludeVisibility: structs.VisibleTypePrivate,
})
var parentCandidates []*org_model.Organization
for _, o := range orgs {
if o.ID != ctx.Org.Organization.ID {
parentCandidates = append(parentCandidates, o)
}
}
ctx.Data["ParentOrgCandidates"] = parentCandidates
}
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
@@ -98,6 +115,17 @@ func SettingsPost(ctx *context.Context) {
return
}
// Save parent org (enterprise hierarchy).
parentOrgID := ctx.FormInt64("parent_org_id")
if parentOrgID != org.ParentOrgID {
user := org.AsUser()
user.ParentOrgID = parentOrgID
if err := user_model.UpdateUserCols(ctx, user, "parent_org_id"); err != nil {
ctx.ServerError("UpdateUserCols", err)
return
}
}
log.Trace("Organization setting updated: %s", org.Name)
ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings")
+2
View File
@@ -5,6 +5,7 @@ package org
import (
"net/http"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
@@ -47,6 +48,7 @@ func SettingsUpdateStreamsPost(ctx *context.Context) {
FeedVisibility: ctx.FormString("feed_visibility"),
DownloadGating: ctx.FormString("download_gating"),
SupportURL: ctx.FormString("support_url"),
KeyPrefix: strings.ToUpper(strings.TrimSpace(ctx.FormString("key_prefix"))),
ExtensionName: ctx.FormString("extension_name"),
DisplayName: ctx.FormString("display_name"),
Description: ctx.FormString("feed_description"),
+5 -2
View File
@@ -182,8 +182,11 @@ func ServeAttachment(ctx *context.Context, uuid string) {
}
if !perm.CanRead(unitType) {
ctx.HTTPError(http.StatusNotFound)
return
// Allow access for licensed read-only mode (private repo with valid license key).
if ctx.Data["LicensedReadOnly"] != true {
ctx.HTTPError(http.StatusNotFound)
return
}
}
if requiredScope, ok := attachmentReadScope(unitType); ok {
+6
View File
@@ -77,6 +77,12 @@ func ServeChangelogXML(ctx *context.Context) {
}
version := extractVersionFromTag(rel.TagName)
// If the tag is a stream name, try the release title for the version.
if version == rel.TagName && (version == "stable" || version == "release-candidate" || version == "beta" || version == "alpha" || version == "development") {
if titleVer := extractVersionFromTag(rel.Title); titleVer != "" {
version = titleVer
}
}
cl := xmlChangelog{
Element: element,
Type: extType,
+23
View File
@@ -14,6 +14,7 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
@@ -355,6 +356,17 @@ func newReleaseCommon(ctx *context.Context) {
upload.AddUploadContext(ctx, "release")
// Load available streams for the stream selector (when licensing enabled).
if ctx.Data["LicensingEnabled"] == true {
ownerID := ctx.Repo.Repository.OwnerID
orgCfg, _ := licenses_model.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses_model.DefaultJoomlaStreams()
}
}
PrepareBranchList(ctx) // for New Release page
}
@@ -520,6 +532,10 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err)
return
}
// Save manual stream assignment if specified.
if streamName := form.UpdateStream; streamName != "" {
_ = licenses_model.SetReleaseStream(ctx, rel.ID, rel.RepoID, streamName)
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
return
}
@@ -580,6 +596,7 @@ func EditRelease(ctx *context.Context) {
ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease
ctx.Data["IsDraft"] = rel.IsDraft
ctx.Data["ReleaseStream"] = licenses_model.GetReleaseStream(ctx, rel.ID)
rel.Repo = ctx.Repo.Repository
if err := rel.LoadAttributes(ctx); err != nil {
@@ -660,6 +677,12 @@ func EditReleasePost(ctx *context.Context) {
ctx.ServerError("UpdateRelease", err)
return
}
// Save manual stream assignment.
if streamName := form.UpdateStream; streamName != "" {
_ = licenses_model.SetReleaseStream(ctx, rel.ID, rel.RepoID, streamName)
} else {
_ = licenses_model.DeleteReleaseStream(ctx, rel.ID)
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"net/http"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplSettingsAdvanced templates.TplName = "repo/settings/advanced"
// AdvancedSettings displays the advanced (feature units) settings page.
func AdvancedSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.advanced_settings")
ctx.Data["PageIsSettingsAdvanced"] = true
ctx.HTML(http.StatusOK, tplSettingsAdvanced)
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"net/http"
licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplSettingsLicensing templates.TplName = "repo/settings/licensing"
// LicensingSettings displays the licensing settings page.
func LicensingSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.licensing_section")
ctx.Data["PageIsSettingsLicensing"] = true
repoCfg, _ := licenses_model.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
ctx.Data["RepoUpdateConfig"] = repoCfg
ctx.HTML(http.StatusOK, tplSettingsLicensing)
}
// LicensingSettingsPost saves the licensing settings.
func LicensingSettingsPost(ctx *context.Context) {
repo := ctx.Repo.Repository
updatePlatform := ctx.FormString("update_platform")
if updatePlatform == "" {
updatePlatform = "joomla"
}
updateCfg := &licenses_model.UpdateStreamConfig{
OwnerID: repo.OwnerID,
RepoID: repo.ID,
Platform: updatePlatform,
LicensingEnabled: ctx.FormString("enable_licensing") == "on",
RequireKey: ctx.FormString("require_update_key") == "on",
DownloadGating: ctx.FormString("download_gating"),
SupportURL: ctx.FormString("support_url"),
ExtensionName: ctx.FormString("extension_name"),
DisplayName: ctx.FormString("display_name"),
ExtensionType: ctx.FormString("extension_type"),
TargetVersion: ctx.FormString("target_version"),
Maintainer: ctx.FormString("maintainer"),
PHPMinimum: ctx.FormString("php_minimum"),
StreamMode: "joomla",
}
if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil {
log.Error("SaveConfig: %v", err)
ctx.ServerError("SaveConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/licensing")
}
+24 -10
View File
@@ -1055,26 +1055,40 @@ func handleSettingsPostVisibility(ctx *context.Context) {
return
}
private := ctx.FormOptionalBool("private").ValueOrDefault(true) // default to true for privacy & safety
visibility := ctx.FormString("visibility")
// Backward compat: if old "private" field is sent instead of "visibility"
if visibility == "" {
private := ctx.FormOptionalBool("private").ValueOrDefault(true)
if private {
visibility = "private"
} else {
visibility = "public"
}
}
// System repos (dot-prefixed) cannot be made public, regardless of user role.
if !private && repo.IsSystemRepo() {
isPrivate := visibility == "private" || visibility == "hidden"
isHidden := visibility == "hidden"
if !isPrivate && repo.IsSystemRepo() {
ctx.JSONError(ctx.Tr("repo.settings.visibility.system_repo_private"))
return
}
// when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
if !private && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin {
if !isPrivate && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin {
ctx.JSONError(ctx.Tr("form.repository_force_private"))
return
}
if private && repo.FullName() != ctx.FormString("confirm_repo_name") {
ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name"))
err := repo_service.MakeRepoPrivate(ctx, repo, isPrivate)
if err != nil {
log.Error("Tried to change the visibility of the repo: %s", err)
ctx.JSONError(ctx.Tr("repo.settings.visibility.error"))
return
}
err := repo_service.MakeRepoPrivate(ctx, repo, private)
if err != nil {
log.Error("Tried to change the visibility of the repo: %s", err)
// Update IsHidden separately.
repo.IsHidden = isHidden
if err := repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "is_hidden"); err != nil {
log.Error("Failed to update is_hidden: %s", err)
ctx.JSONError(ctx.Tr("repo.settings.visibility.error"))
return
}
+142 -33
View File
@@ -27,30 +27,8 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
}
if rawKey == "" {
cfg := licenses.GetEffectiveConfig(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
feedVis := "public"
requireKey := false
if cfg != nil {
requireKey = cfg.RequireKey
if cfg.FeedVisibility != "" {
feedVis = cfg.FeedVisibility
}
}
if requireKey {
switch feedVis {
case "hidden":
// Fully hidden — return empty feed.
return nil, false, false
case "no-download":
// Show versions but strip download URLs.
return nil, true, true
default:
// "public" with RequireKey — still hide feed (backward compat).
return nil, false, false
}
}
// No key required — allow public access (all channels).
// Feed is always public — shows versions and download URLs.
// Actual file downloads are gated by CheckDownloadGating, not the feed.
return nil, true, false
}
@@ -58,7 +36,7 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
key, pkg, err := licenses.ValidateLicenseKeyForRepo(ctx, rawKey, domain, ctx.Repo.Repository.ID)
if err != nil {
log.Debug("License key validation failed: %v", err)
return nil, false
return nil, false, false
}
// Update heartbeat and record usage.
@@ -74,17 +52,18 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
// Parse allowed channels from the package.
if pkg.AllowedChannels != "" {
channels := strings.Split(pkg.AllowedChannels, ",")
var channels []string
if strings.HasPrefix(pkg.AllowedChannels, "[") {
// JSON array format — parse first to avoid substring issues.
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &channels); err != nil {
channels = strings.Split(pkg.AllowedChannels, ",")
}
} else {
channels = strings.Split(pkg.AllowedChannels, ",")
}
for i := range channels {
channels[i] = strings.TrimSpace(channels[i])
}
// Also try JSON array format.
if strings.HasPrefix(pkg.AllowedChannels, "[") {
var parsed []string
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil {
channels = parsed
}
}
// Normalize shorthand names to full Joomla convention.
for i := range channels {
channels[i] = updateserver.NormalizeChannel(channels[i])
@@ -210,3 +189,133 @@ func ServeWordPressJSON(ctx *context.Context) {
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
// ServeComposerJSON generates and serves a Composer packages.json feed.
func ServeComposerJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "composer" {
ctx.NotFound(nil)
return
}
_, ok, _ := validateUpdateKey(ctx)
if !ok {
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`{"packages":{}}`))
return
}
licenseKey := ctx.FormString("key")
if licenseKey == "" {
licenseKey = ctx.FormString("dlid")
}
data, err := updateserver.GenerateComposerJSON(ctx, ctx.Repo.Repository, licenseKey)
if err != nil {
ctx.ServerError("GenerateComposerJSON", err)
return
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
ctx.ServerError("json.Marshal", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
// ServePrestaShopXML generates and serves a PrestaShop module update XML.
func ServePrestaShopXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "prestashop" {
ctx.NotFound(nil)
return
}
allowedChannels, ok, _ := validateUpdateKey(ctx)
if !ok {
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><modules></modules>`))
return
}
xmlData, err := updateserver.GeneratePrestaShopXML(ctx, ctx.Repo.Repository, allowedChannels...)
if err != nil {
ctx.ServerError("GeneratePrestaShopXML", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(xmlData)
}
// ServeDrupalXML generates and serves a Drupal update status XML.
func ServeDrupalXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "drupal" {
ctx.NotFound(nil)
return
}
allowedChannels, ok, _ := validateUpdateKey(ctx)
if !ok {
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><project></project>`))
return
}
xmlData, err := updateserver.GenerateDrupalXML(ctx, ctx.Repo.Repository, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateDrupalXML", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(xmlData)
}
// ServeWHMCSJSON generates and serves a WHMCS module update JSON.
func ServeWHMCSJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "whmcs" {
ctx.NotFound(nil)
return
}
_, ok, _ := validateUpdateKey(ctx)
if !ok {
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`{"name":"","version":"0.0.0"}`))
return
}
licenseKey := ctx.FormString("key")
if licenseKey == "" {
licenseKey = ctx.FormString("dlid")
}
data, err := updateserver.GenerateWHMCSJSON(ctx, ctx.Repo.Repository, licenseKey)
if err != nil {
ctx.ServerError("GenerateWHMCSJSON", err)
return
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
ctx.ServerError("json.Marshal", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
+11 -3
View File
@@ -1183,6 +1183,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/avatar/delete", repo_setting.SettingsDeleteAvatar)
m.Combo("/public_access").Get(repo_setting.PublicAccess).Post(repo_setting.PublicAccessPost)
m.Group("", func() {
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
}, repo_setting.SettingsCtxData)
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
m.Group("/collaboration", func() {
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
@@ -1519,8 +1523,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/updates.xml", repo.ServeUpdatesXML)
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
m.Get("/updates/wordpress.json", repo.ServeWordPressJSON)
m.Get("/updates/packages.json", repo.ServeComposerJSON)
m.Get("/updates/prestashop.xml", repo.ServePrestaShopXML)
m.Get("/updates/drupal.xml", repo.ServeDrupalXML)
m.Get("/updates/whmcs.json", repo.ServeWHMCSJSON)
m.Get("/changelog.xml", repo.ServeChangelogXML)
}, optSignIn, context.RepoAssignment)
}, context.RepoAssignmentPublicFeed())
// end "/{username}/{reponame}": update server
// "/{username}/{reponame}": licenses page
@@ -1540,7 +1548,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
m.Post("/keys/{id}/renew", repo.LicensesRenewKey)
m.Post("/keys/{id}/delete", repo.LicensesDeleteKey)
}, optSignIn, context.RepoAssignment)
}, reqSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": licenses
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
@@ -1620,7 +1628,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)
})
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
}, reqSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
// end "/{username}/{reponame}/actions"
m.Group("/{username}/{reponame}/wiki", func() {
+21
View File
@@ -169,6 +169,27 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
ctx.HTML(http.StatusNotFound, "status/404")
}
// Forbidden displays a styled 403 (Access Denied) page, matching the 404 page layout.
func (ctx *Context) Forbidden() {
showHTML := false
for _, part := range ctx.Req.Header["Accept"] {
if strings.Contains(part, "text/html") {
showHTML = true
break
}
}
if !showHTML {
ctx.plainTextInternal(3, http.StatusForbidden, []byte("Access denied.\n"))
return
}
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
ctx.Data["Title"] = "Access Denied"
ctx.Data["CurrentURL"] = ctx.Req.URL.RequestURI()
ctx.HTML(http.StatusForbidden, "status/403")
}
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
// If the error is controlled by our error system, a related 404 page can be displayed instead.
func (ctx *Context) ServerError(logMsg string, logErr error) {
+4
View File
@@ -78,6 +78,10 @@ func RequireUnitWriter(unitTypes ...unit.Type) func(ctx *Context) {
// RequireUnitReader returns a middleware for requiring repository write to one of the unit permission
func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) {
return func(ctx *Context) {
// Licensed read-only mode grants read access to all units.
if ctx.Data["LicensedReadOnly"] == true {
return
}
for _, unitType := range unitTypes {
if ctx.Repo.Permission.CanRead(unitType) {
return
+69 -5
View File
@@ -435,8 +435,53 @@ func repoAssignmentLegacy(ctx *Context, data *repoAssignmentPrepareDataStruct) {
EarlyResponseForGoGetMeta(ctx)
return
}
ctx.NotFound(nil)
return
// Check if licensing is enabled — licensed repos allow access to
// releases and downloads via license key, even without membership.
orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID)
repoCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) ||
(repoCfg != nil && repoCfg.LicensingEnabled)
if licensingEnabled {
// Check if a license key is provided in query params (for Joomla/WP clients).
hasKey := ctx.FormString("dlid") != "" || ctx.FormString("key") != "" || ctx.FormString("download_key") != ""
// Check if downloads are set to public (download_gating=none means no key needed).
// Only apply to release/download paths, not the main repo page.
downloadsPublic := false
reqPath := ctx.Req.URL.Path
isDownloadPath := strings.Contains(reqPath, "/releases/") || strings.Contains(reqPath, "/archive/")
if isDownloadPath {
// Allow anonymous access to download paths — the actual gating
// is done by CheckDownloadGating in the handler, which checks
// the stream (stable vs prerelease) and validates the key.
// RepoAssignment just needs to let the request through.
downloadsPublic = true
}
if ctx.IsSigned || hasKey || downloadsPublic {
// Grant read-only access — downloads gated by CheckDownloadGating handler.
ctx.Data["LicensingEnabled"] = licensingEnabled
ctx.Data["HideReleaseDownloads"] = !hasKey && !ctx.IsSigned
ctx.Data["LicensedReadOnly"] = true
// Continue — don't block access.
} else if repo.IsHidden {
// Hidden repo: 404 — pretend it doesn't exist.
ctx.NotFound(nil)
return
} else {
// Private repo: 403 — access denied with styled page.
ctx.Forbidden()
return
}
} else if repo.IsHidden {
ctx.NotFound(nil)
return
} else {
ctx.Forbidden()
return
}
}
ctx.Data["Permission"] = &ctx.Repo.Permission
@@ -628,10 +673,29 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
feedVis = repoUpdateCfg.FeedVisibility
}
ctx.Data["FeedVisibility"] = feedVis
// Only "hidden" mode requires login. "no-download" shows page but hides files.
// Only "hidden" mode requires login for the page itself.
ctx.Data["ReleasesRequireLogin"] = licensingEnabled && feedVis == "hidden"
// Hide download attachments for anonymous users in "no-download" mode.
ctx.Data["HideReleaseDownloads"] = licensingEnabled && feedVis == "no-download" && !ctx.IsSigned
// Determine download gating mode.
downloadGating := "none"
if orgCfg != nil && orgCfg.DownloadGating != "" {
downloadGating = orgCfg.DownloadGating
}
if repoUpdateCfg != nil && repoUpdateCfg.DownloadGating != "" {
downloadGating = repoUpdateCfg.DownloadGating
}
ctx.Data["DownloadGating"] = downloadGating
// Hide download links on release page when:
// - licensing enabled AND feed visibility is "no-download" (anonymous only), OR
// - licensing enabled AND download gating is active AND user not signed in
hideDownloads := false
if licensingEnabled && !ctx.IsSigned {
if feedVis == "no-download" || feedVis == "hidden" || downloadGating != "none" {
hideDownloads = true
}
}
ctx.Data["HideReleaseDownloads"] = hideDownloads
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package context
import (
licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// RepoAssignmentPublicFeed is a lightweight repo loader for update feed endpoints.
// It loads the repo and owner without checking user permissions — the feed
// handlers gate access via license keys instead of repo membership.
// This allows private repos to serve update feeds to anonymous clients.
func RepoAssignmentPublicFeed() func(ctx *Context) {
return func(ctx *Context) {
ownerName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
owner, err := user_model.GetUserByName(ctx, ownerName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetRepositoryByOwnerAndName", err)
}
return
}
repo.Owner = owner
ctx.Repo.Repository = repo
// Load update config for platform-aware routing.
repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
if repoUpdateCfg != nil {
ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform
} else {
ctx.Data["RepoUpdatePlatform"] = "joomla"
}
log.Trace("Public feed access: %s/%s", ownerName, repoName)
}
}
+9 -7
View File
@@ -670,8 +670,9 @@ type NewReleaseForm struct {
Draft bool
TagOnly bool
Prerelease bool
AddTagMsg bool
Files []string
AddTagMsg bool
Files []string
UpdateStream string
}
// Validate validates the fields
@@ -695,11 +696,12 @@ func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Erro
// EditReleaseForm form for changing release
type EditReleaseForm struct {
Title string `form:"title" binding:"Required;MaxSize(255)"`
Content string `form:"content"`
Draft string `form:"draft"`
Prerelease bool `form:"prerelease"`
Files []string
Title string `form:"title" binding:"Required;MaxSize(255)"`
Content string `form:"content"`
Draft string `form:"draft"`
Prerelease bool `form:"prerelease"`
Files []string
UpdateStream string
}
// Validate validates the fields
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// ComposerPackage represents the packages.json response for Composer/Packagist.
type ComposerPackage struct {
Packages map[string]map[string]ComposerVersion `json:"packages"`
}
// ComposerVersion represents a single version entry.
type ComposerVersion struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Homepage string `json:"homepage,omitempty"`
License []string `json:"license,omitempty"`
Authors []ComposerAuthor `json:"authors,omitempty"`
Dist *ComposerDist `json:"dist,omitempty"`
Require map[string]string `json:"require,omitempty"`
Time string `json:"time,omitempty"`
}
// ComposerAuthor represents a package author.
type ComposerAuthor struct {
Name string `json:"name"`
Homepage string `json:"homepage,omitempty"`
}
// ComposerDist represents a distribution archive.
type ComposerDist struct {
URL string `json:"url"`
Type string `json:"type"`
Shasum string `json:"shasum,omitempty"`
Reference string `json:"reference,omitempty"`
}
// GenerateComposerJSON builds a Composer packages.json from repo releases.
func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*ComposerPackage, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
// Composer package name: vendor/package
packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name))
if cfg != nil && cfg.ExtensionName != "" {
packageName = cfg.ExtensionName
}
maintainer := repo.Owner.Name
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
if cfg != nil && cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
if cfg != nil && cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
description := ""
if cfg != nil && cfg.Description != "" {
description = cfg.Description
}
phpMin := ""
if cfg != nil && cfg.PHPMinimum != "" {
phpMin = ">=" + cfg.PHPMinimum
}
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
versions := make(map[string]ComposerVersion)
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch != "stable" {
continue // Composer only serves stable versions
}
version := extractVersion(rel.TagName)
if isStreamName(version, streams) {
version = extractVersion(rel.Title)
}
if version == "" {
continue
}
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
var downloadURL string
var sha256Hash string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
if licenseKey != "" {
downloadURL += "?dlid=" + licenseKey
}
// Look for SHA256 sidecar
for _, att := range rel.Attachments {
if strings.HasSuffix(att.Name, ".sha256") {
sha256Hash = readSHA256FromSidecar(ctx, att)
break
}
}
require := make(map[string]string)
if phpMin != "" {
require["php"] = phpMin
}
v := ComposerVersion{
Name: packageName,
Version: version,
Description: description,
Homepage: repoLink,
Authors: []ComposerAuthor{
{Name: maintainer, Homepage: maintainerURL},
},
Dist: &ComposerDist{
URL: downloadURL,
Type: "zip",
Shasum: sha256Hash,
Reference: rel.TagName,
},
Require: require,
Time: time.Unix(int64(rel.CreatedUnix), 0).Format(time.RFC3339),
}
versions[version] = v
}
return &ComposerPackage{
Packages: map[string]map[string]ComposerVersion{
packageName: versions,
},
}, nil
}
+7 -1
View File
@@ -67,7 +67,7 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allo
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
@@ -108,6 +108,12 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allo
}
version := extractVersion(rel.TagName)
if version == "" || isStreamName(rel.TagName, streams) {
version = extractVersion(rel.Title)
}
if version == "" {
version = rel.TagName
}
suffix := stream.Suffix
if suffix == "" {
suffix = channelSuffix(ch)
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"encoding/xml"
"fmt"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// Drupal update status XML structures.
// See: https://www.drupal.org/docs/drupal-apis/update-status-module/update-status-xml-format
type drupalProject struct {
XMLName xml.Name `xml:"project"`
Title string `xml:"title"`
ShortName string `xml:"short_name"`
APIVersion string `xml:"api_version"`
RecommendedMaj string `xml:"recommended_major"`
DefaultMajor string `xml:"default_major"`
ProjectStatus string `xml:"project_status"`
Link string `xml:"link"`
Releases drupalReleases `xml:"releases"`
}
type drupalReleases struct {
Releases []drupalRelease `xml:"release"`
}
type drupalRelease struct {
Name string `xml:"name"`
Version string `xml:"version"`
Tag string `xml:"tag"`
Status string `xml:"status"`
ReleaseLink string `xml:"release_link"`
DownloadURL string `xml:"download_link"`
Date string `xml:"date"`
FileHash string `xml:"mdhash,omitempty"`
SHA256 string `xml:"sha256,omitempty"`
}
// GenerateDrupalXML builds a Drupal update status-compatible XML feed.
func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
shortName := strings.ToLower(repo.Name)
title := repo.Name
if cfg != nil {
if cfg.ExtensionName != "" {
shortName = cfg.ExtensionName
}
if cfg.DisplayName != "" {
title = cfg.DisplayName
}
}
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
channelAllowed := make(map[string]bool)
if len(allowedChannels) > 0 {
for _, c := range allowedChannels {
channelAllowed[NormalizeChannel(c)] = true
}
}
project := drupalProject{
Title: title,
ShortName: shortName,
APIVersion: "7.x", // default API version
RecommendedMaj: "1",
DefaultMajor: "1",
ProjectStatus: "published",
Link: repoLink,
}
if cfg != nil && cfg.TargetVersion != "" {
project.APIVersion = cfg.TargetVersion
}
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if len(channelAllowed) > 0 && !channelAllowed[ch] {
continue
}
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
var downloadURL, sha256Hash string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
for _, att := range rel.Attachments {
if strings.HasSuffix(att.Name, ".sha256") {
sha256Hash = readSHA256FromSidecar(ctx, att)
break
}
}
version := extractVersion(rel.TagName)
if isStreamName(version, streams) || version == "" {
version = extractVersion(rel.Title)
}
if version == "" {
version = rel.TagName
}
status := "published"
if rel.IsPrerelease {
status = "insecure" // Drupal uses this for non-stable
}
project.Releases.Releases = append(project.Releases.Releases, drupalRelease{
Name: fmt.Sprintf("%s %s", shortName, version),
Version: version,
Tag: rel.TagName,
Status: status,
ReleaseLink: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
DownloadURL: downloadURL,
Date: fmt.Sprintf("%d", rel.CreatedUnix),
SHA256: sha256Hash,
})
}
output, err := xml.MarshalIndent(project, "", " ")
if err != nil {
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
}
return append([]byte(xml.Header), output...), nil
}
+46 -11
View File
@@ -8,6 +8,7 @@ import (
"encoding/xml"
"fmt"
"io"
"regexp"
"strings"
"time"
@@ -107,6 +108,17 @@ func channelFromTag(tagName string, isPrerelease bool) string {
}
}
// isStreamName checks if a string matches any stream name (indicating the tag
// is a stream name, not a version number).
func isStreamName(s string, streams []licenses.StreamDef) bool {
for _, st := range streams {
if strings.EqualFold(st.Name, s) {
return true
}
}
return false
}
// joomlaTagName maps internal stream names to Joomla-standard tag values.
// Joomla recognizes: dev, alpha, beta, rc, stable.
func joomlaTagName(channel string) string {
@@ -190,10 +202,12 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
maintainer = cfg.Maintainer
}
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
if cfg != nil && cfg.MaintainerURL != "" {
if cfg != nil && cfg.SupportURL != "" {
maintainerURL = cfg.SupportURL
} else if cfg != nil && cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
targetVersion := ".*"
targetVersion := "(5|6)\\..*"
if cfg != nil && cfg.TargetVersion != "" {
targetVersion = cfg.TargetVersion
}
@@ -215,7 +229,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
@@ -273,6 +287,14 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
}
version := extractVersion(rel.TagName)
// If the tag is a stream name (not a version), try the release title instead.
if version == "" || isStreamName(rel.TagName, streams) {
version = extractVersion(rel.Title)
}
// Last resort: use the tag name as-is.
if version == "" {
version = rel.TagName
}
suffix := stream.Suffix
if suffix == "" {
suffix = channelSuffix(ch) // fallback for Joomla defaults
@@ -287,9 +309,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
}
infoURL := fmt.Sprintf("%s/releases", repoLink)
if cfg != nil && cfg.SupportURL != "" {
infoURL = cfg.SupportURL
} else if cfg != nil && cfg.InfoURL != "" {
if cfg != nil && cfg.InfoURL != "" {
infoURL = cfg.InfoURL
}
@@ -340,20 +360,35 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
return append([]byte(xml.Header), output...), nil
}
// extractVersion strips common tag prefixes (v, release-, etc.) to get the version.
func extractVersion(tagName string) string {
v := tagName
// versionRegex matches semantic version patterns like 1.0.0, 02.29.04, etc.
var versionRegex = regexp.MustCompile(`(\d+\.\d+(?:\.\d+)?)`)
// extractVersion finds a version number from a tag name or release title.
// Tries: (1) strip common prefixes for version-style tags, (2) regex match for embedded versions.
func extractVersion(s string) string {
// Try prefix stripping first (works for "v1.0.0", "release-1.0.0").
v := s
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "release-")
v = strings.TrimPrefix(v, "release/")
// Strip channel suffixes to get base version.
// Strip channel suffixes.
for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
v = v[:idx]
break
}
}
return v
// If result looks like a version (starts with digit), use it.
if len(v) > 0 && v[0] >= '0' && v[0] <= '9' {
return strings.TrimSpace(v)
}
// Fallback: extract version pattern from anywhere in the string.
if m := versionRegex.FindString(s); m != "" {
return m
}
return ""
}
// channelSuffix returns the version suffix for a channel.
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"encoding/xml"
"fmt"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// PrestaShop module update XML structures.
type psUpdates struct {
XMLName xml.Name `xml:"modules"`
Modules []psModule `xml:"module"`
}
type psModule struct {
Name string `xml:"name,attr"`
Version string `xml:"version,attr"`
DisplayName string `xml:"displayName"`
Description string `xml:"description,omitempty"`
Author string `xml:"author"`
Tab string `xml:"tab,omitempty"`
Download string `xml:"download"`
Date string `xml:"date"`
SHA256 string `xml:"sha256,omitempty"`
}
// GeneratePrestaShopXML builds a PrestaShop-compatible module update XML.
func GeneratePrestaShopXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
moduleName := strings.ToLower(repo.Name)
displayName := repo.Name
maintainer := repo.Owner.Name
description := ""
if cfg != nil {
if cfg.ExtensionName != "" {
moduleName = cfg.ExtensionName
}
if cfg.DisplayName != "" {
displayName = cfg.DisplayName
}
if cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
if cfg.Description != "" {
description = cfg.Description
}
}
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
// Channel filtering.
channelAllowed := make(map[string]bool)
if len(allowedChannels) > 0 {
for _, c := range allowedChannels {
channelAllowed[NormalizeChannel(c)] = true
}
}
// Track best release per channel.
bestByChannel := make(map[string]*repo_model.Release)
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
var result psUpdates
for _, stream := range streams {
ch := stream.Name
if len(channelAllowed) > 0 && !channelAllowed[ch] {
continue
}
rel, ok := bestByChannel[ch]
if !ok || ch != "stable" {
continue // PrestaShop typically only serves stable
}
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
var downloadURL, sha256Hash string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
for _, att := range rel.Attachments {
if strings.HasSuffix(att.Name, ".sha256") {
sha256Hash = readSHA256FromSidecar(ctx, att)
break
}
}
version := extractVersion(rel.TagName)
if isStreamName(version, streams) || version == "" {
version = extractVersion(rel.Title)
}
if version == "" {
version = rel.TagName
}
result.Modules = append(result.Modules, psModule{
Name: moduleName,
Version: version,
DisplayName: displayName,
Description: description,
Author: maintainer,
Download: downloadURL,
Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
SHA256: sha256Hash,
})
}
output, err := xml.MarshalIndent(result, "", " ")
if err != nil {
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
}
return append([]byte(xml.Header), output...), nil
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// WHMCS module update JSON structures.
// WHMCS marketplace modules check for updates via a simple JSON endpoint.
type WHMCSUpdate struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Changelog string `json:"changelog,omitempty"`
ReleaseDate string `json:"release_date"`
Author string `json:"author,omitempty"`
AuthorURL string `json:"author_url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
// GenerateWHMCSJSON builds a WHMCS-compatible module update response.
func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WHMCSUpdate, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
displayName := repo.Name
maintainer := repo.Owner.Name
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
description := ""
if cfg != nil {
if cfg.DisplayName != "" {
displayName = cfg.DisplayName
}
if cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
if cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
if cfg.Description != "" {
description = cfg.Description
}
}
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
// Find latest stable release.
var latestStable *repo_model.Release
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch == "stable" {
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
latestStable = rel
}
}
}
if latestStable == nil {
return &WHMCSUpdate{
Name: displayName,
Version: "0.0.0",
}, nil
}
if err := latestStable.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("LoadAttributes: %w", err)
}
var downloadURL, sha256Hash string
for _, att := range latestStable.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName)
}
if licenseKey != "" {
downloadURL += "?dlid=" + licenseKey
}
for _, att := range latestStable.Attachments {
if strings.HasSuffix(att.Name, ".sha256") {
sha256Hash = readSHA256FromSidecar(ctx, att)
break
}
}
version := extractVersion(latestStable.TagName)
if isStreamName(version, streams) || version == "" {
version = extractVersion(latestStable.Title)
}
if version == "" {
version = latestStable.TagName
}
changelog := ""
if latestStable.Note != "" {
changelog = latestStable.Note
}
return &WHMCSUpdate{
Name: displayName,
Version: version,
Description: description,
DownloadURL: downloadURL,
Changelog: changelog,
ReleaseDate: time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02"),
Author: maintainer,
AuthorURL: maintainerURL,
SHA256: sha256Hash,
}, nil
}
+13 -6
View File
@@ -6,6 +6,7 @@ package updateserver
import (
"context"
"fmt"
"html"
"strings"
"time"
@@ -95,7 +96,7 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch == "stable" {
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
latestStable = rel
@@ -138,15 +139,21 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
}
version := extractVersion(latestStable.TagName)
if version == "" || isStreamName(latestStable.TagName, streams) {
version = extractVersion(latestStable.Title)
}
if version == "" {
version = latestStable.TagName
}
lastUpdated := time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02 3:04pm MST")
// Build sections from release notes.
sections := map[string]string{}
if latestStable.Note != "" {
sections["changelog"] = buildWordPressChangelog(releases, streams)
sections["changelog"] = buildWordPressChangelog(ctx, releases, streams)
}
if cfg != nil && cfg.Description != "" {
sections["description"] = "<p>" + cfg.Description + "</p>"
sections["description"] = "<p>" + html.EscapeString(cfg.Description) + "</p>"
}
// Build icon/banner URLs from repo assets.
@@ -170,14 +177,14 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
}
// buildWordPressChangelog builds an HTML changelog from multiple releases.
func buildWordPressChangelog(releases []*repo_model.Release, streams []licenses.StreamDef) string {
func buildWordPressChangelog(ctx context.Context, releases []*repo_model.Release, streams []licenses.StreamDef) string {
var b strings.Builder
count := 0
for _, rel := range releases {
if rel.IsDraft || rel.IsTag || rel.Note == "" {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch != "stable" {
continue
}
@@ -191,7 +198,7 @@ func buildWordPressChangelog(releases []*repo_model.Release, streams []licenses.
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
b.WriteString(fmt.Sprintf("<li>%s</li>\n", strings.TrimLeft(trimmed[2:], " ")))
b.WriteString(fmt.Sprintf("<li>%s</li>\n", html.EscapeString(strings.TrimLeft(trimmed[2:], " "))))
}
}
b.WriteString("</ul>\n")
+15 -9
View File
@@ -25,14 +25,16 @@
</div>
{{end}}
<h4 class="ui top attached header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}
<details id="new-package-details">
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
{{if .IsRepoAdmin}}
<summary class="ui primary small button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
{{end}}
</h4>
<div class="ui attached segment">
{{if .IsRepoAdmin}}
<details class="tw-mb-4">
<summary class="ui primary button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
<div class="tw-mt-4">
<div class="tw-mb-4">
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages">
{{.CsrfTokenHtml}}
<div class="two fields">
@@ -80,9 +82,9 @@
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
</div>
</details>
</div>
{{end}}
</details>
{{if .LicensePackages}}
<table class="ui compact table">
<thead>
@@ -149,8 +151,10 @@
</h4>
<div class="ui attached segment">
<form class="ui form tw-mb-4" method="get" action="{{$.Org.HomeLink}}/-/licenses">
<div class="ui action input tw-w-full">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
<div class="tw-flex tw-gap-2 tw-items-center tw-max-w-lg">
<div class="ui input tw-flex-1">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
</div>
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}}</button>
{{if .SearchQuery}}<a class="ui button" href="{{$.Org.HomeLink}}/-/licenses">{{ctx.Locale.Tr "repo.licenses.clear_search"}}</a>{{end}}
</div>
@@ -261,6 +265,7 @@
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_package_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.type_name_to_confirm"}}</label>
<input name="confirm_name" required>
@@ -277,6 +282,7 @@
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_key_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
</form>
</div>
+8 -8
View File
@@ -1,28 +1,28 @@
<div class="flex-container-nav">
<div class="ui fluid vertical menu">
<div class="header item">{{ctx.Locale.Tr "org.settings"}}</div>
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "org.settings"}}</div>
<a class="{{if .PageIsSettingsOptions}}active {{end}}item" href="{{.OrgLink}}/settings">
{{ctx.Locale.Tr "org.settings.options"}}
{{svg "octicon-gear"}} {{ctx.Locale.Tr "org.settings.options"}}
</a>
{{if not DisableWebhooks}}
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{.OrgLink}}/settings/hooks">
{{ctx.Locale.Tr "repo.settings.hooks"}}
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
</a>
{{end}}
<a class="{{if .PageIsOrgSettingsLabels}}active {{end}}item" href="{{.OrgLink}}/settings/labels">
{{ctx.Locale.Tr "repo.labels"}}
{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.labels"}}
</a>
{{if .EnableOAuth2}}
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{.OrgLink}}/settings/applications">
{{ctx.Locale.Tr "settings.applications"}}
{{svg "octicon-apps"}} {{ctx.Locale.Tr "settings.applications"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
{{ctx.Locale.Tr "user.block.list"}}
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block.list"}}
</a>
{{if .EnablePackages}}
<a class="{{if .PageIsSettingsPackages}}active {{end}}item" href="{{.OrgLink}}/settings/packages">
{{ctx.Locale.Tr "packages.title"}}
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsUpdateStreams}}active {{end}}item" href="{{.OrgLink}}/settings/update-streams">
@@ -30,7 +30,7 @@
</a>
{{if .EnableActions}}
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsOrgSettingsActionsGeneral}}active {{end}}item" href="{{.OrgLink}}/settings/actions">
{{ctx.Locale.Tr "settings.general"}}
+13
View File
@@ -28,6 +28,19 @@
<input id="location" name="location" value="{{.Org.Location}}" maxlength="50">
</div>
{{if .ParentOrgCandidates}}
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.parent_org"}}</label>
<select name="parent_org_id" class="ui dropdown">
<option value="0">{{ctx.Locale.Tr "org.settings.parent_org_none"}}</option>
{{range .ParentOrgCandidates}}
<option value="{{.ID}}" {{if eq $.ParentOrgID .ID}}selected{{end}}>{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}</option>
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.parent_org_help"}}</p>
</div>
{{end}}
<div class="field" id="permission_box">
<label>{{ctx.Locale.Tr "org.settings.permission"}}</label>
<div class="field">
@@ -37,6 +37,12 @@
<p class="help">{{ctx.Locale.Tr "org.settings.feed_visibility_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.key_prefix"}}</label>
<input name="key_prefix" value="{{.StreamConfig.KeyPrefix}}" placeholder="MOKO" maxlength="20">
<p class="help">{{ctx.Locale.Tr "org.settings.key_prefix_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.download_gating"}}</label>
<select name="download_gating" class="ui dropdown">
+2 -2
View File
@@ -113,7 +113,7 @@
</a>
{{end}}
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}}
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo) .IsSigned}}
<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
{{if .Repository.NumOpenActionRuns}}
@@ -128,7 +128,7 @@
</a>
{{end}}
{{if .LicensingEnabled}}
{{if and .LicensingEnabled .IsSigned}}
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumLicensePackages}}
+19 -11
View File
@@ -26,8 +26,12 @@
{{end}}
{{/* License Packages */}}
<h4 class="ui top attached header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}
<details id="new-package-details">
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
{{if .IsRepoAdmin}}
<summary class="ui primary small button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
{{end}}
</h4>
<div class="ui attached segment">
{{if .LicensePackages}}
@@ -90,12 +94,9 @@
{{end}}
</div>
{{/* Create New License Package */}}
{{/* Create New License Package (form panel, toggled by header button) */}}
{{if .IsRepoAdmin}}
<div class="tw-mt-4">
<details>
<summary class="ui primary button">{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.new_package"}}</summary>
<div class="ui segment tw-mt-2">
<div class="ui attached segment">
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
{{.CsrfTokenHtml}}
<div class="two fields">
@@ -143,9 +144,8 @@
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
</div>
</details>
</div>
</details>
{{end}}
{{/* Issued Keys */}}
@@ -155,8 +155,10 @@
</h4>
<div class="ui attached segment">
<form class="ui form tw-mb-4" method="get" action="{{.RepoLink}}/licenses">
<div class="ui action input tw-w-full">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
<div class="tw-flex tw-gap-2 tw-items-center tw-max-w-lg">
<div class="ui input tw-flex-1">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
</div>
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}}</button>
{{if .SearchQuery}}<a class="ui button" href="{{.RepoLink}}/licenses">{{ctx.Locale.Tr "repo.licenses.clear_search"}}</a>{{end}}
</div>
@@ -270,6 +272,7 @@
<div class="ui action input tw-w-full">
<input class="js-feed-url-joomla" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-joomla" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates.xml" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
</div>
</div>
{{end}}
@@ -279,6 +282,7 @@
<div class="ui action input tw-w-full">
<input class="js-feed-url-dolibarr" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-dolibarr" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
</div>
</div>
{{end}}
@@ -288,6 +292,7 @@
<div class="ui action input tw-w-full">
<input class="js-feed-url-wordpress" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/wordpress.json" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-wordpress" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/updates/wordpress.json" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
</div>
</div>
{{end}}
@@ -296,6 +301,7 @@
<div class="ui action input tw-w-full">
<input class="js-feed-url-changelog" type="text" readonly value="{{.Repository.HTMLURL ctx}}/changelog.xml" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-changelog" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
<a class="ui button" href="{{.Repository.HTMLURL ctx}}/changelog.xml" target="_blank" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.open_feed"}}">{{svg "octicon-link-external" 14}}</a>
</div>
</div>
</div>
@@ -311,6 +317,7 @@
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_package_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.type_name_to_confirm"}}</label>
<input name="confirm_name" required>
@@ -328,6 +335,7 @@
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_key_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
</form>
</div>
+13
View File
@@ -117,6 +117,19 @@
<div class="help tw-block tw-ml-[21px]">{{ctx.Locale.Tr "repo.release.prerelease_helper"}}</div>
</div>
{{if .LicensingEnabled}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.release.update_stream"}}</label>
<select name="update_stream" class="ui dropdown">
<option value="">{{ctx.Locale.Tr "repo.release.update_stream_auto"}}</option>
{{range .AvailableStreams}}
<option value="{{.Name}}" {{if eq $.ReleaseStream .Name}}selected{{end}}>{{.Name}}{{if .Description}}{{.Description}}{{end}}</option>
{{end}}
</select>
<div class="help">{{ctx.Locale.Tr "repo.release.update_stream_help"}}</div>
</div>
{{end}}
<div class="flex-text-block tw-justify-end">
{{if .PageIsEditRelease}}
<a class="ui small button" href="{{.RepoLink}}/releases">
+391
View File
@@ -0,0 +1,391 @@
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings advanced")}}
<div class="user-main-content twelve wide column">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.advanced_settings"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
<input type="hidden" name="action" value="advanced">
{{/* Code */}}
<div class="tw-mb-4">
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-code" 16}} {{ctx.Locale.Tr "repo.code"}}</h5>
{{$isCodeEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
{{$isCodeGlobalDisabled := ctx.Consts.RepoUnitTypeCode.UnitGlobalDisabled}}
<div class="inline field">
<div class="ui checkbox{{if $isCodeGlobalDisabled}} disabled{{end}}"{{if $isCodeGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_code" type="checkbox"{{if $isCodeEnabled}} checked{{end}}>
<label>{{ctx.Locale.Tr "repo.code.desc"}}</label>
</div>
</div>
{{/* Wiki */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-book" 16}} {{ctx.Locale.Tr "repo.wiki"}}</h5>
{{$isInternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeWiki}}
{{$isExternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalWiki}}
{{$isWikiEnabled := or $isInternalWikiEnabled $isExternalWikiEnabled}}
{{$isWikiGlobalDisabled := ctx.Consts.RepoUnitTypeWiki.UnitGlobalDisabled}}
{{$isExternalWikiGlobalDisabled := ctx.Consts.RepoUnitTypeExternalWiki.UnitGlobalDisabled}}
{{$isBothWikiGlobalDisabled := and $isWikiGlobalDisabled $isExternalWikiGlobalDisabled}}
<div class="inline field">
<div class="ui checkbox{{if $isBothWikiGlobalDisabled}} disabled{{end}}"{{if $isBothWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_wiki" type="checkbox" data-target="#wiki_box" {{if $isWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.wiki_desc"}}</label>
</div>
<div class="field{{if not $isWikiEnabled}} disabled{{end}}" id="wiki_box">
<div class="field">
<div class="ui radio checkbox{{if $isWikiGlobalDisabled}} disabled{{end}}"{{if $isWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="false" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isInternalWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_internal_wiki"}}</label>
</div>
</div>
<div id="internal_wiki_box" class="field tw-pl-4 {{if not $isInternalWikiEnabled}}disabled{{end}}">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="wiki_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="true" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isExternalWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_external_wiki"}}</label>
</div>
</div>
<div id="external_wiki_box" class="field tw-pl-4 {{if not $isExternalWikiEnabled}}disabled{{end}}">
<label for="external_wiki_url">{{ctx.Locale.Tr "repo.settings.external_wiki_url"}}</label>
<input id="external_wiki_url" name="external_wiki_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.external_wiki_url_desc"}}</p>
</div>
</div>
{{/* Issues */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-issue-opened" 16}} {{ctx.Locale.Tr "repo.issues"}}</h5>
{{$isIssuesEnabled := or (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeIssues) (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
{{$isIssuesGlobalDisabled := ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
{{$isExternalTrackerGlobalDisabled := ctx.Consts.RepoUnitTypeExternalTracker.UnitGlobalDisabled}}
{{$isIssuesAndExternalGlobalDisabled := and $isIssuesGlobalDisabled $isExternalTrackerGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.issues"}}</label>
<div class="ui checkbox{{if $isIssuesAndExternalGlobalDisabled}} disabled{{end}}"{{if $isIssuesAndExternalGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_issues" type="checkbox" data-target="#issue_box" {{if $isIssuesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.issues_desc"}}</label>
</div>
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
<div class="field">
<div class="ui radio checkbox{{if $isIssuesGlobalDisabled}} disabled{{end}}"{{if $isIssuesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_internal_issue_tracker"}}</label>
</div>
</div>
<div class="field tw-pl-4 {{if (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
{{if .Repository.CanEnableTimetracker}}
<div class="field">
<div class="ui checkbox">
<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled ctx}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.enable_timetracker"}}</label>
</div>
</div>
<div class="field {{if not (.Repository.IsTimetrackerEnabled ctx)}}disabled{{end}}" id="only_contributors">
<div class="ui checkbox">
<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime ctx}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
</div>
</div>
{{end}}
<div class="field">
<div class="ui checkbox">
<input name="enable_issue_dependencies" type="checkbox" {{if (.Repository.IsDependenciesEnabled ctx)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.issues.dependency.setting"}}</label>
</div>
</div>
<div class="ui checkbox">
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
</div>
<div class="inline field tw-mt-2">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="issues_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_external_issue_tracker"}}</label>
</div>
</div>
<div class="field tw-pl-4 {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="external_issue_box">
<div class="field">
<label for="external_tracker_url">{{ctx.Locale.Tr "repo.settings.external_tracker_url"}}</label>
<input id="external_tracker_url" name="external_tracker_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerURL}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.external_tracker_url_desc"}}</p>
</div>
<div class="field">
<label for="tracker_url_format">{{ctx.Locale.Tr "repo.settings.tracker_url_format"}}</label>
<input id="tracker_url_format" name="tracker_url_format" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerFormat}}" placeholder="https://github.com/{user}/{repo}/issues/{index}">
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_url_format_desc"}}</p>
</div>
<div class="inline fields">
<label for="issue_style">{{ctx.Locale.Tr "repo.settings.tracker_issue_style"}}</label>
<div class="field">
<div class="ui radio checkbox">
{{$externalTracker := (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
{{$externalTrackerStyle := $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle}}
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="numeric" {{if eq $externalTrackerStyle "numeric"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">#1234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="alphanumeric" {{if eq $externalTrackerStyle "alphanumeric"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">ABC-123 , DEFG-234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="regexp" {{if eq $externalTrackerStyle "regexp"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp"}} <span class="ui light grey text">(ISSUE-\d+) , ISSUE-(\d+)</span></label>
</div>
</div>
</div>
<div class="field {{if ne $externalTrackerStyle "regexp"}}disabled{{end}}" id="tracker-issue-style-regex-box">
<label for="external_tracker_regexp_pattern">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern"}}</label>
<input id="external_tracker_regexp_pattern" name="external_tracker_regexp_pattern" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerRegexpPattern}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern_desc"}}</p>
</div>
</div>
</div>
{{/* Projects */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-project" 16}} {{ctx.Locale.Tr "repo.projects"}}</h5>
{{$isProjectsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeProjects}}
{{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}}
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.projects"}}</label>
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>
</div>
<div class="field {{if not $isProjectsEnabled}} disabled{{end}} tw-pl-4" id="projects_box">
<p>
{{ctx.Locale.Tr "repo.settings.projects_mode_desc"}}
</p>
<div class="ui dropdown selection">
<select name="projects_mode">
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
{{end}}
</div>
<div class="menu">
<div class="item" data-value="repo">{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</div>
<div class="item" data-value="owner">{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</div>
<div class="item" data-value="all">{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</div>
</div>
</div>
</div>
{{/* Releases */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-tag" 16}} {{ctx.Locale.Tr "repo.releases"}}</h5>
{{$isReleasesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeReleases}}
{{$isReleasesGlobalDisabled := ctx.Consts.RepoUnitTypeReleases.UnitGlobalDisabled}}
<div class="inline field">
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
</div>
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="releases_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
</div>
</div>
{{/* Packages */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-package" 16}} {{ctx.Locale.Tr "repo.packages"}}</h5>
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
<div class="inline field">
<div class="ui checkbox{{if $isPackagesGlobalDisabled}} disabled{{end}}"{{if $isPackagesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_packages" type="checkbox" {{if $isPackagesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.packages_desc"}}</label>
</div>
</div>
{{if not .IsMirror}}
{{/* Pull Requests */}}
<div class="tw-mb-4">
<div class="ui divider"></div>
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2">{{svg "octicon-git-pull-request" 16}} {{ctx.Locale.Tr "repo.pulls"}}</h5>
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}}
{{$pullRequestGlobalDisabled := ctx.Consts.RepoUnitTypePullRequests.UnitGlobalDisabled}}
{{$prUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypePullRequests}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.pulls"}}</label>
<div class="ui checkbox{{if $pullRequestGlobalDisabled}} disabled{{end}}"{{if $pullRequestGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_pulls" type="checkbox" data-target="#pull_box" {{if $pullRequestEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls_desc"}}</label>
</div>
</div>
<div class="field{{if not $pullRequestEnabled}} disabled{{end}}" id="pull_box">
<div class="field">
<p>
{{ctx.Locale.Tr "repo.settings.merge_style_desc"}}
</p>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebase)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_squash" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowSquash)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_fast_forward_only" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowFastForwardOnly)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.merge_manually"}}</label>
</div>
</div>
<div class="field">
<p>
{{ctx.Locale.Tr "repo.settings.default_merge_style_desc"}}
</p>
<div class="ui dropdown selection">
<select name="pulls_default_merge_style">
<option value="merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</option>
<option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option>
<option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option>
<option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option>
<option value="fast-forward-only" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</option>
</select>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}
{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}
{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
{{end}}
</div>
<div class="menu">
<div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div>
<div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div>
<div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div>
<div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div>
<div class="item" data-value="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
</div>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
<div class="ui search selection dropdown">
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
<div class="default text"></div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
{{range $branchName := $.Branches}}
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_allow_edits_from_maintainers"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase_update" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseUpdate)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.allow_rebase_update"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="default_delete_branch_after_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultDeleteBranchAfterMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_delete_branch_after_merge"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="enable_autodetect_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AutodetectManualMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.enable_autodetect_manual_merge"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_ignore_whitespace" type="checkbox" {{if and $pullRequestEnabled ($prUnit.PullRequestsConfig.IgnoreWhitespaceConflicts)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.ignore_whitespace"}}</label>
</div>
</div>
</div>
{{end}}
</div>
<div class="field tw-mt-4">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
</div>
</form>
</div>
</div>
{{template "repo/settings/layout_footer" .}}
+110
View File
@@ -0,0 +1,110 @@
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings licensing")}}
<div class="user-main-content twelve wide column">
<h4 class="ui top attached header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{.Link}}">
<input type="hidden" name="action" value="licensing">
<div class="inline field">
<div class="ui checkbox">
<input name="enable_licensing" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</strong></label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
</div>
<div class="ui divider"></div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
<select name="update_platform" class="ui dropdown">
<option value="joomla" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.Platform "joomla") (eq .RepoUpdateConfig.Platform "")}}selected{{end}}>Joomla (updates.xml)</option>
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
<option value="wordpress" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "wordpress")}}selected{{end}}>WordPress (JSON)</option>
<option value="composer" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "composer")}}selected{{end}}>Composer (packages.json)</option>
<option value="prestashop" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "prestashop")}}selected{{end}}>PrestaShop</option>
<option value="drupal" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "drupal")}}selected{{end}}>Drupal</option>
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
<select name="download_gating" class="ui dropdown">
<option value="none" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.DownloadGating "") (eq .RepoUpdateConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
<option value="prerelease" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "prerelease")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
<option value="all" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "all")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.support_url"}}</label>
<input name="support_url" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.SupportURL}}{{end}}" placeholder="https://mokoconsulting.tech/support">
<p class="help">{{ctx.Locale.Tr "repo.settings.support_url_help"}}</p>
</div>
<div class="ui divider"></div>
<h5>{{ctx.Locale.Tr "repo.settings.extension_metadata"}}</h5>
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}</p>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
<input name="extension_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.ExtensionName}}{{end}}" placeholder="pkg_myextension">
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
<input name="display_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.DisplayName}}{{end}}" placeholder="Package - My Extension">
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
<select name="extension_type" class="ui dropdown">
<option value="">{{ctx.Locale.Tr "repo.settings.inherit_org"}}</option>
<option value="package" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "package")}}selected{{end}}>Package</option>
<option value="component" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "component")}}selected{{end}}>Component</option>
<option value="module" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "module")}}selected{{end}}>Module</option>
<option value="plugin" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "plugin")}}selected{{end}}>Plugin</option>
<option value="template" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "template")}}selected{{end}}>Template</option>
<option value="library" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "library")}}selected{{end}}>Library</option>
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
<input name="target_version" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.TargetVersion}}{{end}}" placeholder="(5|6)\..*">
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
<input name="maintainer" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.Maintainer}}{{end}}" placeholder="Moko Consulting">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
<input name="php_minimum" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.PHPMinimum}}{{end}}" placeholder="8.1">
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
</div>
{{template "repo/settings/layout_footer" .}}
+19 -11
View File
@@ -1,45 +1,53 @@
<div class="flex-container-nav">
<div class="ui fluid vertical menu">
<div class="header item">{{ctx.Locale.Tr "repo.settings"}}</div>
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "repo.settings"}}</div>
<a class="{{if .PageIsSettingsOptions}}active {{end}}item" href="{{.RepoLink}}/settings">
{{ctx.Locale.Tr "repo.settings.options"}}
{{svg "octicon-gear"}} {{ctx.Locale.Tr "repo.settings.options"}}
</a>
<a class="{{if .PageIsSettingsAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/advanced">
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings.advanced_settings"}}
</a>
{{if .LicensingEnabled}}
<a class="{{if .PageIsSettingsLicensing}}active {{end}}item" href="{{.RepoLink}}/settings/licensing">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
</a>
{{end}}
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
{{ctx.Locale.Tr "repo.settings.public_access"}}
{{svg "octicon-eye"}} {{ctx.Locale.Tr "repo.settings.public_access"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsCollaboration}}active {{end}}item" href="{{.RepoLink}}/settings/collaboration">
{{ctx.Locale.Tr "repo.settings.collaboration"}}
{{svg "octicon-people"}} {{ctx.Locale.Tr "repo.settings.collaboration"}}
</a>
{{if not DisableWebhooks}}
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{.RepoLink}}/settings/hooks">
{{ctx.Locale.Tr "repo.settings.hooks"}}
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
</a>
{{end}}
{{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
<a class="{{if .PageIsSettingsBranches}}active {{end}}item" href="{{.RepoLink}}/settings/branches">
{{ctx.Locale.Tr "repo.settings.branches"}}
{{svg "octicon-git-branch"}} {{ctx.Locale.Tr "repo.settings.branches"}}
</a>
<a class="{{if .PageIsSettingsTags}}active {{end}}item" href="{{.RepoLink}}/settings/tags">
{{ctx.Locale.Tr "repo.settings.tags"}}
{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.settings.tags"}}
</a>
{{if .SignedUser.CanEditGitHook}}
<a class="{{if .PageIsSettingsGitHooks}}active {{end}}item" href="{{.RepoLink}}/settings/hooks/git">
{{ctx.Locale.Tr "repo.settings.githooks"}}
{{svg "octicon-terminal"}} {{ctx.Locale.Tr "repo.settings.githooks"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{.RepoLink}}/settings/keys">
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
{{svg "octicon-key-asterisk"}} {{ctx.Locale.Tr "repo.settings.deploy_keys"}}
</a>
{{if .LFSStartServer}}
<a class="{{if .PageIsSettingsLFS}}active {{end}}item" href="{{.RepoLink}}/settings/lfs">
{{ctx.Locale.Tr "repo.settings.lfs"}}
{{svg "octicon-file-binary"}} {{ctx.Locale.Tr "repo.settings.lfs"}}
</a>
{{end}}
{{end}}
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsActionsSettingsGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/actions/general">
{{ctx.Locale.Tr "actions.general"}}
+46 -521
View File
@@ -287,481 +287,6 @@
</div>
{{end}}
{{/* FIXME: need to split the "Advance Settings" by units, there are too many options here */}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.advanced_settings"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
<input type="hidden" name="action" value="advanced">
{{$isCodeEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeCode}}
{{$isCodeGlobalDisabled := ctx.Consts.RepoUnitTypeCode.UnitGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.code"}}</label>
<div class="ui checkbox{{if $isCodeGlobalDisabled}} disabled{{end}}"{{if $isCodeGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_code" type="checkbox"{{if $isCodeEnabled}} checked{{end}}>
<label>{{ctx.Locale.Tr "repo.code.desc"}}</label>
</div>
</div>
{{$isInternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeWiki}}
{{$isExternalWikiEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalWiki}}
{{$isWikiEnabled := or $isInternalWikiEnabled $isExternalWikiEnabled}}
{{$isWikiGlobalDisabled := ctx.Consts.RepoUnitTypeWiki.UnitGlobalDisabled}}
{{$isExternalWikiGlobalDisabled := ctx.Consts.RepoUnitTypeExternalWiki.UnitGlobalDisabled}}
{{$isBothWikiGlobalDisabled := and $isWikiGlobalDisabled $isExternalWikiGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.wiki"}}</label>
<div class="ui checkbox{{if $isBothWikiGlobalDisabled}} disabled{{end}}"{{if $isBothWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_wiki" type="checkbox" data-target="#wiki_box" {{if $isWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.wiki_desc"}}</label>
</div>
</div>
<div class="field{{if not $isWikiEnabled}} disabled{{end}}" id="wiki_box">
<div class="field">
<div class="ui radio checkbox{{if $isWikiGlobalDisabled}} disabled{{end}}"{{if $isWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="false" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isInternalWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_internal_wiki"}}</label>
</div>
</div>
<div id="internal_wiki_box" class="field tw-pl-4 {{if not $isInternalWikiEnabled}}disabled{{end}}">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="wiki_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_wiki" type="radio" value="true" data-context="#internal_wiki_box" data-target="#external_wiki_box" {{if $isExternalWikiEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_external_wiki"}}</label>
</div>
</div>
<div id="external_wiki_box" class="field tw-pl-4 {{if not $isExternalWikiEnabled}}disabled{{end}}">
<label for="external_wiki_url">{{ctx.Locale.Tr "repo.settings.external_wiki_url"}}</label>
<input id="external_wiki_url" name="external_wiki_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.external_wiki_url_desc"}}</p>
</div>
</div>
<div class="divider"></div>
{{$isIssuesEnabled := or (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeIssues) (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
{{$isIssuesGlobalDisabled := ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
{{$isExternalTrackerGlobalDisabled := ctx.Consts.RepoUnitTypeExternalTracker.UnitGlobalDisabled}}
{{$isIssuesAndExternalGlobalDisabled := and $isIssuesGlobalDisabled $isExternalTrackerGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.issues"}}</label>
<div class="ui checkbox{{if $isIssuesAndExternalGlobalDisabled}} disabled{{end}}"{{if $isIssuesAndExternalGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_issues" type="checkbox" data-target="#issue_box" {{if $isIssuesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.issues_desc"}}</label>
</div>
</div>
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
<div class="field">
<div class="ui radio checkbox{{if $isIssuesGlobalDisabled}} disabled{{end}}"{{if $isIssuesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_internal_issue_tracker"}}</label>
</div>
</div>
<div class="field tw-pl-4 {{if (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
{{if .Repository.CanEnableTimetracker}}
<div class="field">
<div class="ui checkbox">
<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled ctx}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.enable_timetracker"}}</label>
</div>
</div>
<div class="field {{if not (.Repository.IsTimetrackerEnabled ctx)}}disabled{{end}}" id="only_contributors">
<div class="ui checkbox">
<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime ctx}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
</div>
</div>
{{end}}
<div class="field">
<div class="ui checkbox">
<input name="enable_issue_dependencies" type="checkbox" {{if (.Repository.IsDependenciesEnabled ctx)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.issues.dependency.setting"}}</label>
</div>
</div>
<div class="ui checkbox">
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
</div>
<div class="inline field tw-mt-2">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="issues_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system-radio" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.use_external_issue_tracker"}}</label>
</div>
</div>
<div class="field tw-pl-4 {{if not (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}disabled{{end}}" id="external_issue_box">
<div class="field">
<label for="external_tracker_url">{{ctx.Locale.Tr "repo.settings.external_tracker_url"}}</label>
<input id="external_tracker_url" name="external_tracker_url" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerURL}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.external_tracker_url_desc"}}</p>
</div>
<div class="field">
<label for="tracker_url_format">{{ctx.Locale.Tr "repo.settings.tracker_url_format"}}</label>
<input id="tracker_url_format" name="tracker_url_format" type="url" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerFormat}}" placeholder="https://github.com/{user}/{repo}/issues/{index}">
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_url_format_desc"}}</p>
</div>
<div class="inline fields">
<label for="issue_style">{{ctx.Locale.Tr "repo.settings.tracker_issue_style"}}</label>
<div class="field">
<div class="ui radio checkbox">
{{$externalTracker := (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker)}}
{{$externalTrackerStyle := $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle}}
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="numeric" {{if eq $externalTrackerStyle "numeric"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">#1234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="alphanumeric" {{if eq $externalTrackerStyle "alphanumeric"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">ABC-123 , DEFG-234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="regexp" {{if eq $externalTrackerStyle "regexp"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp"}} <span class="ui light grey text">(ISSUE-\d+) , ISSUE-(\d+)</span></label>
</div>
</div>
</div>
<div class="field {{if ne $externalTrackerStyle "regexp"}}disabled{{end}}" id="tracker-issue-style-regex-box">
<label for="external_tracker_regexp_pattern">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern"}}</label>
<input id="external_tracker_regexp_pattern" name="external_tracker_regexp_pattern" value="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerRegexpPattern}}">
<p class="help">{{ctx.Locale.Tr "repo.settings.tracker_issue_style.regexp_pattern_desc"}}</p>
</div>
</div>
</div>
<div class="divider"></div>
{{$isProjectsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeProjects}}
{{$isProjectsGlobalDisabled := ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled}}
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.projects"}}</label>
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>
</div>
</div>
<div class="field {{if not $isProjectsEnabled}} disabled{{end}} tw-pl-4" id="projects_box">
<p>
{{ctx.Locale.Tr "repo.settings.projects_mode_desc"}}
</p>
<div class="ui dropdown selection">
<select name="projects_mode">
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
{{end}}
</div>
<div class="menu">
<div class="item" data-value="repo">{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</div>
<div class="item" data-value="owner">{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</div>
<div class="item" data-value="all">{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</div>
</div>
</div>
</div>
<div class="divider"></div>
{{$isReleasesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeReleases}}
{{$isReleasesGlobalDisabled := ctx.Consts.RepoUnitTypeReleases.UnitGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.releases"}}</label>
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
</div>
</div>
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="releases_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
</div>
</div>
<div class="divider"></div>
{{/* Licensing & Update Feeds */}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.licensing_section"}}</label>
<div class="ui checkbox">
<input class="enable-system" name="enable_licensing" type="checkbox" data-target="#licensing_options_box" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</label>
</div>
</div>
<div class="field tw-pl-4{{if not (and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled)}} disabled{{end}}" id="licensing_options_box">
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
<select name="update_platform" class="ui dropdown">
<option value="joomla" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.Platform "joomla") (eq .RepoUpdateConfig.Platform "")}}selected{{end}}>Joomla (updates.xml)</option>
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
<select name="download_gating" class="ui dropdown">
<option value="none" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.DownloadGating "") (eq .RepoUpdateConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
<option value="prerelease" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "prerelease")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
<option value="all" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "all")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.support_url"}}</label>
<input name="support_url" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.SupportURL}}{{end}}" placeholder="https://mokoconsulting.tech/support">
<p class="help">{{ctx.Locale.Tr "repo.settings.support_url_help"}}</p>
</div>
<div class="ui divider"></div>
<h6>{{ctx.Locale.Tr "repo.settings.extension_metadata"}}</h6>
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}</p>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
<input name="extension_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.ExtensionName}}{{end}}" placeholder="pkg_myextension">
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
<input name="display_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.DisplayName}}{{end}}" placeholder="Package - My Extension">
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
<select name="extension_type" class="ui dropdown">
<option value="">{{ctx.Locale.Tr "repo.settings.inherit_org"}}</option>
<option value="package" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "package")}}selected{{end}}>Package</option>
<option value="component" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "component")}}selected{{end}}>Component</option>
<option value="module" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "module")}}selected{{end}}>Module</option>
<option value="plugin" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "plugin")}}selected{{end}}>Plugin</option>
<option value="template" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "template")}}selected{{end}}>Template</option>
<option value="library" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "library")}}selected{{end}}>Library</option>
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
<input name="target_version" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.TargetVersion}}{{end}}" placeholder="(5|6)\..*">
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
<input name="maintainer" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.Maintainer}}{{end}}" placeholder="Moko Consulting">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
<input name="php_minimum" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.PHPMinimum}}{{end}}" placeholder="8.1">
</div>
</div>
</div>
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.packages"}}</label>
<div class="ui checkbox{{if $isPackagesGlobalDisabled}} disabled{{end}}"{{if $isPackagesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_packages" type="checkbox" {{if $isPackagesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.packages_desc"}}</label>
</div>
</div>
{{if not .IsMirror}}
<div class="divider"></div>
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}}
{{$pullRequestGlobalDisabled := ctx.Consts.RepoUnitTypePullRequests.UnitGlobalDisabled}}
{{$prUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypePullRequests}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.pulls"}}</label>
<div class="ui checkbox{{if $pullRequestGlobalDisabled}} disabled{{end}}"{{if $pullRequestGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_pulls" type="checkbox" data-target="#pull_box" {{if $pullRequestEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls_desc"}}</label>
</div>
</div>
<div class="field{{if not $pullRequestEnabled}} disabled{{end}}" id="pull_box">
<div class="field">
<p>
{{ctx.Locale.Tr "repo.settings.merge_style_desc"}}
</p>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebase)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_squash" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowSquash)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_fast_forward_only" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowFastForwardOnly)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.merge_manually"}}</label>
</div>
</div>
<div class="field">
<p>
{{ctx.Locale.Tr "repo.settings.default_merge_style_desc"}}
</p>
<div class="ui dropdown selection">
<select name="pulls_default_merge_style">
<option value="merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</option>
<option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option>
<option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option>
<option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option>
<option value="fast-forward-only" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</option>
</select>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}
{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}
{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
{{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
{{end}}
</div>
<div class="menu">
<div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div>
<div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div>
<div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div>
<div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div>
<div class="item" data-value="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
</div>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
<div class="ui search selection dropdown">
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
<div class="default text"></div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
{{range $branchName := $.Branches}}
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_allow_edits_from_maintainers"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_rebase_update" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowRebaseUpdate)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.allow_rebase_update"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="default_delete_branch_after_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultDeleteBranchAfterMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_delete_branch_after_merge"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="enable_autodetect_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AutodetectManualMerge)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.enable_autodetect_manual_merge"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_ignore_whitespace" type="checkbox" {{if and $pullRequestEnabled ($prUnit.PullRequestsConfig.IgnoreWhitespaceConflicts)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pulls.ignore_whitespace"}}</label>
</div>
</div>
</div>
{{end}}
<div class="divider"></div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
</div>
</form>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.signing_settings"}}
@@ -879,23 +404,34 @@
{{if not .Repository.IsFork}}
<div class="item tw-items-center">
<div class="item-main">
<div class="item-title">{{ctx.Locale.Tr "repo.visibility"}}</div>
{{if .IsSystemRepo}}
<div class="item-body">This is a system repository (dot-prefixed name). System repositories are always private and cannot be made public.</div>
{{else if .Repository.IsPrivate}}
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.public.text"}}</div>
{{else}}
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.private.text"}}</div>
{{end}}
<div class="item-title tw-flex tw-items-center tw-justify-between">
<span>{{ctx.Locale.Tr "repo.visibility"}}</span>
{{if .IsSystemRepo}}
<span class="ui grey label">System</span>
{{else if .Repository.IsHidden}}
<span class="ui red label">{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</span>
{{else if .Repository.IsPrivate}}
<span class="ui orange label">{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</span>
{{else}}
<span class="ui green label">{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</span>
{{end}}
</div>
<div class="item-body">
{{if .IsSystemRepo}}
This is a system repository. System repositories are always private.
{{else if .Repository.IsHidden}}
{{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}
{{else if .Repository.IsPrivate}}
{{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}
{{else}}
{{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}
{{end}}
</div>
</div>
{{if not .IsSystemRepo}}
<div class="item-trailing">
<button class="ui basic red show-modal button" data-modal="#visibility-repo-modal">
{{if .Repository.IsPrivate}}
{{ctx.Locale.Tr "repo.settings.visibility.public.button"}}
{{else}}
{{ctx.Locale.Tr "repo.settings.visibility.private.button"}}
{{end}}
{{ctx.Locale.Tr "repo.settings.change_visibility"}}
</button>
</div>
{{end}}
@@ -1078,43 +614,32 @@
{{ctx.Locale.Tr "repo.visibility"}}
</div>
<div class="content">
{{if .Repository.IsPrivate}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_one"}}</li>
</ul>
{{else}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_one"}}</li>
<li>
{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_two"}}
</li>
{{if or .Repository.NumStars .Repository.NumWatches .Repository.NumForks}}
<ul class="tw-my-0 tw-pl-4">
{{if .Repository.NumStars}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_stars" .Repository.NumStars}}</li>{{end}}
{{if .Repository.NumWatches}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_watchers" .Repository.NumWatches}}</li>{{end}}
{{if .Repository.NumForks}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_forks" .Repository.NumForks}}</li>{{end}}
</ul>
{{end}}
</ul>
{{end}}
<form class="ui form tw-mt-5 form-fetch-action" action="{{.Link}}" method="post">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.settings.visibility.warning"}}</p>
</div>
<form class="ui form tw-mt-4 form-fetch-action" action="{{.Link}}" method="post">
<input type="hidden" name="action" value="visibility">
<input type="hidden" name="private" value="{{not .Repository.IsPrivate}}">
{{if not .Repository.IsPrivate}}
<div class="grouped fields">
<div class="field">
<label>
{{ctx.Locale.Tr "repo.settings.enter_repo_full_name_to_confirm"}}
<span class="tw-text-red">{{.Repository.FullName}}</span>
</label>
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="public" {{if and (not .Repository.IsPrivate) (not .Repository.IsHidden)}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}</label>
</div>
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "repo.repo_name"}}</label>
<input name="confirm_repo_name" required maxlength="200">
<div class="field">
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="private" {{if and .Repository.IsPrivate (not .Repository.IsHidden)}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}</label>
</div>
</div>
{{end}}
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (Iif .Repository.IsPrivate (ctx.Locale.Tr "repo.settings.visibility.public.button") (ctx.Locale.Tr "repo.settings.visibility.private.button")))}}
<div class="field">
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="hidden" {{if .Repository.IsHidden}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}</label>
</div>
</div>
</div>
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.change_visibility"))}}
</form>
</div>
</div>
+31
View File
@@ -0,0 +1,31 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content {{if .IsRepo}}repository{{end}}">
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
<div class="ui container">
{{template "base/alert" .}}
<div class="status-page-error">
<div class="status-page-error-title">403 Access Denied</div>
<div class="tw-text-center">
<div class="tw-my-4">{{if .AccessDeniedPrompt}}{{.AccessDeniedPrompt}}{{else}}{{ctx.Locale.Tr "error403"}}{{end}}</div>
</div>
{{if not .IsSigned}}
<div class="tw-max-w-sm tw-mx-auto tw-mt-4">
<form class="ui form" action="{{AppSubUrl}}/user/login" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect_to" value="{{.CurrentURL}}">
<div class="required field">
<label>{{ctx.Locale.Tr "home.uname_holder"}}</label>
<input type="text" name="user_name" required autofocus>
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "password"}}</label>
<input type="password" name="password" required>
</div>
<button class="ui primary fluid button tw-mt-2" type="submit">{{ctx.Locale.Tr "sign_in"}}</button>
</form>
</div>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}
+14 -14
View File
@@ -1,41 +1,41 @@
<div class="flex-container-nav">
<div class="ui fluid vertical menu">
<div class="header item">{{ctx.Locale.Tr "user.settings"}}</div>
<div class="header item">{{svg "octicon-gear"}} {{ctx.Locale.Tr "user.settings"}}</div>
<a class="{{if .PageIsSettingsProfile}}active {{end}}item" href="{{AppSubUrl}}/user/settings">
{{ctx.Locale.Tr "settings.profile"}}
{{svg "octicon-person"}} {{ctx.Locale.Tr "settings.profile"}}
</a>
{{if not ($.UserDisabledFeatures.Contains "manage_credentials" "deletion")}}
<a class="{{if .PageIsSettingsAccount}}active {{end}}item" href="{{AppSubUrl}}/user/settings/account">
{{ctx.Locale.Tr "settings.account"}}
{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "settings.account"}}
</a>
{{end}}
{{if $.EnableNotifyMail}}
<a class="{{if .PageIsSettingsNotifications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/notifications">
{{ctx.Locale.Tr "notifications"}}
{{svg "octicon-bell"}} {{ctx.Locale.Tr "notifications"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsAppearance}}active {{end}}item" href="{{AppSubUrl}}/user/settings/appearance">
{{ctx.Locale.Tr "settings.appearance"}}
{{svg "octicon-paintbrush"}} {{ctx.Locale.Tr "settings.appearance"}}
</a>
{{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
<a class="{{if .PageIsSettingsSecurity}}active {{end}}item" href="{{AppSubUrl}}/user/settings/security">
{{ctx.Locale.Tr "settings.security"}}
{{svg "octicon-lock"}} {{ctx.Locale.Tr "settings.security"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
{{ctx.Locale.Tr "user.block.list"}}
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block.list"}}
</a>
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/applications">
{{ctx.Locale.Tr "settings.applications"}}
{{svg "octicon-apps"}} {{ctx.Locale.Tr "settings.applications"}}
</a>
{{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}}
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{AppSubUrl}}/user/settings/keys">
{{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
{{svg "octicon-key"}} {{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
</a>
{{end}}
{{if .EnableActions}}
<details class="item toggleable-item" {{if or .PageIsUserSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsUserSettingsActionsGeneral}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/general">
{{ctx.Locale.Tr "actions.general"}}
@@ -54,19 +54,19 @@
{{end}}
{{if .EnablePackages}}
<a class="{{if .PageIsSettingsPackages}}active {{end}}item" href="{{AppSubUrl}}/user/settings/packages">
{{ctx.Locale.Tr "packages.title"}}
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
{{if not DisableWebhooks}}
<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{AppSubUrl}}/user/settings/hooks">
{{ctx.Locale.Tr "repo.settings.hooks"}}
{{svg "octicon-webhook"}} {{ctx.Locale.Tr "repo.settings.hooks"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsOrganization}}active {{end}}item" href="{{AppSubUrl}}/user/settings/organization">
{{ctx.Locale.Tr "settings.organization"}}
{{svg "octicon-organization"}} {{ctx.Locale.Tr "settings.organization"}}
</a>
<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
{{ctx.Locale.Tr "settings.repos"}}
{{svg "octicon-repo"}} {{ctx.Locale.Tr "settings.repos"}}
</a>
</div>
</div>
+7 -7
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 05.14.00
VERSION: 05.17.00
-->
<updates>
@@ -87,15 +87,15 @@
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.14.00</version>
<creationDate>2026-05-31</creationDate>
<infourl title='MokoGitea'>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<version>05.17.00</version>
<creationDate>2026-06-03</creationDate>
<infourl title='MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.14.00.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.17.00.zip</downloadurl>
</downloads>
<sha256>bec4bf5a1a841f8e72d9826451004db5d8afc70144231dfedc7fb01a6695955c</sha256>
<sha256>7f50295f58e207f1c2d2be92a172f4d077a4115ad1337c663e6f33e065e0cff9</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name="go" version=".*" />
+6 -4
View File
@@ -426,10 +426,11 @@ The update feed system currently supports:
| **Joomla** | `/{repo}/updates.xml` | XML with `<downloadkey>` | Production |
| **Dolibarr** | `/{repo}/updates/dolibarr.json` | JSON | Production |
| **WordPress** | `/{repo}/updates/wordpress.json` | PUC-compatible JSON | Production |
| **Drupal** | Planned | XML/JSON | Planned (#353) |
| **PrestaShop** | Planned | XML | Planned (#352) |
| **Composer** | Planned | packages.json | Planned (#354) |
| **WHMCS** | Planned | Custom | Planned (#355) |
| **Composer** | `/{repo}/updates/packages.json` | packages.json | Production |
| **PrestaShop** | `/{repo}/updates/prestashop.xml` | Module update XML | Production |
| **Drupal** | `/{repo}/updates/drupal.xml` | Update status XML | Production |
| **WHMCS** | `/{repo}/updates/whmcs.json` | Module update JSON | Production |
| **Changelog** | `/{repo}/changelog.xml` | Joomla changelog XML | Production |
All platforms share the same licensing backend — the same keys, packages, and validation work across all feed formats.
@@ -444,3 +445,4 @@ All platforms share the same licensing backend — the same keys, packages, and
| 1.2 | 2026-05-31 | Jonathan Miller (@jmiller) | Add permissions (TypeLicenses unit), renewal, auto-domain, custom keys, UI/UX cleanup |
| 1.3 | 2026-06-01 | Jonathan Miller (@jmiller) | Add package archiving, expanded delete permissions, migration v340, API renew, step-by-step guides |
| 1.4 | 2026-06-02 | Jonathan Miller (@jmiller) | WordPress feed, feed visibility modes, download gating, RepoScope enforcement, API package CRUD, settings API, combolist channel picker, double confirmation modals, extension metadata in repo settings, domain lock timer, Joomla-standard tags, SHA256 in XML, changelog XML, no-download release page mode |
| 1.5 | 2026-06-02 | Jonathan Miller (@jmiller) | All 7 platform feeds (Composer, PrestaShop, Drupal, WHMCS), enterprise sub-org hierarchy, three-level repo visibility (Public/Private/Hidden), styled 403 page with login form, separate licensing/advanced settings pages, icons on all navbars, manual stream mapping, configurable key prefix, feed always public, xorm column name fixes, security hardening |