< Summary - go-semantic-release Coverage

Line coverage
88%
Covered lines: 226
Uncovered lines: 30
Coverable lines: 256
Total lines: 440
Line coverage: 88.2%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/config/decode_hook.go

#LineLine coverage
 1package config
 2
 3import (
 4  "reflect"
 5
 6  "github.com/go-viper/mapstructure/v2"
 7
 8  "github.com/jedi-knights/go-semantic-release/internal/domain"
 9)
 10
 11// StringToGitHubAssetHookFunc returns a mapstructure DecodeHookFuncType that
 12// converts a plain string into a domain.GitHubAsset with only the Path set.
 13// This enables backward-compatible YAML where assets can be listed as bare
 14// glob strings alongside the new structured {path, label} form.
 2415func StringToGitHubAssetHookFunc() mapstructure.DecodeHookFuncType {
 2416  assetType := reflect.TypeOf(domain.GitHubAsset{})
 2417  return func(f reflect.Type, t reflect.Type, data any) (any, error) {
 7918    if t != assetType {
 7919      return data, nil
 7920    }
 621    s, ok := data.(string)
 322    if !ok {
 323      return data, nil
 324    }
 325    return domain.GitHubAsset{Path: s}, nil
 26  }
 27}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/config/merge.go

#LineLine coverage
 1package config
 2
 3import "github.com/jedi-knights/go-semantic-release/internal/domain"
 4
 5// MergeConfigs merges a parent config into a base config.
 6// Base values take precedence over parent (child overrides parent).
 77func MergeConfigs(base, parent domain.Config) domain.Config {
 78  if base.ReleaseMode == "" {
 79    base.ReleaseMode = parent.ReleaseMode
 710  }
 411  if base.TagFormat == "" {
 412    base.TagFormat = parent.TagFormat
 413  }
 714  if base.ProjectTagFormat == "" {
 715    base.ProjectTagFormat = parent.ProjectTagFormat
 716  }
 717  if base.RepositoryURL == "" {
 718    base.RepositoryURL = parent.RepositoryURL
 719  }
 720  if base.GitBackend == "" {
 721    base.GitBackend = parent.GitBackend
 722  }
 723  if base.ChangelogTemplate == "" {
 724    base.ChangelogTemplate = parent.ChangelogTemplate
 725  }
 26
 727  if len(base.Branches) == 0 {
 728    base.Branches = parent.Branches
 729  }
 730  if len(base.CommitTypes) == 0 {
 731    base.CommitTypes = parent.CommitTypes
 732  }
 733  if len(base.Projects) == 0 {
 734    base.Projects = parent.Projects
 735  }
 736  if len(base.IncludePaths) == 0 {
 737    base.IncludePaths = parent.IncludePaths
 738  }
 739  if len(base.ExcludePaths) == 0 {
 740    base.ExcludePaths = parent.ExcludePaths
 741  }
 742  if len(base.ChangelogSections) == 0 {
 743    base.ChangelogSections = parent.ChangelogSections
 744  }
 745  if len(base.Plugins) == 0 {
 746    base.Plugins = parent.Plugins
 747  }
 48
 749  base.Prepare = mergePrepare(base.Prepare, parent.Prepare)
 750  base.GitHub = mergeGitHub(base.GitHub, parent.GitHub)
 751  base.GitLab = mergeGitLab(base.GitLab, parent.GitLab)
 752  base.Bitbucket = mergeBitbucket(base.Bitbucket, parent.Bitbucket)
 753  base.Lint = mergeLint(base.Lint, parent.Lint)
 754  base.GitAuthor = mergeIdentity(base.GitAuthor, parent.GitAuthor)
 755  base.GitCommitter = mergeIdentity(base.GitCommitter, parent.GitCommitter)
 756
 757  return base
 58}
 59
 760func mergePrepare(base, parent domain.PrepareConfig) domain.PrepareConfig {
 761  if base.ChangelogFile == "" {
 762    base.ChangelogFile = parent.ChangelogFile
 763  }
 764  if base.VersionFile == "" {
 765    base.VersionFile = parent.VersionFile
 766  }
 767  if len(base.AdditionalFiles) == 0 {
 768    base.AdditionalFiles = parent.AdditionalFiles
 769  }
 770  return base
 71}
 72
 773func mergeGitHub(base, parent domain.GitHubConfig) domain.GitHubConfig {
 674  if base.Owner == "" {
 675    base.Owner = parent.Owner
 676  }
 777  if base.Repo == "" {
 778    base.Repo = parent.Repo
 779  }
 780  if base.Token == "" {
 781    base.Token = parent.Token
 782  }
 783  if base.APIURL == "" {
 784    base.APIURL = parent.APIURL
 785  }
 786  if len(base.Assets) == 0 {
 787    base.Assets = parent.Assets
 788  }
 789  if base.SuccessComment == "" {
 790    base.SuccessComment = parent.SuccessComment
 791  }
 792  if base.FailComment == "" {
 793    base.FailComment = parent.FailComment
 794  }
 795  if len(base.ReleasedLabels) == 0 {
 796    base.ReleasedLabels = parent.ReleasedLabels
 797  }
 798  if len(base.FailLabels) == 0 {
 799    base.FailLabels = parent.FailLabels
 7100  }
 7101  return base
 102}
 103
 7104func mergeGitLab(base, parent domain.GitLabConfig) domain.GitLabConfig {
 7105  if base.ProjectID == "" {
 7106    base.ProjectID = parent.ProjectID
 7107  }
 7108  if base.Token == "" {
 7109    base.Token = parent.Token
 7110  }
 7111  if base.APIURL == "" {
 7112    base.APIURL = parent.APIURL
 7113  }
 7114  if len(base.Assets) == 0 {
 7115    base.Assets = parent.Assets
 7116  }
 7117  return base
 118}
 119
 7120func mergeBitbucket(base, parent domain.BitbucketConfig) domain.BitbucketConfig {
 7121  if base.Workspace == "" {
 7122    base.Workspace = parent.Workspace
 7123  }
 7124  if base.RepoSlug == "" {
 7125    base.RepoSlug = parent.RepoSlug
 7126  }
 7127  if base.Token == "" {
 7128    base.Token = parent.Token
 7129  }
 7130  if base.APIURL == "" {
 7131    base.APIURL = parent.APIURL
 7132  }
 7133  return base
 134}
 135
 7136func mergeLint(base, parent domain.LintConfig) domain.LintConfig {
 0137  if !base.Enabled && parent.Enabled {
 0138    base.Enabled = true
 0139  }
 7140  if base.MaxSubjectLength == 0 {
 7141    base.MaxSubjectLength = parent.MaxSubjectLength
 7142  }
 7143  if len(base.AllowedTypes) == 0 {
 7144    base.AllowedTypes = parent.AllowedTypes
 7145  }
 7146  if len(base.AllowedScopes) == 0 {
 7147    base.AllowedScopes = parent.AllowedScopes
 7148  }
 7149  return base
 150}
 151
 14152func mergeIdentity(base, parent domain.GitIdentity) domain.GitIdentity {
 13153  if base.Name == "" {
 13154    base.Name = parent.Name
 13155  }
 14156  if base.Email == "" {
 14157    base.Email = parent.Email
 14158  }
 14159  return base
 160}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/config/resolve.go

#LineLine coverage
 1package config
 2
 3import (
 4  "context"
 5  "fmt"
 6  "io"
 7  "net/http"
 8  "os"
 9  "path/filepath"
 10  "strings"
 11  "time"
 12
 13  "github.com/go-viper/mapstructure/v2"
 14  "github.com/spf13/viper"
 15
 16  "github.com/jedi-knights/go-semantic-release/internal/domain"
 17)
 18
 19const maxExtendsDepth = 10
 20
 21// extendsHTTPClient is used for fetching remote extends configs. It has an
 22// explicit timeout so that a slow or unresponsive URL does not block the CLI.
 23var extendsHTTPClient = &http.Client{Timeout: 30 * time.Second}
 24
 25// ResolveExtends loads and merges all extended configurations into the base config.
 26// Supports local file paths and HTTP(S) URLs. Detects cycles.
 827func ResolveExtends(base domain.Config) (domain.Config, error) {
 828  return resolveExtendsRecursive(base, make(map[string]bool), 0)
 829}
 30
 2531func resolveExtendsRecursive(cfg domain.Config, seen map[string]bool, depth int) (domain.Config, error) {
 132  if depth > maxExtendsDepth {
 133    return cfg, fmt.Errorf("extends chain exceeds maximum depth of %d", maxExtendsDepth)
 134  }
 35
 436  if len(cfg.Extends) == 0 {
 437    return cfg, nil
 438  }
 39
 40  // Process extends in order; later entries override earlier ones.
 2041  for _, ref := range cfg.Extends {
 142    if seen[ref] {
 143      return cfg, fmt.Errorf("circular extends detected: %q", ref)
 144    }
 45
 46    // Use a per-branch copy of the seen set so siblings that share a common
 47    // ancestor (diamond-shaped extends) are not falsely flagged as cycles.
 1948    branchSeen := make(map[string]bool, len(seen)+1)
 1949    for k, v := range seen {
 5750      branchSeen[k] = v
 5751    }
 1952    branchSeen[ref] = true
 1953
 1954    parent, err := loadExtendsRef(ref)
 255    if err != nil {
 256      return cfg, fmt.Errorf("loading extends %q: %w", ref, err)
 257    }
 58
 59    // Recursively resolve the parent's own extends.
 1760    parent, err = resolveExtendsRecursive(parent, branchSeen, depth+1)
 1361    if err != nil {
 1362      return cfg, err
 1363    }
 64
 65    // Merge: base values take precedence over parent.
 466    cfg = MergeConfigs(cfg, parent)
 67  }
 68
 469  return cfg, nil
 70}
 71
 1972func loadExtendsRef(ref string) (domain.Config, error) {
 273  if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
 274    return loadExtendsFromURL(ref)
 275  }
 1776  return loadExtendsFromFile(ref)
 77}
 78
 1879func loadExtendsFromFile(path string) (domain.Config, error) {
 1880  absPath, err := filepath.Abs(path)
 081  if err != nil {
 082    return domain.Config{}, fmt.Errorf("resolving path: %w", err)
 083  }
 84
 1885  v := viper.New()
 1886  v.SetConfigFile(absPath)
 1887
 188  if err := v.ReadInConfig(); err != nil {
 189    return domain.Config{}, fmt.Errorf("reading config file: %w", err)
 190  }
 91
 1792  var cfg domain.Config
 1793  if err := v.Unmarshal(&cfg, viper.DecodeHook(
 1794    mapstructure.ComposeDecodeHookFunc(
 1795      StringToGitHubAssetHookFunc(),
 1796      mapstructure.StringToTimeDurationHookFunc(),
 1797      mapstructure.StringToSliceHookFunc(","),
 1798    ),
 099  )); err != nil {
 0100    return domain.Config{}, fmt.Errorf("unmarshaling config: %w", err)
 0101  }
 102
 17103  return cfg, nil
 104}
 105
 106// loadExtendsFromURL fetches a remote extends config over HTTP.
 107// It uses context.Background() because ResolveExtends does not yet accept a
 108// context.Context. When ConfigProvider.Load gains a context parameter this
 109// function should be updated to accept and forward it.
 2110func loadExtendsFromURL(rawURL string) (domain.Config, error) {
 2111  req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, http.NoBody)
 0112  if err != nil {
 0113    return domain.Config{}, fmt.Errorf("creating request: %w", err)
 0114  }
 2115  resp, err := extendsHTTPClient.Do(req)
 0116  if err != nil {
 0117    return domain.Config{}, fmt.Errorf("fetching URL: %w", err)
 0118  }
 2119  defer func() { _ = resp.Body.Close() }()
 120
 1121  if resp.StatusCode != http.StatusOK {
 1122    return domain.Config{}, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, rawURL)
 1123  }
 124
 1125  data, err := io.ReadAll(resp.Body)
 0126  if err != nil {
 0127    return domain.Config{}, fmt.Errorf("reading response: %w", err)
 0128  }
 129
 1130  tmpFile, err := os.CreateTemp("", "semantic-release-extends-*.yaml")
 0131  if err != nil {
 0132    return domain.Config{}, fmt.Errorf("creating temp file: %w", err)
 0133  }
 1134  defer func() { _ = os.Remove(tmpFile.Name()) }()
 135
 0136  if _, err := tmpFile.Write(data); err != nil {
 0137    return domain.Config{}, fmt.Errorf("writing temp file: %w", err)
 0138  }
 139  // Close explicitly before passing the path to loadExtendsFromFile, which
 140  // opens the same file. No deferred close — the explicit close below is sufficient.
 0141  if err := tmpFile.Close(); err != nil {
 0142    return domain.Config{}, fmt.Errorf("closing temp file: %w", err)
 0143  }
 144
 1145  return loadExtendsFromFile(tmpFile.Name())
 146}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/config/viper.go

#LineLine coverage
 1package config
 2
 3import (
 4  "fmt"
 5  "strings"
 6
 7  "github.com/go-viper/mapstructure/v2"
 8  "github.com/spf13/viper"
 9
 10  "github.com/jedi-knights/go-semantic-release/internal/domain"
 11  "github.com/jedi-knights/go-semantic-release/internal/ports"
 12)
 13
 14// Compile-time interface compliance check.
 15var _ ports.ConfigProvider = (*ViperProvider)(nil)
 16
 17// Supported config file names, searched in order (matching semantic-release conventions).
 18var configNames = []string{
 19  ".semantic-release",
 20  ".releaserc",
 21  "release.config",
 22}
 23
 24// ViperProvider implements ports.ConfigProvider using Viper.
 25type ViperProvider struct{}
 26
 27// NewViperProvider creates a new Viper-based config provider.
 728func NewViperProvider() *ViperProvider {
 729  return &ViperProvider{}
 730}
 31
 32func (p *ViperProvider) Load(path string) (domain.Config, error) {
 33  cfg := domain.DefaultConfig()
 34
 35  v := viper.New()
 36  v.SetEnvPrefix("SEMANTIC_RELEASE")
 37  v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
 38  v.AutomaticEnv()
 39
 40  if path != "" {
 41    v.SetConfigFile(path)
 42  } else {
 43    // Search for multiple config file names/formats.
 44    v.AddConfigPath(".")
 45    found := false
 46    for _, name := range configNames {
 47      v.SetConfigName(name)
 48      if err := v.ReadInConfig(); err == nil {
 49        found = true
 50        break
 51      }
 52    }
 53    if !found {
 54      // No config file found — use defaults + env only.
 55      return cfg, nil
 56    }
 57  }
 58
 59  if path != "" {
 60    if err := v.ReadInConfig(); err != nil {
 61      return cfg, fmt.Errorf("reading config: %w", err)
 62    }
 63  }
 64
 65  // StringToGitHubAssetHookFunc must run first so string values are promoted to
 66  // GitHubAsset{Path: s} before mapstructure attempts its own map→struct decode.
 67  if err := v.Unmarshal(&cfg, viper.DecodeHook(
 68    mapstructure.ComposeDecodeHookFunc(
 69      StringToGitHubAssetHookFunc(),
 70      mapstructure.StringToTimeDurationHookFunc(),
 71      mapstructure.StringToSliceHookFunc(","),
 72    ),
 73  )); err != nil {
 74    return cfg, fmt.Errorf("unmarshaling config: %w", err)
 75  }
 76
 77  // Resolve extended configurations.
 78  if len(cfg.Extends) > 0 {
 79    resolved, err := ResolveExtends(cfg)
 80    if err != nil {
 81      return cfg, fmt.Errorf("resolving extends: %w", err)
 82    }
 83    cfg = resolved
 84  }
 85
 86  return cfg, nil
 87}
 88
 89// WriteDefaultConfig writes a default config file to the given path.
 290func WriteDefaultConfig(path string) error {
 291  v := viper.New()
 292  v.SetConfigType("yaml")
 293
 294  v.Set("release_mode", "repo")
 295  v.Set("tag_format", "v{{.Version}}")
 296  v.Set("project_tag_format", "{{.Project}}/v{{.Version}}")
 297  v.Set("dry_run", false)
 298  v.Set("ci", true)
 299  v.Set("discover_modules", false)
 2100  v.Set("dependency_propagation", false)
 2101  v.Set("github.create_release", true)
 2102
 0103  if err := v.WriteConfigAs(path); err != nil {
 0104    return fmt.Errorf("writing default config to %s: %w", path, err)
 0105  }
 2106  return nil
 107}