feat(config): add ERMITE_COMMITTER_NAME and ERMITE_COMMITTER_EMAIL
The committer of the commits ermite creates. Defaults: ermite and ermite@<host of ERMITE_PUBLIC_URL>.
- commit
- 0d1dbead96c3bae504f6e7eed065527d110bf7a5
- author
- brice · · 2026-10-10 16:57 UTC
- parent
- 224c22d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +83 -0
M internal/config/config.go +44 -0
| @@ -28,6 +28,10 @@ const ( | |||
| 28 | 28 | EnvSourceURL = "ERMITE_SOURCE_URL" | |
| 29 | 29 | EnvLogLevel = "ERMITE_LOG_LEVEL" | |
| 30 | 30 | EnvLogFormat = "ERMITE_LOG_FORMAT" | |
| 31 | + | // EnvCommitterName and EnvCommitterEmail name the committer of the | |
| 32 | + | // commits ermite creates (squash merges, ADR 0022). | |
| 33 | + | EnvCommitterName = "ERMITE_COMMITTER_NAME" | |
| 34 | + | EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL" | |
| 31 | 35 | ) | |
| 32 | 36 | ||
| 33 | 37 | // Defaults. | |
| @@ -36,6 +40,8 @@ const ( | |||
| 36 | 40 | DefaultGit = "git" | |
| 37 | 41 | DefaultLogLevel = "info" | |
| 38 | 42 | DefaultLogFormat = "json" | |
| 43 | + | // DefaultCommitterName is the committer name of the commits ermite creates. | |
| 44 | + | DefaultCommitterName = "ermite" | |
| 39 | 45 | ) | |
| 40 | 46 | ||
| 41 | 47 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | |
| @@ -62,6 +68,10 @@ type Config struct { | |||
| 62 | 68 | LogLevel string | |
| 63 | 69 | // LogFormat is json or console. | |
| 64 | 70 | LogFormat string | |
| 71 | + | // CommitterName and CommitterEmail are recorded as the committer of the | |
| 72 | + | // commits ermite creates. The email defaults to "ermite@<host of | |
| 73 | + | // PublicURL>". | |
| 74 | + | CommitterName, CommitterEmail string | |
| 65 | 75 | } | |
| 66 | 76 | ||
| 67 | 77 | // Error lists every configuration problem found, in variable order. | |
| @@ -167,12 +177,46 @@ func Load(get func(string) string) (Config, error) { | |||
| 167 | 177 | bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat)) | |
| 168 | 178 | } | |
| 169 | 179 | ||
| 180 | + | c.CommitterName = strings.TrimSpace(get(EnvCommitterName)) | |
| 181 | + | if c.CommitterName == "" { | |
| 182 | + | c.CommitterName = DefaultCommitterName | |
| 183 | + | } else if err := validateIdentity(c.CommitterName); err != nil { | |
| 184 | + | bad(EnvCommitterName, "%v", err) | |
| 185 | + | } | |
| 186 | + | c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail)) | |
| 187 | + | switch { | |
| 188 | + | case c.CommitterEmail != "": | |
| 189 | + | if err := validateIdentity(c.CommitterEmail); err != nil { | |
| 190 | + | bad(EnvCommitterEmail, "%v", err) | |
| 191 | + | } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") { | |
| 192 | + | bad(EnvCommitterEmail, "must be an email address such as ermite@example.org") | |
| 193 | + | } | |
| 194 | + | case publicOK: | |
| 195 | + | u, _ := url.Parse(c.PublicURL) | |
| 196 | + | c.CommitterEmail = "ermite@" + u.Hostname() | |
| 197 | + | } | |
| 198 | + | ||
| 170 | 199 | if len(problems) > 0 { | |
| 171 | 200 | return Config{}, &Error{Problems: problems} | |
| 172 | 201 | } | |
| 173 | 202 | return c, nil | |
| 174 | 203 | } | |
| 175 | 204 | ||
| 205 | + | // validateIdentity accepts a name or an email address for a Git identity: valid | |
| 206 | + | // UTF-8 without control characters or the angle brackets and line breaks that | |
| 207 | + | // Git does not allow in an identity. | |
| 208 | + | func validateIdentity(s string) error { | |
| 209 | + | if !utf8.ValidString(s) { | |
| 210 | + | return fmt.Errorf("must be valid UTF-8") | |
| 211 | + | } | |
| 212 | + | for _, r := range s { | |
| 213 | + | if r < 0x20 || r == 0x7f || r == '<' || r == '>' { | |
| 214 | + | return fmt.Errorf("must not contain control characters, '<' or '>'") | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | ||
| 176 | 220 | func validateAddr(addr string) error { | |
| 177 | 221 | _, port, err := net.SplitHostPort(addr) | |
| 178 | 222 | if err != nil { | |
| @@ -28,6 +28,10 @@ const ( | |||||
| 28 | EnvSourceURL = "ERMITE_SOURCE_URL" | 28 | EnvSourceURL = "ERMITE_SOURCE_URL" | ||
| 29 | EnvLogLevel = "ERMITE_LOG_LEVEL" | 29 | EnvLogLevel = "ERMITE_LOG_LEVEL" | ||
| 30 | EnvLogFormat = "ERMITE_LOG_FORMAT" | 30 | EnvLogFormat = "ERMITE_LOG_FORMAT" | ||
| 31 | + | // EnvCommitterName and EnvCommitterEmail name the committer of the | |||
| 32 | + | // commits ermite creates (squash merges, ADR 0022). | |||
| 33 | + | EnvCommitterName = "ERMITE_COMMITTER_NAME" | |||
| 34 | + | EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL" | |||
| 31 | ) | 35 | ) | ||
| 32 | 36 | ||||
| 33 | // Defaults. | 37 | // Defaults. | ||
| @@ -36,6 +40,8 @@ const ( | |||||
| 36 | DefaultGit = "git" | 40 | DefaultGit = "git" | ||
| 37 | DefaultLogLevel = "info" | 41 | DefaultLogLevel = "info" | ||
| 38 | DefaultLogFormat = "json" | 42 | DefaultLogFormat = "json" | ||
| 43 | + | // DefaultCommitterName is the committer name of the commits ermite creates. | |||
| 44 | + | DefaultCommitterName = "ermite" | |||
| 39 | ) | 45 | ) | ||
| 40 | 46 | ||||
| 41 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | 47 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | ||
| @@ -62,6 +68,10 @@ type Config struct { | |||||
| 62 | LogLevel string | 68 | LogLevel string | ||
| 63 | // LogFormat is json or console. | 69 | // LogFormat is json or console. | ||
| 64 | LogFormat string | 70 | LogFormat string | ||
| 71 | + | // CommitterName and CommitterEmail are recorded as the committer of the | |||
| 72 | + | // commits ermite creates. The email defaults to "ermite@<host of | |||
| 73 | + | // PublicURL>". | |||
| 74 | + | CommitterName, CommitterEmail string | |||
| 65 | } | 75 | } | ||
| 66 | 76 | ||||
| 67 | // Error lists every configuration problem found, in variable order. | 77 | // Error lists every configuration problem found, in variable order. | ||
| @@ -167,12 +177,46 @@ func Load(get func(string) string) (Config, error) { | |||||
| 167 | bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat)) | 177 | bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat)) | ||
| 168 | } | 178 | } | ||
| 169 | 179 | ||||
| 180 | + | c.CommitterName = strings.TrimSpace(get(EnvCommitterName)) | |||
| 181 | + | if c.CommitterName == "" { | |||
| 182 | + | c.CommitterName = DefaultCommitterName | |||
| 183 | + | } else if err := validateIdentity(c.CommitterName); err != nil { | |||
| 184 | + | bad(EnvCommitterName, "%v", err) | |||
| 185 | + | } | |||
| 186 | + | c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail)) | |||
| 187 | + | switch { | |||
| 188 | + | case c.CommitterEmail != "": | |||
| 189 | + | if err := validateIdentity(c.CommitterEmail); err != nil { | |||
| 190 | + | bad(EnvCommitterEmail, "%v", err) | |||
| 191 | + | } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") { | |||
| 192 | + | bad(EnvCommitterEmail, "must be an email address such as ermite@example.org") | |||
| 193 | + | } | |||
| 194 | + | case publicOK: | |||
| 195 | + | u, _ := url.Parse(c.PublicURL) | |||
| 196 | + | c.CommitterEmail = "ermite@" + u.Hostname() | |||
| 197 | + | } | |||
| 198 | + | ||||
| 170 | if len(problems) > 0 { | 199 | if len(problems) > 0 { | ||
| 171 | return Config{}, &Error{Problems: problems} | 200 | return Config{}, &Error{Problems: problems} | ||
| 172 | } | 201 | } | ||
| 173 | return c, nil | 202 | return c, nil | ||
| 174 | } | 203 | } | ||
| 175 | 204 | ||||
| 205 | + | // validateIdentity accepts a name or an email address for a Git identity: valid | |||
| 206 | + | // UTF-8 without control characters or the angle brackets and line breaks that | |||
| 207 | + | // Git does not allow in an identity. | |||
| 208 | + | func validateIdentity(s string) error { | |||
| 209 | + | if !utf8.ValidString(s) { | |||
| 210 | + | return fmt.Errorf("must be valid UTF-8") | |||
| 211 | + | } | |||
| 212 | + | for _, r := range s { | |||
| 213 | + | if r < 0x20 || r == 0x7f || r == '<' || r == '>' { | |||
| 214 | + | return fmt.Errorf("must not contain control characters, '<' or '>'") | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 176 | func validateAddr(addr string) error { | 220 | func validateAddr(addr string) error { | ||
| 177 | _, port, err := net.SplitHostPort(addr) | 221 | _, port, err := net.SplitHostPort(addr) | ||
| 178 | if err != nil { | 222 | if err != nil { | ||
M internal/config/config_test.go +39 -0
| @@ -153,3 +153,42 @@ func TestTokenLengthCountsCharacters(t *testing.T) { | |||
| 153 | 153 | _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)}))) | |
| 154 | 154 | assert.Error(t, err) | |
| 155 | 155 | } | |
| 156 | + | ||
| 157 | + | func TestLoadCommitterDefaults(t *testing.T) { | |
| 158 | + | c, err := config.Load(lookup(valid(nil))) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | assert.Equal(t, "ermite", c.CommitterName) | |
| 161 | + | assert.Equal(t, "ermite@git.example.com", c.CommitterEmail, "the host of the public URL, without port or path") | |
| 162 | + | ||
| 163 | + | c, err = config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://localhost:7341/"}))) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | assert.Equal(t, "ermite@localhost", c.CommitterEmail) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestLoadCommitterFromTheEnvironment(t *testing.T) { | |
| 169 | + | c, err := config.Load(lookup(valid(map[string]string{ | |
| 170 | + | config.EnvCommitterName: " Forge Bot ", | |
| 171 | + | config.EnvCommitterEmail: "bot@example.org", | |
| 172 | + | }))) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | assert.Equal(t, "Forge Bot", c.CommitterName) | |
| 175 | + | assert.Equal(t, "bot@example.org", c.CommitterEmail) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestLoadRejectsBadCommitter(t *testing.T) { | |
| 179 | + | for name, env := range map[string]map[string]string{ | |
| 180 | + | "name with an angle bracket": {config.EnvCommitterName: "Bot <x>"}, | |
| 181 | + | "name with a line break": {config.EnvCommitterName: "Bot\nCo-authored-by: x"}, | |
| 182 | + | "name not utf-8": {config.EnvCommitterName: "\xff"}, | |
| 183 | + | "email without an @": {config.EnvCommitterEmail: "bot"}, | |
| 184 | + | "email with a space": {config.EnvCommitterEmail: "a b@example.org"}, | |
| 185 | + | "email with an angle bracket": {config.EnvCommitterEmail: "<bot@example.org>"}, | |
| 186 | + | } { | |
| 187 | + | t.Run(name, func(t *testing.T) { | |
| 188 | + | _, err := config.Load(lookup(valid(env))) | |
| 189 | + | var ce *config.Error | |
| 190 | + | require.ErrorAs(t, err, &ce) | |
| 191 | + | assert.Contains(t, ce.Problems[0], "ERMITE_COMMITTER_") | |
| 192 | + | }) | |
| 193 | + | } | |
| 194 | + | } | |
| @@ -153,3 +153,42 @@ func TestTokenLengthCountsCharacters(t *testing.T) { | |||||
| 153 | _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)}))) | 153 | _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)}))) | ||
| 154 | assert.Error(t, err) | 154 | assert.Error(t, err) | ||
| 155 | } | 155 | } | ||
| 156 | + | ||||
| 157 | + | func TestLoadCommitterDefaults(t *testing.T) { | |||
| 158 | + | c, err := config.Load(lookup(valid(nil))) | |||
| 159 | + | require.NoError(t, err) | |||
| 160 | + | assert.Equal(t, "ermite", c.CommitterName) | |||
| 161 | + | assert.Equal(t, "ermite@git.example.com", c.CommitterEmail, "the host of the public URL, without port or path") | |||
| 162 | + | ||||
| 163 | + | c, err = config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://localhost:7341/"}))) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | assert.Equal(t, "ermite@localhost", c.CommitterEmail) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestLoadCommitterFromTheEnvironment(t *testing.T) { | |||
| 169 | + | c, err := config.Load(lookup(valid(map[string]string{ | |||
| 170 | + | config.EnvCommitterName: " Forge Bot ", | |||
| 171 | + | config.EnvCommitterEmail: "bot@example.org", | |||
| 172 | + | }))) | |||
| 173 | + | require.NoError(t, err) | |||
| 174 | + | assert.Equal(t, "Forge Bot", c.CommitterName) | |||
| 175 | + | assert.Equal(t, "bot@example.org", c.CommitterEmail) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestLoadRejectsBadCommitter(t *testing.T) { | |||
| 179 | + | for name, env := range map[string]map[string]string{ | |||
| 180 | + | "name with an angle bracket": {config.EnvCommitterName: "Bot <x>"}, | |||
| 181 | + | "name with a line break": {config.EnvCommitterName: "Bot\nCo-authored-by: x"}, | |||
| 182 | + | "name not utf-8": {config.EnvCommitterName: "\xff"}, | |||
| 183 | + | "email without an @": {config.EnvCommitterEmail: "bot"}, | |||
| 184 | + | "email with a space": {config.EnvCommitterEmail: "a b@example.org"}, | |||
| 185 | + | "email with an angle bracket": {config.EnvCommitterEmail: "<bot@example.org>"}, | |||
| 186 | + | } { | |||
| 187 | + | t.Run(name, func(t *testing.T) { | |||
| 188 | + | _, err := config.Load(lookup(valid(env))) | |||
| 189 | + | var ce *config.Error | |||
| 190 | + | require.ErrorAs(t, err, &ce) | |||
| 191 | + | assert.Contains(t, ce.Problems[0], "ERMITE_COMMITTER_") | |||
| 192 | + | }) | |||
| 193 | + | } | |||
| 194 | + | } | |||