From c35caa224fb7fab9f828d516b3c3af8aeed553c6 Mon Sep 17 00:00:00 2001 From: kyungmi-k Date: Fri, 3 Jul 2026 12:17:06 +0900 Subject: [PATCH 1/4] feat(commithelper-go): add extends support for remote config inheritance - Add Extends field to Config struct - Add fetchExtendsConfig: fetches and parses a remote .commithelperrc.json via HTTP/HTTPS (10s timeout, non-http URLs rejected) - Add mergeConfigs: base (remote) + local merge strategy - rules: base is default, local overrides on key conflict - protect: union of base + local (deduped) - passthrough: union of base + local (deduped) - template: local wins, falls back to base - Update loadConfig to apply extends after local parse - Add extends property to schema.json - Add tests: TestMergeConfigs_*, TestFetchExtendsConfig, TestProcessMessage_WithExtends (using net/http/httptest) Closes #82 --- packages/commithelper-go/main.go | 91 +++++++++ packages/commithelper-go/main_test.go | 278 ++++++++++++++++++++++++++ packages/commithelper-go/schema.json | 5 + 3 files changed, 374 insertions(+) diff --git a/packages/commithelper-go/main.go b/packages/commithelper-go/main.go index d8fa2a3..a026ac1 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -5,15 +5,18 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "os" "os/exec" "path" "regexp" "strings" "text/template" + "time" ) type Config struct { + Extends *string `json:"extends,omitempty"` Rules map[string]*string `json:"rules"` Passthrough []string `json:"passthrough"` Protect []string `json:"protect"` @@ -218,9 +221,97 @@ func loadConfig() Config { os.Exit(1) } + if config.Extends != nil && *config.Extends != "" { + baseConfig, err := fetchExtendsConfig(*config.Extends) + if err != nil { + fmt.Printf("Error loading extends config: %v\n", err) + os.Exit(1) + } + config = mergeConfigs(baseConfig, config) + } + return config } +// fetchExtendsConfig fetches and parses a remote .commithelperrc.json from url. +// Only http:// and https:// URLs are accepted. +func fetchExtendsConfig(url string) (Config, error) { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return Config{}, fmt.Errorf("extends must be an http/https URL, got: %s", url) + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return Config{}, fmt.Errorf("failed to fetch extends config from %q: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Config{}, fmt.Errorf("failed to fetch extends config from %q: HTTP %s", url, resp.Status) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Config{}, fmt.Errorf("failed to read extends config from %q: %w", url, err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("failed to parse extends config from %q: %w", url, err) + } + return cfg, nil +} + +// mergeConfigs merges base (remote extends) and local configs. +// Merge strategy: +// - rules: base is the default; local overrides on key conflict +// - protect: union of base + local (deduped) +// - passthrough: union of base + local (deduped) +// - template: local wins; falls back to base if local is unset +func mergeConfigs(base, local Config) Config { + merged := Config{ + Rules: make(map[string]*string), + Template: local.Template, + } + if merged.Template == nil { + merged.Template = base.Template + } + + for k, v := range base.Rules { + merged.Rules[k] = v + } + for k, v := range local.Rules { + merged.Rules[k] = v + } + + seen := make(map[string]bool) + for _, p := range base.Protect { + if !seen[p] { + merged.Protect = append(merged.Protect, p) + seen[p] = true + } + } + for _, p := range local.Protect { + if !seen[p] { + merged.Protect = append(merged.Protect, p) + seen[p] = true + } + } + + seenPT := make(map[string]bool) + for _, p := range base.Passthrough { + if !seenPT[p] { + merged.Passthrough = append(merged.Passthrough, p) + seenPT[p] = true + } + } + for _, p := range local.Passthrough { + if !seenPT[p] { + merged.Passthrough = append(merged.Passthrough, p) + seenPT[p] = true + } + } + + return merged +} + func applyTemplate(config Config, data *TemplateData) string { // If no template is configured, use default format if config.Template == nil || *config.Template == "" { diff --git a/packages/commithelper-go/main_test.go b/packages/commithelper-go/main_test.go index ef105f0..b9c5ce0 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "net/http" + "net/http/httptest" "testing" ) @@ -286,3 +289,278 @@ func TestProcessMessage(t *testing.T) { }) } } + +// ── extends: mergeConfigs ───────────────────────────────────────────────────── + +func TestMergeConfigs_Rules(t *testing.T) { + t.Run("local overrides base on same key", func(t *testing.T) { + base := Config{Rules: map[string]*string{"feature": strPtr("base/repo")}} + local := Config{Rules: map[string]*string{"feature": strPtr("local/repo")}} + merged := mergeConfigs(base, local) + if got := *merged.Rules["feature"]; got != "local/repo" { + t.Errorf("got %q, want %q", got, "local/repo") + } + }) + + t.Run("local null overrides base non-null", func(t *testing.T) { + base := Config{Rules: map[string]*string{"feature": strPtr("base/repo")}} + local := Config{Rules: map[string]*string{"feature": nil}} + merged := mergeConfigs(base, local) + if merged.Rules["feature"] != nil { + t.Errorf("expected nil, got %q", *merged.Rules["feature"]) + } + }) + + t.Run("base rules preserved when not overridden", func(t *testing.T) { + base := Config{Rules: map[string]*string{"plan": strPtr("org/plan"), "qa": strPtr("org/qa")}} + local := Config{Rules: map[string]*string{"repo": nil}} + merged := mergeConfigs(base, local) + if _, ok := merged.Rules["plan"]; !ok { + t.Error("expected plan rule from base") + } + if _, ok := merged.Rules["qa"]; !ok { + t.Error("expected qa rule from base") + } + if _, ok := merged.Rules["repo"]; !ok { + t.Error("expected repo rule from local") + } + }) + + t.Run("nil local rules treated as empty", func(t *testing.T) { + base := Config{Rules: map[string]*string{"plan": strPtr("org/plan")}} + local := Config{} + merged := mergeConfigs(base, local) + if _, ok := merged.Rules["plan"]; !ok { + t.Error("expected plan rule from base") + } + }) +} + +func TestMergeConfigs_Protect(t *testing.T) { + t.Run("union of base and local", func(t *testing.T) { + base := Config{Protect: []string{"main", "master"}} + local := Config{Protect: []string{"release/*"}} + merged := mergeConfigs(base, local) + want := map[string]bool{"main": true, "master": true, "release/*": true} + if len(merged.Protect) != 3 { + t.Errorf("got %d protect entries, want 3: %v", len(merged.Protect), merged.Protect) + } + for _, p := range merged.Protect { + if !want[p] { + t.Errorf("unexpected protect entry %q", p) + } + } + }) + + t.Run("deduplicates overlapping entries", func(t *testing.T) { + base := Config{Protect: []string{"main", "master"}} + local := Config{Protect: []string{"main", "release/*"}} + merged := mergeConfigs(base, local) + count := 0 + for _, p := range merged.Protect { + if p == "main" { + count++ + } + } + if count != 1 { + t.Errorf("main appears %d times, want 1", count) + } + }) + + t.Run("base-only protect preserved", func(t *testing.T) { + base := Config{Protect: []string{"main"}} + local := Config{} + merged := mergeConfigs(base, local) + if len(merged.Protect) != 1 || merged.Protect[0] != "main" { + t.Errorf("got %v, want [main]", merged.Protect) + } + }) +} + +func TestMergeConfigs_Passthrough(t *testing.T) { + t.Run("union of base and local", func(t *testing.T) { + base := Config{Passthrough: []string{"PROJ", "OPS"}} + local := Config{Passthrough: []string{"FEAT"}} + merged := mergeConfigs(base, local) + want := map[string]bool{"PROJ": true, "OPS": true, "FEAT": true} + if len(merged.Passthrough) != 3 { + t.Errorf("got %d passthrough entries, want 3: %v", len(merged.Passthrough), merged.Passthrough) + } + for _, p := range merged.Passthrough { + if !want[p] { + t.Errorf("unexpected passthrough entry %q", p) + } + } + }) + + t.Run("deduplicates overlapping entries", func(t *testing.T) { + base := Config{Passthrough: []string{"PROJ"}} + local := Config{Passthrough: []string{"PROJ", "OPS"}} + merged := mergeConfigs(base, local) + count := 0 + for _, p := range merged.Passthrough { + if p == "PROJ" { + count++ + } + } + if count != 1 { + t.Errorf("PROJ appears %d times, want 1", count) + } + }) +} + +func TestMergeConfigs_Template(t *testing.T) { + t.Run("local template wins", func(t *testing.T) { + base := Config{Template: strPtr("base: {{.Message}}")} + local := Config{Template: strPtr("local: {{.Message}}")} + merged := mergeConfigs(base, local) + if *merged.Template != "local: {{.Message}}" { + t.Errorf("got %q, want local template", *merged.Template) + } + }) + + t.Run("base template used when local is nil", func(t *testing.T) { + base := Config{Template: strPtr("base: {{.Message}}")} + local := Config{} + merged := mergeConfigs(base, local) + if merged.Template == nil || *merged.Template != "base: {{.Message}}" { + t.Errorf("expected base template, got %v", merged.Template) + } + }) + + t.Run("both nil stays nil", func(t *testing.T) { + merged := mergeConfigs(Config{}, Config{}) + if merged.Template != nil { + t.Errorf("expected nil template, got %q", *merged.Template) + } + }) +} + +// ── extends: fetchExtendsConfig ─────────────────────────────────────────────── + +func TestFetchExtendsConfig(t *testing.T) { + t.Run("valid remote config is fetched and parsed", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "protect": ["main", "master"], + "rules": {"plan": "org/plan", "qa": "org/qa"}, + "passthrough": ["PROJ"] + }`) + })) + defer srv.Close() + + cfg, err := fetchExtendsConfig(srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Protect) != 2 { + t.Errorf("protect: got %v, want [main master]", cfg.Protect) + } + if repo, ok := cfg.Rules["plan"]; !ok || *repo != "org/plan" { + t.Errorf("rules[plan]: got %v", cfg.Rules["plan"]) + } + if len(cfg.Passthrough) != 1 || cfg.Passthrough[0] != "PROJ" { + t.Errorf("passthrough: got %v, want [PROJ]", cfg.Passthrough) + } + }) + + t.Run("non-http URL is rejected", func(t *testing.T) { + _, err := fetchExtendsConfig("file:///etc/passwd") + if err == nil { + t.Error("expected error for non-http URL, got nil") + } + }) + + t.Run("HTTP error status is rejected", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetchExtendsConfig(srv.URL) + if err == nil { + t.Error("expected error for 404, got nil") + } + }) + + t.Run("invalid JSON is rejected", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `not json`) + })) + defer srv.Close() + + _, err := fetchExtendsConfig(srv.URL) + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } + }) + + t.Run("extends field in remote config is not followed (no recursion)", func(t *testing.T) { + // Remote config has its own "extends" — it must be ignored. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"extends":"http://should-not-be-called","rules":{"plan":"org/plan"}}`) + })) + defer srv.Close() + + cfg, err := fetchExtendsConfig(srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Just verify the config was parsed; the nested extends is not followed + if _, ok := cfg.Rules["plan"]; !ok { + t.Error("expected plan rule from remote") + } + }) +} + +// ── extends: end-to-end merge via processMessage ───────────────────────────── + +func TestProcessMessage_WithExtends(t *testing.T) { + // Simulate what loadConfig produces after merging a remote base config. + // Base (remote): plan → org/plan, protect: main + // Local: qa → org/qa, protect: release/* + // Merged result should have all of them. + merged := mergeConfigs( + Config{ + Rules: map[string]*string{"plan": strPtr("org/plan")}, + Protect: []string{"main"}, + }, + Config{ + Rules: map[string]*string{"qa": strPtr("org/qa")}, + Protect: []string{"release/*"}, + }, + ) + + tests := []struct { + name string + message string + branch string + want string + wantError bool + }{ + {"base rule still works", "fix", "plan/42", "[org/plan#42] fix", false}, + {"local rule works", "fix", "qa/99", "[org/qa#99] fix", false}, + {"base protect blocks", "fix", "main", "", true}, + {"local protect blocks", "fix", "release/1.0", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := processMessage(tt.message, tt.branch, merged) + if tt.wantError { + if err == nil { + t.Fatalf("expected error, got nil (result %q)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/packages/commithelper-go/schema.json b/packages/commithelper-go/schema.json index 615a4f3..03fc77e 100644 --- a/packages/commithelper-go/schema.json +++ b/packages/commithelper-go/schema.json @@ -10,6 +10,11 @@ "type": "string", "description": "Path or URL to this schema (enables editor autocompletion)." }, + "extends": { + "type": "string", + "pattern": "^https?://", + "description": "URL of a remote .commithelperrc.json to use as the base config. Must be an http:// or https:// URL. The remote config is fetched and merged with the local config: local values override remote on conflict. Nested extends in the remote config are not followed." + }, "rules": { "type": "object", "description": "GitHub-style branch prefixes. The key is a branch prefix, matched as `/`; the value is the repo to tag (`repo#number`), or null to tag the current repo (`#number`).", From d2714c59f5e034f2dd36291b879bb7ec1b223063 Mon Sep 17 00:00:00 2001 From: Koong Kyungmi Date: Fri, 3 Jul 2026 12:37:43 +0900 Subject: [PATCH 2/4] Add extends support for remote config inheritance Added support for remote config inheritance in commithelper-go. --- .changeset/ten-lights-heal.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/ten-lights-heal.md diff --git a/.changeset/ten-lights-heal.md b/.changeset/ten-lights-heal.md new file mode 100644 index 0000000..67b81c7 --- /dev/null +++ b/.changeset/ten-lights-heal.md @@ -0,0 +1,7 @@ +--- +"@naverpay/commithelper-go": minor +--- + +feat(commithelper-go): add extends support for remote config inheritance + +PR: [feat(commithelper-go): add extends support for remote config inheritance](https://github.com/NaverPayDev/cli/pull/83) From d9e0a03bf3f619444555a6d63c654cdd9547656b Mon Sep 17 00:00:00 2001 From: kyungmi-k Date: Fri, 3 Jul 2026 12:38:30 +0900 Subject: [PATCH 3/4] docs(commithelper-go): document extends field --- packages/commithelper-go/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index 8124427..5daf373 100644 --- a/packages/commithelper-go/README.md +++ b/packages/commithelper-go/README.md @@ -103,6 +103,34 @@ This is Basic rule of `.commithelperrc.json`. } ``` +#### extends + +Inherit a shared base config from a remote URL. The remote config is fetched once at commit time and merged with the local config — useful for teams managing many repositories with a common set of rules. + +```json +{ + "extends": "https://raw.githubusercontent.com/my-org/.github/main/.commithelperrc.json", + "protect": ["main"], + "rules": { + "repo": null + } +} +``` + +**Merge strategy** (local always wins on conflict): + +| Field | How merged | +| ------------- | ---------- | +| `rules` | remote is the base; local key overrides on conflict | +| `protect` | union of remote + local (deduped) | +| `passthrough` | union of remote + local (deduped) | +| `template` | local wins; falls back to remote if local is unset | + +**Constraints:** +- Only `http://` and `https://` URLs are accepted. +- The remote config's own `extends` field is **ignored** — no recursive fetching. +- Fetch failure is a fatal error (exits with code 1). + #### $schema (optional — editor autocompletion) Add a `$schema` reference so your editor offers autocompletion, inline docs, and validation while editing `.commithelperrc.json`. The schema ships inside the installed package, so a relative path works offline (no CDN, Nexus-friendly): From 79f0831a8fba207fd6b56b05dcb407bca84aa36a Mon Sep 17 00:00:00 2001 From: kyungmi-k Date: Fri, 3 Jul 2026 13:08:05 +0900 Subject: [PATCH 4/4] feat(commithelper-go): support local file path in extends --- packages/commithelper-go/README.md | 35 ++++++++---- packages/commithelper-go/main.go | 31 ++++++++-- packages/commithelper-go/main_test.go | 81 +++++++++++++++++++++++++++ packages/commithelper-go/schema.json | 3 +- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index 5daf373..b162ac5 100644 --- a/packages/commithelper-go/README.md +++ b/packages/commithelper-go/README.md @@ -105,14 +105,28 @@ This is Basic rule of `.commithelperrc.json`. #### extends -Inherit a shared base config from a remote URL. The remote config is fetched once at commit time and merged with the local config — useful for teams managing many repositories with a common set of rules. +Inherit a shared base config. Accepts an **HTTP/HTTPS URL** or a **local file path** — useful for teams managing many repositories with a common set of rules. + +**Local file path (recommended for private registries):** + +```json +{ + "extends": "./node_modules/@my-org/commithelperrc/.commithelperrc.json", + "rules": { + "my-feature": "my-org/my-repo" + } +} +``` + +Install the shared config as a dev dependency and reference it via a relative path. Works offline, versioned through `package.json`, no auth required. + +**Remote URL:** ```json { "extends": "https://raw.githubusercontent.com/my-org/.github/main/.commithelperrc.json", - "protect": ["main"], "rules": { - "repo": null + "my-feature": "my-org/my-repo" } } ``` @@ -121,15 +135,16 @@ Inherit a shared base config from a remote URL. The remote config is fetched onc | Field | How merged | | ------------- | ---------- | -| `rules` | remote is the base; local key overrides on conflict | -| `protect` | union of remote + local (deduped) | -| `passthrough` | union of remote + local (deduped) | -| `template` | local wins; falls back to remote if local is unset | +| `rules` | base is the default; local key overrides on conflict | +| `protect` | union of base + local (deduped) | +| `passthrough` | union of base + local (deduped) | +| `template` | local wins; falls back to base if local is unset | **Constraints:** -- Only `http://` and `https://` URLs are accepted. -- The remote config's own `extends` field is **ignored** — no recursive fetching. -- Fetch failure is a fatal error (exits with code 1). +- Local path: relative paths are resolved from the current working directory (repo root). +- Remote URL: only `http://` and `https://` are accepted. 10-second timeout. +- The base config's own `extends` field is **ignored** — no recursive loading. +- Load failure is a fatal error (exits with code 1). #### $schema (optional — editor autocompletion) diff --git a/packages/commithelper-go/main.go b/packages/commithelper-go/main.go index a026ac1..0423c02 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -222,7 +222,7 @@ func loadConfig() Config { } if config.Extends != nil && *config.Extends != "" { - baseConfig, err := fetchExtendsConfig(*config.Extends) + baseConfig, err := loadExtendsConfig(*config.Extends) if err != nil { fmt.Printf("Error loading extends config: %v\n", err) os.Exit(1) @@ -233,12 +233,17 @@ func loadConfig() Config { return config } -// fetchExtendsConfig fetches and parses a remote .commithelperrc.json from url. -// Only http:// and https:// URLs are accepted. -func fetchExtendsConfig(url string) (Config, error) { - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - return Config{}, fmt.Errorf("extends must be an http/https URL, got: %s", url) +// loadExtendsConfig dispatches to fetchExtendsConfig (HTTP/HTTPS) or +// readLocalConfig (local file path) based on the extends value. +func loadExtendsConfig(extends string) (Config, error) { + if strings.HasPrefix(extends, "http://") || strings.HasPrefix(extends, "https://") { + return fetchExtendsConfig(extends) } + return readLocalConfig(extends) +} + +// fetchExtendsConfig fetches and parses a remote .commithelperrc.json via HTTP/HTTPS. +func fetchExtendsConfig(url string) (Config, error) { client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(url) if err != nil { @@ -259,6 +264,20 @@ func fetchExtendsConfig(url string) (Config, error) { return cfg, nil } +// readLocalConfig reads and parses a .commithelperrc.json from a local file path. +// Relative paths are resolved from the current working directory. +func readLocalConfig(filePath string) (Config, error) { + data, err := ioutil.ReadFile(filePath) + if err != nil { + return Config{}, fmt.Errorf("failed to read extends config from %q: %w", filePath, err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("failed to parse extends config from %q: %w", filePath, err) + } + return cfg, nil +} + // mergeConfigs merges base (remote extends) and local configs. // Merge strategy: // - rules: base is the default; local overrides on key conflict diff --git a/packages/commithelper-go/main_test.go b/packages/commithelper-go/main_test.go index b9c5ce0..5bafe53 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) @@ -514,6 +516,85 @@ func TestFetchExtendsConfig(t *testing.T) { }) } +// ── extends: readLocalConfig ────────────────────────────────────────────────── + +func TestReadLocalConfig(t *testing.T) { + t.Run("valid local file is read and parsed", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".commithelperrc.json") + content := `{"protect":["main"],"rules":{"plan":"org/plan"},"passthrough":["PROJ"]}` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + cfg, err := readLocalConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Protect) != 1 || cfg.Protect[0] != "main" { + t.Errorf("protect: got %v, want [main]", cfg.Protect) + } + if repo, ok := cfg.Rules["plan"]; !ok || *repo != "org/plan" { + t.Errorf("rules[plan]: unexpected %v", cfg.Rules["plan"]) + } + if len(cfg.Passthrough) != 1 || cfg.Passthrough[0] != "PROJ" { + t.Errorf("passthrough: got %v, want [PROJ]", cfg.Passthrough) + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := readLocalConfig("/nonexistent/path/.commithelperrc.json") + if err == nil { + t.Error("expected error for missing file, got nil") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".commithelperrc.json") + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + _, err := readLocalConfig(path) + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } + }) +} + +func TestLoadExtendsConfig(t *testing.T) { + t.Run("http URL dispatches to fetchExtendsConfig", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"rules":{"plan":"org/plan"}}`) + })) + defer srv.Close() + + cfg, err := loadExtendsConfig(srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.Rules["plan"]; !ok { + t.Error("expected plan rule from http extends") + } + }) + + t.Run("local path dispatches to readLocalConfig", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".commithelperrc.json") + if err := os.WriteFile(path, []byte(`{"rules":{"qa":"org/qa"}}`), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + cfg, err := loadExtendsConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.Rules["qa"]; !ok { + t.Error("expected qa rule from local extends") + } + }) +} + // ── extends: end-to-end merge via processMessage ───────────────────────────── func TestProcessMessage_WithExtends(t *testing.T) { diff --git a/packages/commithelper-go/schema.json b/packages/commithelper-go/schema.json index 03fc77e..6baec35 100644 --- a/packages/commithelper-go/schema.json +++ b/packages/commithelper-go/schema.json @@ -12,8 +12,7 @@ }, "extends": { "type": "string", - "pattern": "^https?://", - "description": "URL of a remote .commithelperrc.json to use as the base config. Must be an http:// or https:// URL. The remote config is fetched and merged with the local config: local values override remote on conflict. Nested extends in the remote config are not followed." + "description": "Base config to inherit from. Accepts an http:// or https:// URL, or a local file path (e.g. ./node_modules/@my-org/commithelperrc/.commithelperrc.json). The base config is merged with the local config: local values override base on conflict. Nested extends in the base config are not followed." }, "rules": { "type": "object",