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
7 changes: 7 additions & 0 deletions .changeset/ten-lights-heal.md
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 43 additions & 0 deletions packages/commithelper-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,49 @@ This is Basic rule of `.commithelperrc.json`.
}
```

#### extends

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",
"rules": {
"my-feature": "my-org/my-repo"
}
}
```

**Merge strategy** (local always wins on conflict):

| Field | How merged |
| ------------- | ---------- |
| `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:**
- 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)

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):
Expand Down
110 changes: 110 additions & 0 deletions packages/commithelper-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -218,9 +221,116 @@ func loadConfig() Config {
os.Exit(1)
}

if config.Extends != nil && *config.Extends != "" {
baseConfig, err := loadExtendsConfig(*config.Extends)
if err != nil {
fmt.Printf("Error loading extends config: %v\n", err)
os.Exit(1)
}
config = mergeConfigs(baseConfig, config)
}

return config
}

// 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 {
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
}

// 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
// - 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 == "" {
Expand Down
Loading
Loading