Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions actions/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ func extraEnvForProject(cfg *config.GitteConfig, st *state.GitteState, projName
continue
}

// A gate applies only within its configured scope. A per-machine override may
// narrow that set further, but must never broaden it — so always require the
// config scope to match, then additionally the override when one is set.
if !ProjectMatchesScopeByName(projName, proj, gate.Scope) {
continue
}
if fs.OverrideScope != nil {
host, path, _, err := config.ParseRemoteURL(proj.Remote)
if err != nil {
Expand All @@ -369,8 +375,6 @@ func extraEnvForProject(cfg *config.GitteConfig, st *state.GitteState, projName
if !features.ProjectMatchesOverrideScope(projName, host, path, fs.OverrideScope) {
continue
}
} else if !ProjectMatchesScopeByName(projName, proj, gate.Scope) {
continue
}

for k, v := range gate.Effects.Env {
Expand Down
60 changes: 60 additions & 0 deletions actions/runner_features_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package actions

import (
"testing"

"github.com/cego/gitte/config"
"github.com/cego/gitte/state"
)

// TestExtraEnvForProject_OverrideCannotBroadenConfigScope guards against a scoped
// `features disable` (or the TUI scope editor) reconstructing an OverrideScope that
// matches projects outside the gate's configured scope. The override may only narrow
// the config scope, never broaden it.
func TestExtraEnvForProject_OverrideCannotBroadenConfigScope(t *testing.T) {
cfg := &config.GitteConfig{
FeatureGates: map[string]config.FeatureGate{
"hot": {
Scope: config.FeatureScope{Projects: []string{"svc-a", "svc-b"}},
Effects: config.FeatureEffects{Env: map[string]string{"FOO": "bar"}},
},
},
Projects: map[string]config.ProjectConfig{
"svc-a": {Remote: "git@gitlab.example.com:myorg/services/svc-a.git"},
"svc-b": {Remote: "git@gitlab.example.com:myorg/services/svc-b.git"},
"other": {Remote: "git@gitlab.example.com:myorg/tools/other.git"},
},
}

// Simulates the state after `disable hot --project svc-a`: the reconstruction
// collapses the remaining projects into the top-level "myorg" group excluding
// svc-a — a group that also covers myorg/tools/other, which was never in scope.
st := &state.GitteState{
Features: map[string]state.FeatureState{
"hot": {
Enabled: true,
OverrideScope: &state.ScopeOverride{
GitlabGroups: []state.ScopeOverrideGroup{
{Host: "gitlab.example.com", Group: "myorg", ExcludeProjects: []string{"svc-a"}},
},
},
},
},
}

cases := []struct {
proj string
wantEnv bool
}{
{"svc-b", true}, // still in scope and not excluded
{"svc-a", false}, // in config scope but excluded by the override
{"other", false}, // NEVER in config scope — must not be broadened in
}

for _, tc := range cases {
env := extraEnvForProject(cfg, st, tc.proj, cfg.Projects[tc.proj])
if got := env["FOO"] == "bar"; got != tc.wantEnv {
t.Errorf("%s: got env=%v, want %v (env=%v)", tc.proj, got, tc.wantEnv, env)
}
}
}
134 changes: 113 additions & 21 deletions cmd/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,16 @@ func newFeaturesEnableCmd() *cobra.Command {
return fmt.Errorf("unknown feature gate: %q", gateName)
}

if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 {
return fmt.Errorf("--exclude requires --gitlab-group or --github-org")
}

fs := state.FeatureState{Enabled: true}

if len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0 {
override := &state.ScopeOverride{Projects: projects}
for _, g := range gitlabGroups {
host, group, ok := strings.Cut(g, "/")
if !ok {
return fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g)
}
entry := state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes}
override.GitlabGroups = append(override.GitlabGroups, entry)
}
for _, o := range githubOrgs {
host, org, ok := strings.Cut(o, "/")
if !ok {
return fmt.Errorf("invalid --github-org format %q, expected host/org", o)
}
entry := state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes}
override.GithubOrgs = append(override.GithubOrgs, entry)
override, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes)
if err != nil {
return err
}
fs.OverrideScope = override
}
Expand All @@ -122,27 +113,128 @@ func newFeaturesEnableCmd() *cobra.Command {
}

func newFeaturesDisableCmd() *cobra.Command {
return &cobra.Command{
var (
projects []string
gitlabGroups []string
githubOrgs []string
excludes []string
)

cmd := &cobra.Command{
Use: "disable <gate>",
Short: "Disable a feature gate",
Short: "Disable a feature gate, or (with scope flags) disable it only for specific projects",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
gateName := args[0]

if _, ok := globalSt.Features[gateName]; !ok {
if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 {
return fmt.Errorf("--exclude requires --gitlab-group or --github-org")
}

fs, ok := globalSt.Features[gateName]
scoped := len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0
Comment thread
LauJosefsen marked this conversation as resolved.

// Unscoped disable only consults state, so it can also clean up a stale
// entry for a gate that has since been removed from the config.
if !scoped {
if !ok {
fmt.Printf("Feature gate %q was not enabled\n", gateName)
return nil
}
delete(globalSt.Features, gateName)
if err := state.Save(globalCwd, globalSt); err != nil {
return fmt.Errorf("failed to save state: %w", err)
}
fmt.Printf("Feature gate %q disabled\n", gateName)
return nil
}

// Scoped disable needs the gate's configured scope to reconstruct the override.
gate, cfgOK := globalCfg.FeatureGates[gateName]
if !cfgOK {
return fmt.Errorf("unknown feature gate: %q", gateName)
}
if !ok || !fs.Enabled {
fmt.Printf("Feature gate %q was not enabled\n", gateName)
return nil
}

delete(globalSt.Features, gateName)
// Remove the matching projects from the gate's current scope, leaving it
// enabled for the rest.
removal, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes)
if err != nil {
return err
}

scopeProjects := features.ProjectsInGateScope(globalCfg, gate)
checked := features.OverrideToCheckedState(fs.OverrideScope, scopeProjects)

removed := 0
for name, sp := range scopeProjects {
if checked[name] && features.ProjectMatchesOverrideScope(name, sp.Host, sp.Path, removal) {
checked[name] = false
removed++
}
}

if removed == 0 {
fmt.Printf("Feature gate %q was not enabled for the given project(s)\n", gateName)
return nil
}

anyLeft := false
for _, v := range checked {
if v {
anyLeft = true
break
}
}

if anyLeft {
fs.OverrideScope = features.CheckedStateToOverride(checked, scopeProjects)
Comment thread
LauJosefsen marked this conversation as resolved.
globalSt.Features[gateName] = fs
} else {
delete(globalSt.Features, gateName)
}

if err := state.Save(globalCwd, globalSt); err != nil {
return fmt.Errorf("failed to save state: %w", err)
}

fmt.Printf("Feature gate %q disabled\n", gateName)
if anyLeft {
fmt.Printf("Feature gate %q disabled for the given project(s)\n", gateName)
} else {
fmt.Printf("Feature gate %q disabled\n", gateName)
}
return nil
},
}

cmd.Flags().StringArrayVar(&projects, "project", nil, "disable only for specific project(s)")
cmd.Flags().StringArrayVar(&gitlabGroups, "gitlab-group", nil, "disable only for gitlab group (host/group)")
cmd.Flags().StringArrayVar(&githubOrgs, "github-org", nil, "disable only for github org (host/org)")
cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "exclude project from all groups/orgs")
return cmd
}

// buildOverrideFromFlags turns the CLI scope flags into a ScopeOverride.
func buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes []string) (*state.ScopeOverride, error) {
override := &state.ScopeOverride{Projects: projects}
for _, g := range gitlabGroups {
host, group, ok := strings.Cut(g, "/")
if !ok {
return nil, fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g)
}
override.GitlabGroups = append(override.GitlabGroups, state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes})
}
for _, o := range githubOrgs {
host, org, ok := strings.Cut(o, "/")
if !ok {
return nil, fmt.Errorf("invalid --github-org format %q, expected host/org", o)
}
override.GithubOrgs = append(override.GithubOrgs, state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes})
}
return override, nil
}

func buildOverrideScopeDescription(o *state.ScopeOverride) string {
Expand Down
15 changes: 13 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,19 @@ Manage feature gates — opt-in behaviours that inject environment variables int

```bash
gitte features list # show all gates and their state
gitte features enable HOT_RELOAD # enable a gate
gitte features disable HOT_RELOAD # disable a gate
gitte features enable HOT_RELOAD # enable a gate (all projects in its scope)
gitte features disable HOT_RELOAD # disable a gate entirely
```

Both `enable` and `disable` accept scope flags to target individual projects, GitLab
groups, or GitHub orgs. A scoped `disable` removes only the matching projects from the
gate's current scope, leaving it enabled for the rest (and disables the gate entirely
once the last project is removed):

```bash
gitte features enable HOT_RELOAD --project frontend # enable for one project only
gitte features disable HOT_RELOAD --project frontend # disable for one project only
gitte features disable HOT_RELOAD --gitlab-group gitlab.example.com/myorg/services
```

---
Expand Down
9 changes: 9 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,15 @@ gitte features enable HOT_RELOAD
gitte features disable HOT_RELOAD
```

`enable` and `disable` both accept `--project`, `--gitlab-group`, and `--github-org`
flags to target a subset. A scoped `disable` removes only the matching projects from the
gate's scope, leaving it on for the rest:

```bash
gitte features enable HOT_RELOAD --project frontend
gitte features disable HOT_RELOAD --project frontend
```

---

## sources
Expand Down
17 changes: 17 additions & 0 deletions features/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@ import (
"sort"
"strings"

"github.com/cego/gitte/config"
"github.com/cego/gitte/state"
)

// ProjectsInGateScope returns the projects a gate's configured scope applies to,
// keyed by config project name. Projects with an unparseable remote are skipped.
func ProjectsInGateScope(cfg *config.GitteConfig, gate config.FeatureGate) map[string]ScopeProject {
projects := make(map[string]ScopeProject)
for projName, proj := range cfg.Projects {
host, path, _, err := config.ParseRemoteURL(proj.Remote)
if err != nil {
continue
}
if projectMatchesScopeByName(projName, host, path, gate.Scope) {
projects[projName] = ScopeProject{Host: host, Path: path}
}
}
return projects
}

// ProjectMatchesOverrideScope checks if a project is included in an override scope.
// projName is the config key, host and path come from config.ParseRemoteURL.
// Returns false if override is nil (caller should use config scope instead).
Expand Down
47 changes: 47 additions & 0 deletions features/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package features
import (
"testing"

"github.com/cego/gitte/config"
"github.com/cego/gitte/state"
)

Expand Down Expand Up @@ -155,6 +156,52 @@ func TestCheckedStateToOverride_PartialGroup(t *testing.T) {
}
}

func TestProjectsInGateScope_EmptyScopeMatchesAll(t *testing.T) {
Comment thread
LauJosefsen marked this conversation as resolved.
cfg := &config.GitteConfig{
Projects: map[string]config.ProjectConfig{
"monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"},
"promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"},
},
}
gate := config.FeatureGate{} // no scope => all projects

got := ProjectsInGateScope(cfg, gate)
if len(got) != 2 {
t.Fatalf("expected 2 projects, got %d", len(got))
}
if got["promo"].Path != "spilnu/services/promo" {
t.Errorf("expected parsed path for promo, got %q", got["promo"].Path)
}
}

func TestProjectsInGateScope_RestrictedByGitlabGroup(t *testing.T) {
cfg := &config.GitteConfig{
Projects: map[string]config.ProjectConfig{
"monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"},
"promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"},
"broken": {Remote: "not-a-valid-remote"},
},
}
gate := config.FeatureGate{
Scope: config.FeatureScope{
GitlabGroups: []config.GitlabScope{
{Host: "gitlab.cego.dk", Group: "spilnu"},
},
},
}

got := ProjectsInGateScope(cfg, gate)
if len(got) != 1 {
t.Fatalf("expected 1 project in scope, got %d", len(got))
}
if _, ok := got["promo"]; !ok {
t.Error("expected promo to be in scope")
}
if _, ok := got["monolith"]; ok {
t.Error("expected monolith to be out of scope")
}
}

func TestOverrideToCheckedState(t *testing.T) {
override := &state.ScopeOverride{
GitlabGroups: []state.ScopeOverrideGroup{
Expand Down
11 changes: 1 addition & 10 deletions features/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,16 +457,7 @@ func (m *featuresModel) scopeUndo() {
func (m *featuresModel) enterScopeTree() {
g := m.gates[m.gateCursor]

projects := make(map[string]ScopeProject)
for projName, proj := range m.cfg.Projects {
host, path, _, err := config.ParseRemoteURL(proj.Remote)
if err != nil {
continue
}
if projectMatchesScopeByName(projName, host, path, g.Gate.Scope) {
projects[projName] = ScopeProject{Host: host, Path: path}
}
}
projects := ProjectsInGateScope(m.cfg, g.Gate)

fs := m.st.Features[g.Name]
var checked map[string]bool
Expand Down
Loading