Skip to content

Commit fd2a845

Browse files
committed
add configuration loading
Connection identity and credentials resolve from env only (GERRIT_URL, GERRIT_USERNAME, GERRIT_TOKEN); behavior resolves from flags with GERRIT_MCP_* env mirrors, flag winning over mirror, mirror over default. Zero behavior config yields the read group. All configuration errors are aggregated into a single report; unknown group names and empty group lists fail startup. The behaviorFlag table is the extension point for upcoming options: tool filters, project scoping, own-changes restriction. Refs: #3
1 parent 2ff615e commit fd2a845

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Package config resolves the server configuration from CLI flags and
2+
// environment variables. Connection identity and credentials come from
3+
// environment only (GERRIT_URL, GERRIT_USERNAME, GERRIT_TOKEN); behavior
4+
// comes from flags, each mirrored by a GERRIT_MCP_* variable with the flag
5+
// winning when both are set. Zero behavior configuration yields the read
6+
// capability group only.
7+
package config
8+
9+
import (
10+
"errors"
11+
"flag"
12+
"fmt"
13+
"io"
14+
"strings"
15+
)
16+
17+
// Group is a capability group name: an independent, self-sufficient unit of
18+
// server capability selected via --groups.
19+
type Group string
20+
21+
// Capability groups. Each write-capable group bundles the minimal change-read
22+
// subset it needs to function on its own; enabled groups union.
23+
const (
24+
GroupRead Group = "read"
25+
GroupComment Group = "comment"
26+
GroupTransition Group = "transition"
27+
)
28+
29+
const defaultGroups = "read"
30+
31+
// Env variable names for connection identity and credentials.
32+
const (
33+
EnvURL = "GERRIT_URL"
34+
EnvUsername = "GERRIT_USERNAME"
35+
EnvToken = "GERRIT_TOKEN"
36+
)
37+
38+
var (
39+
errEnvMissing = errors.New("required environment variable is not set")
40+
errUnknownGroups = errors.New("unknown capability groups")
41+
errNoGroups = errors.New("no capability groups enabled")
42+
)
43+
44+
// Config is the resolved server configuration.
45+
type Config struct {
46+
// GerritURL is the base URL of the Gerrit instance.
47+
GerritURL string
48+
// Username authenticates HTTP Basic requests.
49+
Username string
50+
// Token is the HTTP credential paired with Username.
51+
Token string
52+
// Groups are the enabled capability groups, deduplicated, in input order.
53+
Groups []Group
54+
}
55+
56+
// behaviorFlag is one CLI flag with its GERRIT_MCP_* env mirror. The flag
57+
// value wins over the mirror; the mirror wins over the default. New behavior
58+
// options (tool filters, project scoping, own-changes) register here.
59+
type behaviorFlag struct {
60+
name string
61+
mirror string
62+
usage string
63+
fallback string
64+
65+
value string `exhaustruct:"optional"`
66+
}
67+
68+
// Load resolves configuration from CLI arguments and the environment.
69+
// It reports every configuration error at once.
70+
func Load(args []string, getenv func(string) string) (*Config, error) {
71+
groups := behaviorFlag{
72+
name: "groups",
73+
mirror: "GERRIT_MCP_GROUPS",
74+
usage: "comma-separated capability groups to enable: read, comment, transition",
75+
fallback: defaultGroups,
76+
}
77+
78+
if err := resolveFlags(args, getenv, []*behaviorFlag{&groups}); err != nil {
79+
return nil, err
80+
}
81+
82+
cfg := &Config{
83+
GerritURL: getenv(EnvURL),
84+
Username: getenv(EnvUsername),
85+
Token: getenv(EnvToken),
86+
Groups: nil,
87+
}
88+
89+
errs := missingEnv(cfg)
90+
91+
parsed, err := parseGroups(groups.value)
92+
if err != nil {
93+
errs = append(errs, err)
94+
}
95+
96+
cfg.Groups = parsed
97+
98+
if len(errs) > 0 {
99+
return nil, errors.Join(errs...)
100+
}
101+
102+
return cfg, nil
103+
}
104+
105+
// resolveFlags parses args into the given flags, then fills every flag that
106+
// was not set explicitly from its env mirror, falling back to its default.
107+
func resolveFlags(args []string, getenv func(string) string, flags []*behaviorFlag) error {
108+
fs := flag.NewFlagSet("go-gerrit-mcp", flag.ContinueOnError)
109+
fs.SetOutput(io.Discard)
110+
111+
for _, bf := range flags {
112+
fs.StringVar(&bf.value, bf.name, "", bf.usage)
113+
}
114+
115+
if err := fs.Parse(args); err != nil {
116+
return fmt.Errorf("parse flags: %w", err)
117+
}
118+
119+
explicit := make(map[string]bool, len(flags))
120+
fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
121+
122+
for _, bf := range flags {
123+
if explicit[bf.name] {
124+
continue
125+
}
126+
127+
bf.value = bf.fallback
128+
129+
if v := getenv(bf.mirror); v != "" {
130+
bf.value = v
131+
}
132+
}
133+
134+
return nil
135+
}
136+
137+
func missingEnv(cfg *Config) []error {
138+
var errs []error
139+
140+
for _, v := range []struct{ name, value string }{
141+
{EnvURL, cfg.GerritURL},
142+
{EnvUsername, cfg.Username},
143+
{EnvToken, cfg.Token},
144+
} {
145+
if v.value == "" {
146+
errs = append(errs, fmt.Errorf("%w: %s", errEnvMissing, v.name))
147+
}
148+
}
149+
150+
return errs
151+
}
152+
153+
func parseGroups(s string) ([]Group, error) {
154+
known := map[Group]bool{GroupRead: true, GroupComment: true, GroupTransition: true}
155+
156+
var (
157+
groups []Group
158+
unknown []string
159+
seen = make(map[Group]bool, len(known))
160+
)
161+
162+
for part := range strings.SplitSeq(s, ",") {
163+
name := Group(strings.TrimSpace(part))
164+
if name == "" {
165+
continue
166+
}
167+
168+
if !known[name] {
169+
unknown = append(unknown, string(name))
170+
171+
continue
172+
}
173+
174+
if !seen[name] {
175+
seen[name] = true
176+
177+
groups = append(groups, name)
178+
}
179+
}
180+
181+
if len(unknown) > 0 {
182+
return nil, fmt.Errorf("%w: %s (known: read, comment, transition)",
183+
errUnknownGroups, strings.Join(unknown, ", "))
184+
}
185+
186+
if len(groups) == 0 {
187+
return nil, errNoGroups
188+
}
189+
190+
return groups, nil
191+
}

internal/config/config_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package config_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"dev.gaijin.team/go/go-gerrit-mcp/internal/config"
10+
)
11+
12+
func env(m map[string]string) func(string) string {
13+
return func(k string) string { return m[k] }
14+
}
15+
16+
func secrets() map[string]string {
17+
return map[string]string{
18+
"GERRIT_URL": "https://gerrit.example.com",
19+
"GERRIT_USERNAME": "bot",
20+
"GERRIT_TOKEN": "s3cret",
21+
}
22+
}
23+
24+
func Test_Load_Groups(t *testing.T) {
25+
t.Parallel()
26+
27+
tests := []struct {
28+
name string
29+
giveArgs []string
30+
giveEnv map[string]string
31+
want []config.Group
32+
}{
33+
{
34+
name: "zero config defaults to read",
35+
giveArgs: nil,
36+
giveEnv: secrets(),
37+
want: []config.Group{config.GroupRead},
38+
},
39+
{
40+
name: "env mirror applies when flag absent",
41+
giveArgs: nil,
42+
giveEnv: func() map[string]string {
43+
m := secrets()
44+
45+
m["GERRIT_MCP_GROUPS"] = "comment"
46+
47+
return m
48+
}(),
49+
want: []config.Group{config.GroupComment},
50+
},
51+
{
52+
name: "flag wins over env mirror",
53+
giveArgs: []string{"--groups", "transition"},
54+
giveEnv: func() map[string]string {
55+
m := secrets()
56+
57+
m["GERRIT_MCP_GROUPS"] = "comment"
58+
59+
return m
60+
}(),
61+
want: []config.Group{config.GroupTransition},
62+
},
63+
{
64+
name: "multiple groups preserve order",
65+
giveArgs: []string{"--groups", "comment,read"},
66+
giveEnv: secrets(),
67+
want: []config.Group{config.GroupComment, config.GroupRead},
68+
},
69+
{
70+
name: "duplicates collapse",
71+
giveArgs: []string{"--groups", "read,read,comment"},
72+
giveEnv: secrets(),
73+
want: []config.Group{config.GroupRead, config.GroupComment},
74+
},
75+
{
76+
name: "whitespace tolerated",
77+
giveArgs: []string{"--groups", " read , transition "},
78+
giveEnv: secrets(),
79+
want: []config.Group{config.GroupRead, config.GroupTransition},
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
cfg, err := config.Load(tt.giveArgs, env(tt.giveEnv))
88+
require.NoError(t, err)
89+
assert.Equal(t, tt.want, cfg.Groups)
90+
})
91+
}
92+
}
93+
94+
func Test_Load_Errors(t *testing.T) {
95+
t.Parallel()
96+
97+
t.Run("missing secrets aggregated", func(t *testing.T) {
98+
t.Parallel()
99+
100+
_, err := config.Load(nil, env(map[string]string{}))
101+
require.Error(t, err)
102+
assert.ErrorContains(t, err, "GERRIT_URL")
103+
assert.ErrorContains(t, err, "GERRIT_USERNAME")
104+
assert.ErrorContains(t, err, "GERRIT_TOKEN")
105+
})
106+
107+
t.Run("single missing secret named alone", func(t *testing.T) {
108+
t.Parallel()
109+
110+
m := secrets()
111+
delete(m, "GERRIT_TOKEN")
112+
113+
_, err := config.Load(nil, env(m))
114+
require.Error(t, err)
115+
assert.ErrorContains(t, err, "GERRIT_TOKEN")
116+
assert.NotContains(t, err.Error(), "GERRIT_URL")
117+
})
118+
119+
t.Run("unknown group named in error", func(t *testing.T) {
120+
t.Parallel()
121+
122+
_, err := config.Load([]string{"--groups", "read,write"}, env(secrets()))
123+
require.Error(t, err)
124+
assert.ErrorContains(t, err, "unknown capability groups: write")
125+
})
126+
127+
t.Run("empty groups value rejected", func(t *testing.T) {
128+
t.Parallel()
129+
130+
_, err := config.Load([]string{"--groups", ""}, env(secrets()))
131+
require.Error(t, err)
132+
assert.ErrorContains(t, err, "no capability groups enabled")
133+
})
134+
135+
t.Run("group error and secret error reported together", func(t *testing.T) {
136+
t.Parallel()
137+
138+
m := secrets()
139+
delete(m, "GERRIT_URL")
140+
141+
_, err := config.Load([]string{"--groups", "bogus"}, env(m))
142+
require.Error(t, err)
143+
assert.ErrorContains(t, err, "GERRIT_URL")
144+
assert.ErrorContains(t, err, "unknown capability groups: bogus")
145+
})
146+
147+
t.Run("unknown flag rejected", func(t *testing.T) {
148+
t.Parallel()
149+
150+
_, err := config.Load([]string{"--bogus"}, env(secrets()))
151+
require.Error(t, err)
152+
assert.ErrorContains(t, err, "parse flags")
153+
})
154+
}
155+
156+
func Test_Load_Connection(t *testing.T) {
157+
t.Parallel()
158+
159+
cfg, err := config.Load(nil, env(secrets()))
160+
require.NoError(t, err)
161+
162+
assert.Equal(t, "https://gerrit.example.com", cfg.GerritURL)
163+
assert.Equal(t, "bot", cfg.Username)
164+
assert.Equal(t, "s3cret", cfg.Token)
165+
}

0 commit comments

Comments
 (0)