From a88e3f8787ea2bbb6d2051cdd91ed5893abcc3f7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:49:46 -0500 Subject: [PATCH] feat(updates): org-level default streams with per-repo override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable update streams at org and repo level: - UpdateStreamConfig model: stores stream mode (joomla/custom) and custom stream definitions (name, suffix, description) - Resolution chain: repo override → org default → Joomla defaults - MatchStreamFromTag: matches release tags to streams using configured suffixes (longest match wins) - Both Joomla XML and Dolibarr JSON generators use effective streams - DB migration v336 creates update_stream_config table - Default Joomla streams: stable, release-candidate, beta, alpha, development - Custom streams support any tag suffix (e.g. -lts, -nightly, -security) Ref #265 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/update_stream_config.go | 180 ++++++++++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v336.go | 29 ++++ services/updateserver/dolibarr.go | 14 +- services/updateserver/joomla.go | 14 +- 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 models/licenses/update_stream_config.go create mode 100644 models/migrations/v1_27/v336.go diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go new file mode 100644 index 0000000000..c8dc1e6e55 --- /dev/null +++ b/models/licenses/update_stream_config.go @@ -0,0 +1,180 @@ +// Copyright 2026 Moko Consulting +// 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" +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 3ddfb5e980..aa7fb2b103 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v336.go b/models/migrations/v1_27/v336.go new file mode 100644 index 0000000000..d9beafcade --- /dev/null +++ b/models/migrations/v1_27/v336.go @@ -0,0 +1,29 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go index d434182344..4b61544658 100644 --- a/services/updateserver/dolibarr.go +++ b/services/updateserver/dolibarr.go @@ -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 } diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 100d52e78b..0846d4f91e 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -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 } -- 2.52.0