Skip to content

Commit 97baf61

Browse files
ChrisJBurnsclaude
andauthored
Refactor codebase for testability and clean architecture (#15)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f1ae324 commit 97baf61

10 files changed

Lines changed: 1147 additions & 83 deletions

File tree

internal/files/interfaces.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2025 Stacklok, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package files
16+
17+
// VersionReader reads version information from files.
18+
type VersionReader interface {
19+
// ReadVersion reads the version from the specified path.
20+
ReadVersion(path string) (string, error)
21+
}
22+
23+
// VersionWriter writes version information to files.
24+
type VersionWriter interface {
25+
// WriteVersion writes the version to the specified path.
26+
WriteVersion(path, version string) error
27+
}
28+
29+
// YAMLUpdater updates version information in YAML files.
30+
type YAMLUpdater interface {
31+
// UpdateYAMLFile updates a specific path in a YAML file with a new version.
32+
UpdateYAMLFile(cfg VersionFileConfig, currentVersion, newVersion string) error
33+
}
34+
35+
// DefaultVersionReader is the default implementation of VersionReader.
36+
type DefaultVersionReader struct{}
37+
38+
// ReadVersion reads the version from the specified path.
39+
func (*DefaultVersionReader) ReadVersion(path string) (string, error) {
40+
return ReadVersion(path)
41+
}
42+
43+
// DefaultVersionWriter is the default implementation of VersionWriter.
44+
type DefaultVersionWriter struct{}
45+
46+
// WriteVersion writes the version to the specified path.
47+
func (*DefaultVersionWriter) WriteVersion(path, version string) error {
48+
return WriteVersion(path, version)
49+
}
50+
51+
// DefaultYAMLUpdater is the default implementation of YAMLUpdater.
52+
type DefaultYAMLUpdater struct{}
53+
54+
// UpdateYAMLFile updates a specific path in a YAML file with a new version.
55+
func (*DefaultYAMLUpdater) UpdateYAMLFile(cfg VersionFileConfig, currentVersion, newVersion string) error {
56+
return UpdateYAMLFile(cfg, currentVersion, newVersion)
57+
}

internal/files/yaml.go

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,33 +88,73 @@ func UpdateYAMLFile(cfg VersionFileConfig, currentVersion, newVersion string) er
8888
return nil
8989
}
9090

91+
// replacementRule defines a pattern-replacement pair for surgical YAML value replacement.
92+
// Each rule targets a specific quote style or value format in YAML files.
93+
type replacementRule struct {
94+
// name describes what this rule handles (for debugging/documentation)
95+
name string
96+
// pattern returns the regex pattern to match for the given old value
97+
pattern func(oldValue string) string
98+
// replacement returns the replacement string for the given new value
99+
replacement func(newValue string) string
100+
}
101+
102+
// replacementRules defines all the rules for surgical YAML value replacement.
103+
// Rules are tried in order; the first matching rule is applied.
104+
var replacementRules = []replacementRule{
105+
{
106+
// Handles double-quoted values: key: "value"
107+
name: "double-quoted",
108+
pattern: func(oldValue string) string {
109+
return fmt.Sprintf(`"(%s)"`, regexp.QuoteMeta(oldValue))
110+
},
111+
replacement: func(newValue string) string {
112+
return fmt.Sprintf(`"%s"`, newValue)
113+
},
114+
},
115+
{
116+
// Handles single-quoted values: key: 'value'
117+
name: "single-quoted",
118+
pattern: func(oldValue string) string {
119+
return fmt.Sprintf(`'(%s)'`, regexp.QuoteMeta(oldValue))
120+
},
121+
replacement: func(newValue string) string {
122+
return fmt.Sprintf(`'%s'`, newValue)
123+
},
124+
},
125+
{
126+
// Handles unquoted values at end of line: key: value\n
127+
name: "unquoted-eol",
128+
pattern: func(oldValue string) string {
129+
return fmt.Sprintf(`: (%s)(\s*)$`, regexp.QuoteMeta(oldValue))
130+
},
131+
replacement: func(newValue string) string {
132+
return fmt.Sprintf(`: %s$2`, newValue)
133+
},
134+
},
135+
{
136+
// Handles unquoted values followed by inline comment: key: value # comment
137+
name: "unquoted-with-comment",
138+
pattern: func(oldValue string) string {
139+
return fmt.Sprintf(`: (%s)(\s*#)`, regexp.QuoteMeta(oldValue))
140+
},
141+
replacement: func(newValue string) string {
142+
return fmt.Sprintf(`: %s$2`, newValue)
143+
},
144+
},
145+
}
146+
91147
// surgicalReplace performs a targeted replacement of a YAML value while preserving
92148
// the original formatting (quotes, whitespace, etc.)
93149
func surgicalReplace(data []byte, oldValue, newValue string) ([]byte, error) {
94150
content := string(data)
95151

96-
// Try different quote styles that might wrap the value
97-
patterns := []string{
98-
// Double quoted: key: "value"
99-
fmt.Sprintf(`"(%s)"`, regexp.QuoteMeta(oldValue)),
100-
// Single quoted: key: 'value'
101-
fmt.Sprintf(`'(%s)'`, regexp.QuoteMeta(oldValue)),
102-
// Unquoted after colon: key: value
103-
fmt.Sprintf(`: (%s)(\s*)$`, regexp.QuoteMeta(oldValue)),
104-
fmt.Sprintf(`: (%s)(\s*#)`, regexp.QuoteMeta(oldValue)),
105-
}
106-
107-
replacements := []string{
108-
fmt.Sprintf(`"%s"`, newValue),
109-
fmt.Sprintf(`'%s'`, newValue),
110-
fmt.Sprintf(`: %s$2`, newValue),
111-
fmt.Sprintf(`: %s$2`, newValue),
112-
}
113-
114-
for i, pattern := range patterns {
152+
// Try each replacement rule in order; use the first one that matches
153+
for _, rule := range replacementRules {
154+
pattern := rule.pattern(oldValue)
115155
re := regexp.MustCompile(`(?m)` + pattern)
116156
if re.MatchString(content) {
117-
result := re.ReplaceAllString(content, replacements[i])
157+
result := re.ReplaceAllString(content, rule.replacement(newValue))
118158
return []byte(result), nil
119159
}
120160
}

internal/github/client.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,49 @@ package github
1818
import (
1919
"context"
2020
"fmt"
21+
"os"
2122

2223
"github.com/google/go-github/v60/github"
2324
"golang.org/x/oauth2"
2425
)
2526

26-
// Client wraps the GitHub API client.
27+
// PRCreator defines the interface for creating pull requests.
28+
type PRCreator interface {
29+
// CreateReleasePR creates a new branch with the modified files and opens a PR.
30+
CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult, error)
31+
}
32+
33+
// Client wraps the GitHub API client and implements PRCreator.
2734
type Client struct {
28-
client *github.Client
35+
client *github.Client
36+
fileReader FileReader
37+
}
38+
39+
// Ensure Client implements PRCreator at compile time.
40+
var _ PRCreator = (*Client)(nil)
41+
42+
// osFileReader is the default FileReader implementation that uses os.ReadFile.
43+
type osFileReader struct{}
44+
45+
// ReadFile reads the contents of a file using the standard library os.ReadFile.
46+
func (*osFileReader) ReadFile(path string) ([]byte, error) {
47+
return os.ReadFile(path)
48+
}
49+
50+
// ClientOption is a functional option for configuring the Client.
51+
type ClientOption func(*Client)
52+
53+
// WithFileReader sets a custom FileReader implementation for the Client.
54+
// This is useful for testing or when file reading needs to be customized.
55+
func WithFileReader(fr FileReader) ClientOption {
56+
return func(c *Client) {
57+
c.fileReader = fr
58+
}
2959
}
3060

3161
// NewClient creates a new GitHub client with the provided token.
32-
func NewClient(ctx context.Context, token string) (*Client, error) {
62+
// Optional ClientOption functions can be provided to customize the client behavior.
63+
func NewClient(ctx context.Context, token string, opts ...ClientOption) (*Client, error) {
3364
if token == "" {
3465
return nil, fmt.Errorf("token is required")
3566
}
@@ -39,9 +70,16 @@ func NewClient(ctx context.Context, token string) (*Client, error) {
3970
)
4071
tc := oauth2.NewClient(ctx, ts)
4172

42-
return &Client{
43-
client: github.NewClient(tc),
44-
}, nil
73+
c := &Client{
74+
client: github.NewClient(tc),
75+
fileReader: &osFileReader{},
76+
}
77+
78+
for _, opt := range opts {
79+
opt(c)
80+
}
81+
82+
return c, nil
4583
}
4684

4785
// PRRequest contains the parameters for creating a pull request.

internal/github/client_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,52 @@ func TestPRRequest_Validate(t *testing.T) {
140140
})
141141
}
142142
}
143+
144+
// mockFileReader is a simple mock implementation for testing FileReader injection.
145+
type mockFileReader struct {
146+
called bool
147+
}
148+
149+
func (m *mockFileReader) ReadFile(_ string) ([]byte, error) {
150+
m.called = true
151+
return []byte("mock content"), nil
152+
}
153+
154+
func TestWithFileReader(t *testing.T) {
155+
t.Parallel()
156+
157+
tests := []struct {
158+
name string
159+
fileReader FileReader
160+
}{
161+
{
162+
name: "custom FileReader is injected",
163+
fileReader: &mockFileReader{},
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
t.Parallel()
170+
client, err := NewClient(context.Background(), "test-token", WithFileReader(tt.fileReader))
171+
if err != nil {
172+
t.Fatalf("NewClient() unexpected error = %v", err)
173+
}
174+
if client.fileReader != tt.fileReader {
175+
t.Error("WithFileReader() did not inject the custom FileReader")
176+
}
177+
})
178+
}
179+
}
180+
181+
func TestClient_ImplementsPRCreator(t *testing.T) {
182+
t.Parallel()
183+
184+
client, err := NewClient(context.Background(), "test-token")
185+
if err != nil {
186+
t.Fatalf("NewClient() unexpected error = %v", err)
187+
}
188+
189+
// Runtime assertion that Client implements PRCreator interface.
190+
var _ PRCreator = client
191+
}

internal/github/interfaces.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2025 Stacklok, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package github
16+
17+
// FileReader defines the interface for reading file contents.
18+
// This abstraction allows for dependency injection and makes the client
19+
// testable by enabling mock file systems.
20+
type FileReader interface {
21+
// ReadFile reads the contents of a file at the given path.
22+
// It returns the file contents as a byte slice, or an error if the read fails.
23+
ReadFile(path string) ([]byte, error)
24+
}

internal/github/pr.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package github
1717
import (
1818
"context"
1919
"fmt"
20-
"os"
2120
"path/filepath"
2221

2322
"github.com/google/go-github/v60/github"
@@ -46,8 +45,6 @@ func (c *Client) CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult,
4645
return nil, fmt.Errorf("creating branch: %w", err)
4746
}
4847

49-
fmt.Printf("Created branch: %s\n", req.HeadBranch)
50-
5148
// Commit the files to the new branch
5249
for _, filePath := range req.Files {
5350
if err := c.commitFile(ctx, req.Owner, req.Repo, req.HeadBranch, filePath); err != nil {
@@ -66,12 +63,8 @@ func (c *Client) CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult,
6663
return nil, fmt.Errorf("creating pull request: %w", err)
6764
}
6865

69-
// Add release label
70-
_, _, err = c.client.Issues.AddLabelsToIssue(ctx, req.Owner, req.Repo, pr.GetNumber(), []string{"release"})
71-
if err != nil {
72-
// Non-fatal - label might not exist
73-
fmt.Printf("Warning: could not add 'release' label: %v\n", err)
74-
}
66+
// Add release label (non-fatal if it fails, label might not exist)
67+
_, _, _ = c.client.Issues.AddLabelsToIssue(ctx, req.Owner, req.Repo, pr.GetNumber(), []string{"release"})
7568

7669
return &PRResult{
7770
Number: pr.GetNumber(),
@@ -81,8 +74,8 @@ func (c *Client) CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult,
8174

8275
// commitFile commits a single file to a branch.
8376
func (c *Client) commitFile(ctx context.Context, owner, repo, branch, filePath string) error {
84-
// Read file content
85-
content, err := os.ReadFile(filePath)
77+
// Read file content using the fileReader interface
78+
content, err := c.fileReader.ReadFile(filePath)
8679
if err != nil {
8780
return fmt.Errorf("reading file: %w", err)
8881
}

internal/version/version.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,34 @@ func cmpInt(a, b int) int {
119119
return 0
120120
}
121121

122-
// IsGreater returns true if version a is greater than version b.
123-
func IsGreater(a, b string) bool {
122+
// CompareVersions compares two version strings and returns their relative ordering.
123+
// It returns -1 if a < b, 0 if a == b, and 1 if a > b.
124+
// If either version string cannot be parsed, an error is returned with context
125+
// indicating which version failed to parse.
126+
func CompareVersions(a, b string) (int, error) {
124127
va, err := Parse(a)
125128
if err != nil {
126-
return false
129+
return 0, fmt.Errorf("parsing version a %q: %w", a, err)
127130
}
128131

129132
vb, err := Parse(b)
130133
if err != nil {
131-
return false
134+
return 0, fmt.Errorf("parsing version b %q: %w", b, err)
132135
}
133136

134-
return va.Compare(vb) > 0
137+
return va.Compare(vb), nil
138+
}
139+
140+
// IsGreaterE returns true if version a is greater than version b, along with
141+
// any error that occurred during parsing. This is the preferred function for
142+
// new code as it allows callers to distinguish between "version A is not greater"
143+
// and "invalid version string".
144+
//
145+
// If an error is returned, the boolean result should be ignored.
146+
func IsGreaterE(a, b string) (bool, error) {
147+
cmp, err := CompareVersions(a, b)
148+
if err != nil {
149+
return false, err
150+
}
151+
return cmp > 0, nil
135152
}

0 commit comments

Comments
 (0)