feat(updates): manual stream mapping, version extraction fixes, feed visibility #403
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user