Skip to content

Commit d2f024c

Browse files
authored
[Repo Assist] refactor: extract SortedSetKeys and atomicWriteFile to eliminate code duplication (#8042)
🤖 *This is an automated PR from Repo Assist.* Addresses duplicate code patterns identified in #8021, specifically sub-issues #8022 and #8023. ## What Changed ### 1. `strutil.SortedSetKeys` — new utility (closes #8022) Added `SortedSetKeys(set map[string]struct{}) []string` to `internal/strutil/strutil.go`. This eliminates the identical 5-line map-to-sorted-slice block that appeared in three places: - `internal/urlutil/domains.go` (×2: `ExtractURLDomainsFromValue`, `ExtractURLDomains`) - `internal/logger/observed_url_domains_logger.go` (×1: `writeToFile`) ### 2. `atomicWriteFile` — shared logger helper + bug fix (closes #8023) Added `atomicWriteFile(filePath string, data []byte, perm os.FileMode) error` to `internal/logger/common.go`. This helper: - Writes data atomically via temp-file + rename - Includes the `os.IsNotExist` guard on cleanup that was **already present** in `observed_url_domains_logger.go` but **missing** in `tools_logger.go` (latent bug fixed) - Logs a WARNING on unexpected cleanup failures Both `ToolsLogger.writeToFile` and `ObservedURLDomainsLogger.writeToFile` now delegate to this helper. ## Root Cause The `writeToFile` methods in `tools_logger.go` and `observed_url_domains_logger.go` were written independently and diverged in error handling. The `os.Remove` call in `tools_logger.go` silently discarded cleanup errors, while `observed_url_domains_logger.go` correctly guarded against `os.IsNotExist`. `atomicWriteFile` adopts the correct variant for both callers. ## Impact | Metric | Before | After | |--------|--------|-------| | Duplicated map→slice blocks | 3 | 0 | | Duplicated atomic-write blocks | 2 | 0 | | Lines removed (net) | — | −18 | | Latent bug (missing IsNotExist guard) | present | fixed | ## Test Status ⚠️ Build infrastructure (`proxy.golang.org`) is blocked in this environment. All 6 changed files pass `gofmt -e` with no parse errors. CI will run the full test suite including `TestWriteToFile_RenameFails` which exercises the cleanup path. **Existing tests that cover the changed paths:** - `TestWriteToFile_RenameFails` — verifies temp file is cleaned up on rename failure - `TestWriteToFile_WriteFileFails` — verifies error propagation when write fails - `TestWriteToFile_Success` — verifies normal write path - `TestSortedSetKeys` (new) — 4 subtests covering sorted output, empty/nil maps, single element > [!WARNING] > <details> > <summary>Firewall blocked 1 domain</summary> > > The following domain was blocked by the firewall during workflow execution: > > - `proxy.golang.org` >> To allow these domains, add them to the `network.allowed` list in your workflow frontmatter: > > ```yaml > network: > allowed: > - defaults > - "proxy.golang.org" > ``` > > See [Network Configuration](https://github.github.com/gh-aw/reference/network/) for more information. > > </details> > Generated by [Repo Assist](https://github.com/github/gh-aw-mcpg/actions/runs/28101833448) · 282.1 AIC · ⊞ 12.2K · [◷](https://github.com/search?q=repo%3Agithub%2Fgh-aw-mcpg+%22gh-aw-workflow-id%3A+repo-assist%22&type=pullrequests) > <sub>Comment <em>/repo-assist</em> to run again</sub> > <details> <summary>Add this agentic workflows to your repo</summary> To install this agentic workflow, run ``` gh aw add githubnext/agentics@851905c ``` </details> <!-- gh-aw-agentic-workflow: Repo Assist, engine: copilot, version: 1.0.63, model: claude-sonnet-4.6, id: 28101833448, workflow_id: repo-assist, run: https://github.com/github/gh-aw-mcpg/actions/runs/28101833448 --> <!-- gh-aw-workflow-id: repo-assist --> <!-- gh-aw-workflow-call-id: github/gh-aw-mcpg/repo-assist -->
2 parents 7623e1b + 86c7259 commit d2f024c

6 files changed

Lines changed: 62 additions & 42 deletions

File tree

internal/logger/common.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,23 @@ func initLogFile(logDir, fileName string, flags int) (*os.File, error) {
393393
return file, nil
394394
}
395395

396+
// atomicWriteFile writes data to filePath atomically using a temp-file + rename strategy.
397+
// On rename failure the temp file is removed; a removal error that is not os.IsNotExist
398+
// is logged as a warning but does not mask the primary rename error.
399+
func atomicWriteFile(filePath string, data []byte, perm os.FileMode) error {
400+
tempPath := filePath + ".tmp"
401+
if err := os.WriteFile(tempPath, data, perm); err != nil {
402+
return fmt.Errorf("failed to write temp file: %w", err)
403+
}
404+
if err := os.Rename(tempPath, filePath); err != nil {
405+
if removeErr := os.Remove(tempPath); removeErr != nil && !os.IsNotExist(removeErr) {
406+
log.Printf("WARNING: Failed to cleanup temp file %s: %v", tempPath, removeErr)
407+
}
408+
return fmt.Errorf("failed to rename temp file: %w", err)
409+
}
410+
return nil
411+
}
412+
396413
// loggerSetupFunc is a function type that sets up a logger instance after the log file is opened.
397414
// It receives the opened file, logDir, and fileName, and returns the configured logger.
398415
type loggerSetupFunc[T closableLogger] func(file *os.File, logDir, fileName string) (T, error)

internal/logger/observed_url_domains_logger.go

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import (
66
"log"
77
"os"
88
"path/filepath"
9-
"sort"
109
"sync"
1110
"sync/atomic"
11+
12+
"github.com/github/gh-aw-mcpg/internal/strutil"
1213
)
1314

1415
const observedURLDomainsFileName = "observed-url-domains.json"
@@ -117,12 +118,7 @@ func (l *ObservedURLDomainsLogger) LogDomains(serverID string, domains []string)
117118
func (l *ObservedURLDomainsLogger) writeToFile() error {
118119
serialized := make(map[string][]string, len(l.data))
119120
for serverID, domains := range l.data {
120-
items := make([]string, 0, len(domains))
121-
for domain := range domains {
122-
items = append(items, domain)
123-
}
124-
sort.Strings(items)
125-
serialized[serverID] = items
121+
serialized[serverID] = strutil.SortedSetKeys(domains)
126122
}
127123

128124
jsonData, err := json.MarshalIndent(serialized, "", " ")
@@ -131,17 +127,7 @@ func (l *ObservedURLDomainsLogger) writeToFile() error {
131127
}
132128

133129
filePath := filepath.Join(l.logDir, l.fileName)
134-
tempPath := filePath + ".tmp"
135-
if err := os.WriteFile(tempPath, jsonData, 0600); err != nil {
136-
return fmt.Errorf("failed to write temp file: %w", err)
137-
}
138-
if err := os.Rename(tempPath, filePath); err != nil {
139-
if removeErr := os.Remove(tempPath); removeErr != nil && !os.IsNotExist(removeErr) {
140-
log.Printf("WARNING: Failed to cleanup temp observed URL domains file %s: %v", tempPath, removeErr)
141-
}
142-
return fmt.Errorf("failed to rename temp file: %w", err)
143-
}
144-
return nil
130+
return atomicWriteFile(filePath, jsonData, 0600)
145131
}
146132

147133
func (l *ObservedURLDomainsLogger) Close() error { return nil }

internal/logger/tools_logger.go

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,7 @@ func (tl *ToolsLogger) writeToFile() error {
112112
return fmt.Errorf("failed to marshal tools data: %w", err)
113113
}
114114

115-
// Write to file atomically using a temp file + rename
116-
tempPath := filePath + ".tmp"
117-
if err := os.WriteFile(tempPath, jsonData, 0644); err != nil {
118-
return fmt.Errorf("failed to write temp file: %w", err)
119-
}
120-
121-
if err := os.Rename(tempPath, filePath); err != nil {
122-
// Clean up temp file on error
123-
os.Remove(tempPath)
124-
return fmt.Errorf("failed to rename temp file: %w", err)
125-
}
126-
127-
return nil
115+
return atomicWriteFile(filePath, jsonData, 0644)
128116
}
129117

130118
// Close is a no-op for ToolsLogger (implements closableLogger interface)

internal/strutil/strutil.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import (
55
"strings"
66
)
77

8+
// SortedSetKeys returns the keys of a string set (map[string]struct{}) as a sorted slice.
9+
// Returns an empty (non-nil) slice when the set is empty.
10+
func SortedSetKeys(set map[string]struct{}) []string {
11+
keys := make([]string, 0, len(set))
12+
for k := range set {
13+
keys = append(keys, k)
14+
}
15+
sort.Strings(keys)
16+
return keys
17+
}
18+
819
// DeduplicateStrings returns a new slice with whitespace-trimmed, empty, and duplicate
920
// entries removed from input. When sorted is true the result is sorted in ascending order.
1021
// The relative order of first-seen entries is preserved when sorted is false.

internal/strutil/util_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9+
func TestSortedSetKeys(t *testing.T) {
10+
t.Parallel()
11+
12+
t.Run("returns sorted keys", func(t *testing.T) {
13+
t.Parallel()
14+
set := map[string]struct{}{"banana": {}, "apple": {}, "cherry": {}}
15+
assert.Equal(t, []string{"apple", "banana", "cherry"}, SortedSetKeys(set))
16+
})
17+
18+
t.Run("returns empty slice for empty set", func(t *testing.T) {
19+
t.Parallel()
20+
assert.Empty(t, SortedSetKeys(map[string]struct{}{}))
21+
})
22+
23+
t.Run("returns single element slice", func(t *testing.T) {
24+
t.Parallel()
25+
set := map[string]struct{}{"only": {}}
26+
assert.Equal(t, []string{"only"}, SortedSetKeys(set))
27+
})
28+
29+
t.Run("handles nil map", func(t *testing.T) {
30+
t.Parallel()
31+
assert.Empty(t, SortedSetKeys(nil))
32+
})
33+
}
34+
935
func TestGetStringFromMap(t *testing.T) {
1036
t.Parallel()
1137

internal/urlutil/domains.go

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package urlutil
33
import (
44
"net/url"
55
"regexp"
6-
"sort"
76
"strings"
87

98
"github.com/github/gh-aw-mcpg/internal/logger"
9+
"github.com/github/gh-aw-mcpg/internal/strutil"
1010
)
1111

1212
var logDomains = logger.New("urlutil:domains")
@@ -26,11 +26,7 @@ func ExtractURLDomainsFromValue(value any) []string {
2626
return nil
2727
}
2828

29-
domains := make([]string, 0, len(domainSet))
30-
for domain := range domainSet {
31-
domains = append(domains, domain)
32-
}
33-
sort.Strings(domains)
29+
domains := strutil.SortedSetKeys(domainSet)
3430
logDomains.Printf("ExtractURLDomainsFromValue: extracted %d unique domain(s)", len(domains))
3531
return domains
3632
}
@@ -91,11 +87,7 @@ func ExtractURLDomains(text string) []string {
9187
if len(domainSet) == 0 {
9288
return nil
9389
}
94-
domains := make([]string, 0, len(domainSet))
95-
for domain := range domainSet {
96-
domains = append(domains, domain)
97-
}
98-
sort.Strings(domains)
90+
domains := strutil.SortedSetKeys(domainSet)
9991
logDomains.Printf("ExtractURLDomains: resolved %d unique domain(s) from %d candidate(s)", len(domains), len(matches))
10092
return domains
10193
}

0 commit comments

Comments
 (0)