feat(updates): org-level default streams with per-repo override #266
@@ -0,0 +1,180 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(UpdateStreamConfig))
|
||||
}
|
||||
|
||||
// UpdateStreamConfig stores update stream settings at org or repo level.
|
||||
// When OwnerID is set and RepoID is 0, it's an org-level default.
|
||||
// When RepoID is set, it's a per-repo override.
|
||||
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
|
||||
// CustomStreams is a JSON array of stream definitions.
|
||||
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
|
||||
CustomStreams string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||
}
|
||||
|
||||
func (UpdateStreamConfig) TableName() string {
|
||||
return "update_stream_config"
|
||||
}
|
||||
|
||||
// StreamDef defines a single update stream/channel.
|
||||
type StreamDef struct {
|
||||
Name string `json:"name"` // e.g. "stable", "lts", "nightly"
|
||||
Suffix string `json:"suffix"` // tag suffix to match, e.g. "-lts", "-rc"
|
||||
Description string `json:"description"` // human-readable label
|
||||
}
|
||||
|
||||
// DefaultJoomlaStreams returns the standard Joomla update streams.
|
||||
func DefaultJoomlaStreams() []StreamDef {
|
||||
return []StreamDef{
|
||||
{Name: "stable", Suffix: "", Description: "Stable releases"},
|
||||
{Name: "release-candidate", Suffix: "-rc", Description: "Release candidates"},
|
||||
{Name: "beta", Suffix: "-beta", Description: "Beta testing"},
|
||||
{Name: "alpha", Suffix: "-alpha", Description: "Alpha / early access"},
|
||||
{Name: "development", Suffix: "-dev", Description: "Development builds"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomStreams parses the CustomStreams JSON field.
|
||||
func (c *UpdateStreamConfig) GetCustomStreams() []StreamDef {
|
||||
if c.CustomStreams == "" {
|
||||
return nil
|
||||
}
|
||||
var streams []StreamDef
|
||||
if err := json.Unmarshal([]byte(c.CustomStreams), &streams); err != nil {
|
||||
return nil
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
// GetActiveStreams returns the effective streams for this config.
|
||||
func (c *UpdateStreamConfig) GetActiveStreams() []StreamDef {
|
||||
if c.StreamMode == "custom" {
|
||||
if custom := c.GetCustomStreams(); len(custom) > 0 {
|
||||
return custom
|
||||
}
|
||||
}
|
||||
return DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
// GetOrgConfig returns the org-level update stream config.
|
||||
func GetOrgConfig(ctx context.Context, ownerID int64) (*UpdateStreamConfig, error) {
|
||||
cfg := new(UpdateStreamConfig)
|
||||
has, err := db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", ownerID).Get(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return &UpdateStreamConfig{OwnerID: ownerID, StreamMode: "joomla"}, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetRepoConfig returns the repo-level override, or nil if none exists.
|
||||
func GetRepoConfig(ctx context.Context, repoID int64) (*UpdateStreamConfig, error) {
|
||||
cfg := new(UpdateStreamConfig)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetEffectiveStreams resolves the streams for a repo: repo override → org default → Joomla default.
|
||||
func GetEffectiveStreams(ctx context.Context, ownerID, repoID int64) []StreamDef {
|
||||
// Check repo-level override first.
|
||||
repoCfg, err := GetRepoConfig(ctx, repoID)
|
||||
if err == nil && repoCfg != nil {
|
||||
return repoCfg.GetActiveStreams()
|
||||
}
|
||||
|
||||
// Fall back to org-level config.
|
||||
orgCfg, err := GetOrgConfig(ctx, ownerID)
|
||||
if err == nil && orgCfg != nil {
|
||||
return orgCfg.GetActiveStreams()
|
||||
}
|
||||
|
||||
return DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
// SaveConfig creates or updates an update stream config.
|
||||
func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error {
|
||||
existing := new(UpdateStreamConfig)
|
||||
var has bool
|
||||
var err error
|
||||
if cfg.RepoID > 0 {
|
||||
has, err = db.GetEngine(ctx).Where("repo_id = ?", cfg.RepoID).Get(existing)
|
||||
} else {
|
||||
has, err = db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", cfg.OwnerID).Get(existing)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if has {
|
||||
cfg.ID = existing.ID
|
||||
_, err = db.GetEngine(ctx).ID(cfg.ID).AllCols().Update(cfg)
|
||||
} else {
|
||||
_, err = db.GetEngine(ctx).Insert(cfg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MatchStreamFromTag determines which stream a tag belongs to based on the given stream definitions.
|
||||
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").
|
||||
var bestMatch string
|
||||
bestLen := 0
|
||||
for _, s := range streams {
|
||||
if s.Suffix == "" {
|
||||
continue // stable/default stream handled below
|
||||
}
|
||||
if strings.Contains(lower, s.Suffix) && len(s.Suffix) > bestLen {
|
||||
bestMatch = s.Name
|
||||
bestLen = len(s.Suffix)
|
||||
}
|
||||
}
|
||||
if bestMatch != "" {
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
// If prerelease and no suffix matched, use the first prerelease stream.
|
||||
if isPrerelease {
|
||||
for _, s := range streams {
|
||||
if s.Suffix != "" {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: first stream with empty suffix (stable).
|
||||
for _, s := range streams {
|
||||
if s.Suffix == "" {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
@@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
|
||||
newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch),
|
||||
newMigration(335, "Add license key tables for update server", v1_27.AddLicenseKeyTables),
|
||||
newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type updateStreamConfig336 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
|
||||
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"`
|
||||
CustomStreams string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||
}
|
||||
|
||||
func (updateStreamConfig336) TableName() string {
|
||||
return "update_stream_config"
|
||||
}
|
||||
|
||||
// AddUpdateStreamConfigTable creates the update_stream_config table.
|
||||
func AddUpdateStreamConfigTable(x *xorm.Engine) error {
|
||||
return x.Sync(new(updateStreamConfig336))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
@@ -56,20 +57,24 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do
|
||||
Module: repo.Name,
|
||||
}
|
||||
|
||||
// Resolve effective streams.
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
// Track best release per channel.
|
||||
bestByChannel := make(map[string]*repo_model.Release)
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
ch := channelFromTag(rel.TagName, rel.IsPrerelease)
|
||||
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
|
||||
existing, ok := bestByChannel[ch]
|
||||
if !ok || rel.CreatedUnix > existing.CreatedUnix {
|
||||
bestByChannel[ch] = rel
|
||||
}
|
||||
}
|
||||
|
||||
for _, ch := range AllChannels {
|
||||
for _, stream := range streams {
|
||||
ch := stream.Name
|
||||
rel, ok := bestByChannel[ch]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -91,7 +96,10 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
suffix := channelSuffix(ch)
|
||||
suffix := stream.Suffix
|
||||
if suffix == "" {
|
||||
suffix = channelSuffix(ch)
|
||||
}
|
||||
if suffix != "" {
|
||||
version = version + suffix
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
@@ -142,13 +143,16 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed
|
||||
|
||||
element := strings.ToLower(repo.Name)
|
||||
|
||||
// Resolve effective streams (repo override → org default → Joomla default).
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
// Track best (latest) release per channel to emit one entry per channel.
|
||||
bestByChannel := make(map[string]*repo_model.Release)
|
||||
for _, rel := range releases {
|
||||
if rel.IsDraft || rel.IsTag {
|
||||
continue
|
||||
}
|
||||
ch := channelFromTag(rel.TagName, rel.IsPrerelease)
|
||||
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
|
||||
existing, ok := bestByChannel[ch]
|
||||
if !ok || rel.CreatedUnix > existing.CreatedUnix {
|
||||
bestByChannel[ch] = rel
|
||||
@@ -165,7 +169,8 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed
|
||||
}
|
||||
|
||||
var updates xmlUpdates
|
||||
for _, ch := range AllChannels {
|
||||
for _, stream := range streams {
|
||||
ch := stream.Name
|
||||
// Skip channels not in the allowed set (when filtering is active).
|
||||
if len(channelAllowed) > 0 && !channelAllowed[ch] {
|
||||
continue
|
||||
@@ -194,7 +199,10 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
suffix := channelSuffix(ch)
|
||||
suffix := stream.Suffix
|
||||
if suffix == "" {
|
||||
suffix = channelSuffix(ch) // fallback for Joomla defaults
|
||||
}
|
||||
if suffix != "" {
|
||||
version = version + suffix
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user