feat(updates): manual stream mapping, version extraction fixes, feed visibility #403

Merged
jmiller merged 6 commits from dev into main 2026-06-02 12:43:34 +00:00
14 changed files with 228 additions and 20 deletions
+1 -1
View File
@@ -33,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
+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
}
+11 -1
View File
@@ -171,10 +171,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 {
+16 -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"`
}
@@ -55,12 +55,25 @@ 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),
)
}
+3
View File
@@ -2714,6 +2714,9 @@
"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.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.",
+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")
}
+3 -2
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
+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)
+41 -6
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 {
@@ -215,7 +227,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 +285,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
@@ -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.
+8 -2
View File
@@ -96,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
@@ -139,6 +139,12 @@ 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.
@@ -178,7 +184,7 @@ func buildWordPressChangelog(releases []*repo_model.Release, streams []licenses.
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
}
+4 -2
View File
@@ -149,8 +149,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>
+4 -2
View File
@@ -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>
+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">