Skip to content

Commit 453f7bd

Browse files
authored
feat(features): support scoped 'features disable' (#246)
* feat(features): support scoped 'features disable' Add --project/--gitlab-group/--github-org/--exclude flags to 'gitte features disable', mirroring 'enable'. A scoped disable removes only the matching projects from a gate's current scope, leaving it enabled for the rest, and disables the gate entirely once the last project is removed. Extract buildOverrideFromFlags (shared with enable) and add features.ProjectsInGateScope to resolve a gate's configured project set. * fix(features): keep scoped overrides within a gate's config scope Address review on scoped 'features disable': - runtime: intersect OverrideScope with the gate's config scope in extraEnvForProject so an override can only narrow, never broaden, the effective scope (also fixes the pre-existing TUI scope-editor flaw). - disable: unscoped path now cleans up a state entry for a gate removed from config instead of erroring; scoped path still requires config. - enable/disable: error when --exclude is given without --gitlab-group/--github-org. - tui: enterScopeTree reuses features.ProjectsInGateScope. - test: regression test that an override cannot broaden the config scope.
1 parent 994c998 commit 453f7bd

8 files changed

Lines changed: 266 additions & 35 deletions

File tree

actions/runner.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,12 @@ func extraEnvForProject(cfg *config.GitteConfig, st *state.GitteState, projName
361361
continue
362362
}
363363

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

376380
for k, v := range gate.Effects.Env {

actions/runner_features_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package actions
2+
3+
import (
4+
"testing"
5+
6+
"github.com/cego/gitte/config"
7+
"github.com/cego/gitte/state"
8+
)
9+
10+
// TestExtraEnvForProject_OverrideCannotBroadenConfigScope guards against a scoped
11+
// `features disable` (or the TUI scope editor) reconstructing an OverrideScope that
12+
// matches projects outside the gate's configured scope. The override may only narrow
13+
// the config scope, never broaden it.
14+
func TestExtraEnvForProject_OverrideCannotBroadenConfigScope(t *testing.T) {
15+
cfg := &config.GitteConfig{
16+
FeatureGates: map[string]config.FeatureGate{
17+
"hot": {
18+
Scope: config.FeatureScope{Projects: []string{"svc-a", "svc-b"}},
19+
Effects: config.FeatureEffects{Env: map[string]string{"FOO": "bar"}},
20+
},
21+
},
22+
Projects: map[string]config.ProjectConfig{
23+
"svc-a": {Remote: "git@gitlab.example.com:myorg/services/svc-a.git"},
24+
"svc-b": {Remote: "git@gitlab.example.com:myorg/services/svc-b.git"},
25+
"other": {Remote: "git@gitlab.example.com:myorg/tools/other.git"},
26+
},
27+
}
28+
29+
// Simulates the state after `disable hot --project svc-a`: the reconstruction
30+
// collapses the remaining projects into the top-level "myorg" group excluding
31+
// svc-a — a group that also covers myorg/tools/other, which was never in scope.
32+
st := &state.GitteState{
33+
Features: map[string]state.FeatureState{
34+
"hot": {
35+
Enabled: true,
36+
OverrideScope: &state.ScopeOverride{
37+
GitlabGroups: []state.ScopeOverrideGroup{
38+
{Host: "gitlab.example.com", Group: "myorg", ExcludeProjects: []string{"svc-a"}},
39+
},
40+
},
41+
},
42+
},
43+
}
44+
45+
cases := []struct {
46+
proj string
47+
wantEnv bool
48+
}{
49+
{"svc-b", true}, // still in scope and not excluded
50+
{"svc-a", false}, // in config scope but excluded by the override
51+
{"other", false}, // NEVER in config scope — must not be broadened in
52+
}
53+
54+
for _, tc := range cases {
55+
env := extraEnvForProject(cfg, st, tc.proj, cfg.Projects[tc.proj])
56+
if got := env["FOO"] == "bar"; got != tc.wantEnv {
57+
t.Errorf("%s: got env=%v, want %v (env=%v)", tc.proj, got, tc.wantEnv, env)
58+
}
59+
}
60+
}

cmd/features.go

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,25 +81,16 @@ func newFeaturesEnableCmd() *cobra.Command {
8181
return fmt.Errorf("unknown feature gate: %q", gateName)
8282
}
8383

84+
if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 {
85+
return fmt.Errorf("--exclude requires --gitlab-group or --github-org")
86+
}
87+
8488
fs := state.FeatureState{Enabled: true}
8589

8690
if len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0 {
87-
override := &state.ScopeOverride{Projects: projects}
88-
for _, g := range gitlabGroups {
89-
host, group, ok := strings.Cut(g, "/")
90-
if !ok {
91-
return fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g)
92-
}
93-
entry := state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes}
94-
override.GitlabGroups = append(override.GitlabGroups, entry)
95-
}
96-
for _, o := range githubOrgs {
97-
host, org, ok := strings.Cut(o, "/")
98-
if !ok {
99-
return fmt.Errorf("invalid --github-org format %q, expected host/org", o)
100-
}
101-
entry := state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes}
102-
override.GithubOrgs = append(override.GithubOrgs, entry)
91+
override, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes)
92+
if err != nil {
93+
return err
10394
}
10495
fs.OverrideScope = override
10596
}
@@ -122,27 +113,128 @@ func newFeaturesEnableCmd() *cobra.Command {
122113
}
123114

124115
func newFeaturesDisableCmd() *cobra.Command {
125-
return &cobra.Command{
116+
var (
117+
projects []string
118+
gitlabGroups []string
119+
githubOrgs []string
120+
excludes []string
121+
)
122+
123+
cmd := &cobra.Command{
126124
Use: "disable <gate>",
127-
Short: "Disable a feature gate",
125+
Short: "Disable a feature gate, or (with scope flags) disable it only for specific projects",
128126
Args: cobra.ExactArgs(1),
129127
RunE: func(cmd *cobra.Command, args []string) error {
130128
gateName := args[0]
131129

132-
if _, ok := globalSt.Features[gateName]; !ok {
130+
if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 {
131+
return fmt.Errorf("--exclude requires --gitlab-group or --github-org")
132+
}
133+
134+
fs, ok := globalSt.Features[gateName]
135+
scoped := len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0
136+
137+
// Unscoped disable only consults state, so it can also clean up a stale
138+
// entry for a gate that has since been removed from the config.
139+
if !scoped {
140+
if !ok {
141+
fmt.Printf("Feature gate %q was not enabled\n", gateName)
142+
return nil
143+
}
144+
delete(globalSt.Features, gateName)
145+
if err := state.Save(globalCwd, globalSt); err != nil {
146+
return fmt.Errorf("failed to save state: %w", err)
147+
}
148+
fmt.Printf("Feature gate %q disabled\n", gateName)
149+
return nil
150+
}
151+
152+
// Scoped disable needs the gate's configured scope to reconstruct the override.
153+
gate, cfgOK := globalCfg.FeatureGates[gateName]
154+
if !cfgOK {
155+
return fmt.Errorf("unknown feature gate: %q", gateName)
156+
}
157+
if !ok || !fs.Enabled {
133158
fmt.Printf("Feature gate %q was not enabled\n", gateName)
134159
return nil
135160
}
136161

137-
delete(globalSt.Features, gateName)
162+
// Remove the matching projects from the gate's current scope, leaving it
163+
// enabled for the rest.
164+
removal, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes)
165+
if err != nil {
166+
return err
167+
}
168+
169+
scopeProjects := features.ProjectsInGateScope(globalCfg, gate)
170+
checked := features.OverrideToCheckedState(fs.OverrideScope, scopeProjects)
171+
172+
removed := 0
173+
for name, sp := range scopeProjects {
174+
if checked[name] && features.ProjectMatchesOverrideScope(name, sp.Host, sp.Path, removal) {
175+
checked[name] = false
176+
removed++
177+
}
178+
}
179+
180+
if removed == 0 {
181+
fmt.Printf("Feature gate %q was not enabled for the given project(s)\n", gateName)
182+
return nil
183+
}
184+
185+
anyLeft := false
186+
for _, v := range checked {
187+
if v {
188+
anyLeft = true
189+
break
190+
}
191+
}
192+
193+
if anyLeft {
194+
fs.OverrideScope = features.CheckedStateToOverride(checked, scopeProjects)
195+
globalSt.Features[gateName] = fs
196+
} else {
197+
delete(globalSt.Features, gateName)
198+
}
199+
138200
if err := state.Save(globalCwd, globalSt); err != nil {
139201
return fmt.Errorf("failed to save state: %w", err)
140202
}
141203

142-
fmt.Printf("Feature gate %q disabled\n", gateName)
204+
if anyLeft {
205+
fmt.Printf("Feature gate %q disabled for the given project(s)\n", gateName)
206+
} else {
207+
fmt.Printf("Feature gate %q disabled\n", gateName)
208+
}
143209
return nil
144210
},
145211
}
212+
213+
cmd.Flags().StringArrayVar(&projects, "project", nil, "disable only for specific project(s)")
214+
cmd.Flags().StringArrayVar(&gitlabGroups, "gitlab-group", nil, "disable only for gitlab group (host/group)")
215+
cmd.Flags().StringArrayVar(&githubOrgs, "github-org", nil, "disable only for github org (host/org)")
216+
cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "exclude project from all groups/orgs")
217+
return cmd
218+
}
219+
220+
// buildOverrideFromFlags turns the CLI scope flags into a ScopeOverride.
221+
func buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes []string) (*state.ScopeOverride, error) {
222+
override := &state.ScopeOverride{Projects: projects}
223+
for _, g := range gitlabGroups {
224+
host, group, ok := strings.Cut(g, "/")
225+
if !ok {
226+
return nil, fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g)
227+
}
228+
override.GitlabGroups = append(override.GitlabGroups, state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes})
229+
}
230+
for _, o := range githubOrgs {
231+
host, org, ok := strings.Cut(o, "/")
232+
if !ok {
233+
return nil, fmt.Errorf("invalid --github-org format %q, expected host/org", o)
234+
}
235+
override.GithubOrgs = append(override.GithubOrgs, state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes})
236+
}
237+
return override, nil
146238
}
147239

148240
func buildOverrideScopeDescription(o *state.ScopeOverride) string {

docs/commands.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,19 @@ Manage feature gates — opt-in behaviours that inject environment variables int
8989

9090
```bash
9191
gitte features list # show all gates and their state
92-
gitte features enable HOT_RELOAD # enable a gate
93-
gitte features disable HOT_RELOAD # disable a gate
92+
gitte features enable HOT_RELOAD # enable a gate (all projects in its scope)
93+
gitte features disable HOT_RELOAD # disable a gate entirely
94+
```
95+
96+
Both `enable` and `disable` accept scope flags to target individual projects, GitLab
97+
groups, or GitHub orgs. A scoped `disable` removes only the matching projects from the
98+
gate's current scope, leaving it enabled for the rest (and disables the gate entirely
99+
once the last project is removed):
100+
101+
```bash
102+
gitte features enable HOT_RELOAD --project frontend # enable for one project only
103+
gitte features disable HOT_RELOAD --project frontend # disable for one project only
104+
gitte features disable HOT_RELOAD --gitlab-group gitlab.example.com/myorg/services
94105
```
95106

96107
---

docs/config.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,15 @@ gitte features enable HOT_RELOAD
366366
gitte features disable HOT_RELOAD
367367
```
368368

369+
`enable` and `disable` both accept `--project`, `--gitlab-group`, and `--github-org`
370+
flags to target a subset. A scoped `disable` removes only the matching projects from the
371+
gate's scope, leaving it on for the rest:
372+
373+
```bash
374+
gitte features enable HOT_RELOAD --project frontend
375+
gitte features disable HOT_RELOAD --project frontend
376+
```
377+
369378
---
370379

371380
## sources

features/scope.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,26 @@ import (
44
"sort"
55
"strings"
66

7+
"github.com/cego/gitte/config"
78
"github.com/cego/gitte/state"
89
)
910

11+
// ProjectsInGateScope returns the projects a gate's configured scope applies to,
12+
// keyed by config project name. Projects with an unparseable remote are skipped.
13+
func ProjectsInGateScope(cfg *config.GitteConfig, gate config.FeatureGate) map[string]ScopeProject {
14+
projects := make(map[string]ScopeProject)
15+
for projName, proj := range cfg.Projects {
16+
host, path, _, err := config.ParseRemoteURL(proj.Remote)
17+
if err != nil {
18+
continue
19+
}
20+
if projectMatchesScopeByName(projName, host, path, gate.Scope) {
21+
projects[projName] = ScopeProject{Host: host, Path: path}
22+
}
23+
}
24+
return projects
25+
}
26+
1027
// ProjectMatchesOverrideScope checks if a project is included in an override scope.
1128
// projName is the config key, host and path come from config.ParseRemoteURL.
1229
// Returns false if override is nil (caller should use config scope instead).

features/scope_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package features
33
import (
44
"testing"
55

6+
"github.com/cego/gitte/config"
67
"github.com/cego/gitte/state"
78
)
89

@@ -155,6 +156,52 @@ func TestCheckedStateToOverride_PartialGroup(t *testing.T) {
155156
}
156157
}
157158

159+
func TestProjectsInGateScope_EmptyScopeMatchesAll(t *testing.T) {
160+
cfg := &config.GitteConfig{
161+
Projects: map[string]config.ProjectConfig{
162+
"monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"},
163+
"promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"},
164+
},
165+
}
166+
gate := config.FeatureGate{} // no scope => all projects
167+
168+
got := ProjectsInGateScope(cfg, gate)
169+
if len(got) != 2 {
170+
t.Fatalf("expected 2 projects, got %d", len(got))
171+
}
172+
if got["promo"].Path != "spilnu/services/promo" {
173+
t.Errorf("expected parsed path for promo, got %q", got["promo"].Path)
174+
}
175+
}
176+
177+
func TestProjectsInGateScope_RestrictedByGitlabGroup(t *testing.T) {
178+
cfg := &config.GitteConfig{
179+
Projects: map[string]config.ProjectConfig{
180+
"monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"},
181+
"promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"},
182+
"broken": {Remote: "not-a-valid-remote"},
183+
},
184+
}
185+
gate := config.FeatureGate{
186+
Scope: config.FeatureScope{
187+
GitlabGroups: []config.GitlabScope{
188+
{Host: "gitlab.cego.dk", Group: "spilnu"},
189+
},
190+
},
191+
}
192+
193+
got := ProjectsInGateScope(cfg, gate)
194+
if len(got) != 1 {
195+
t.Fatalf("expected 1 project in scope, got %d", len(got))
196+
}
197+
if _, ok := got["promo"]; !ok {
198+
t.Error("expected promo to be in scope")
199+
}
200+
if _, ok := got["monolith"]; ok {
201+
t.Error("expected monolith to be out of scope")
202+
}
203+
}
204+
158205
func TestOverrideToCheckedState(t *testing.T) {
159206
override := &state.ScopeOverride{
160207
GitlabGroups: []state.ScopeOverrideGroup{

features/tui.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -457,16 +457,7 @@ func (m *featuresModel) scopeUndo() {
457457
func (m *featuresModel) enterScopeTree() {
458458
g := m.gates[m.gateCursor]
459459

460-
projects := make(map[string]ScopeProject)
461-
for projName, proj := range m.cfg.Projects {
462-
host, path, _, err := config.ParseRemoteURL(proj.Remote)
463-
if err != nil {
464-
continue
465-
}
466-
if projectMatchesScopeByName(projName, host, path, g.Gate.Scope) {
467-
projects[projName] = ScopeProject{Host: host, Path: path}
468-
}
469-
}
460+
projects := ProjectsInGateScope(m.cfg, g.Gate)
470461

471462
fs := m.st.Features[g.Name]
472463
var checked map[string]bool

0 commit comments

Comments
 (0)