Skip to content

Commit b651887

Browse files
committed
refactor MarshalItems with generics and per-tool config
1 parent c960f49 commit b651887

File tree

4 files changed

+79
-104
lines changed

4 files changed

+79
-104
lines changed

pkg/github/issues.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,9 +1602,16 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16021602
totalCount = fragment.TotalCount
16031603
}
16041604

1605-
// Create response with issues
1605+
optimizedIssues, err := response.OptimizeList(issues,
1606+
response.WithCollectionExtractors(map[string][]string{"labels": {"name"}}),
1607+
)
1608+
if err != nil {
1609+
return nil, nil, fmt.Errorf("failed to optimize issues: %w", err)
1610+
}
1611+
1612+
// Wrap optimized issues with pagination metadata
16061613
issueResponse := map[string]any{
1607-
"issues": issues,
1614+
"issues": json.RawMessage(optimizedIssues),
16081615
"pageInfo": map[string]any{
16091616
"hasNextPage": pageInfo.HasNextPage,
16101617
"hasPreviousPage": pageInfo.HasPreviousPage,
@@ -1613,7 +1620,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16131620
},
16141621
"totalCount": totalCount,
16151622
}
1616-
out, err := response.MarshalItems(issueResponse)
1623+
out, err := json.Marshal(issueResponse)
16171624
if err != nil {
16181625
return nil, nil, fmt.Errorf("failed to marshal issues: %w", err)
16191626
}

pkg/github/pullrequests.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,7 +1169,14 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
11691169
}
11701170
}
11711171

1172-
r, err := response.MarshalItems(prs)
1172+
r, err := response.OptimizeList(prs,
1173+
response.WithPreservedFields(map[string]bool{"html_url": true, "draft": true}),
1174+
response.WithCollectionExtractors(map[string][]string{
1175+
"labels": {"name"},
1176+
"requested_reviewers": {"login"},
1177+
"requested_teams": {"name"},
1178+
}),
1179+
)
11731180
if err != nil {
11741181
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
11751182
}

pkg/github/repositories.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
217217
minimalCommits[i] = convertToMinimalCommit(commit, false)
218218
}
219219

220-
r, err := response.MarshalItems(minimalCommits, 3)
220+
r, err := response.OptimizeList(minimalCommits,
221+
response.WithMaxDepth(3),
222+
response.WithPreservedFields(map[string]bool{"html_url": true}),
223+
)
221224
if err != nil {
222225
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
223226
}
@@ -304,7 +307,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool {
304307
minimalBranches = append(minimalBranches, convertToMinimalBranch(branch))
305308
}
306309

307-
r, err := response.MarshalItems(minimalBranches)
310+
r, err := response.OptimizeList(minimalBranches)
308311
if err != nil {
309312
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
310313
}
@@ -1498,7 +1501,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool {
14981501
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list tags", resp, body), nil, nil
14991502
}
15001503

1501-
r, err := response.MarshalItems(tags)
1504+
r, err := response.OptimizeList(tags)
15021505
if err != nil {
15031506
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
15041507
}
@@ -1671,7 +1674,9 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool {
16711674
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list releases", resp, body), nil, nil
16721675
}
16731676

1674-
r, err := response.MarshalItems(releases)
1677+
r, err := response.OptimizeList(releases,
1678+
response.WithPreservedFields(map[string]bool{"html_url": true, "prerelease": true}),
1679+
)
16751680
if err != nil {
16761681
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
16771682
}

pkg/response/optimize.go

Lines changed: 52 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -6,80 +6,78 @@ import (
66
"strings"
77
)
88

9-
// defaultFillRateThreshold is the default proportion of items that must have a key for it to survive
10-
const defaultFillRateThreshold = 0.1
9+
const (
10+
defaultFillRateThreshold = 0.1 // default proportion of items that must have a key for it to survive
11+
minFillRateRows = 3 // minimum number of items required to apply fill-rate filtering
12+
defaultMaxDepth = 2 // default nesting depth that flatten will recurse into
13+
)
14+
15+
// OptimizeListConfig controls the optimization pipeline behavior.
16+
type OptimizeListConfig struct {
17+
maxDepth int
18+
preservedFields map[string]bool
19+
collectionExtractors map[string][]string
20+
}
1121

12-
// minFillRateRows is the minimum number of items required to apply fill-rate filtering
13-
const minFillRateRows = 3
22+
type OptimizeListOption func(*OptimizeListConfig)
1423

15-
// maxFlattenDepth is the maximum nesting depth that flatten will recurse into.
24+
// WithMaxDepth sets the maximum nesting depth for flattening.
1625
// Deeper nested maps are silently dropped.
17-
const maxFlattenDepth = 2
26+
func WithMaxDepth(d int) OptimizeListOption {
27+
return func(c *OptimizeListConfig) {
28+
c.maxDepth = d
29+
}
30+
}
1831

19-
// preservedFields is a set of keys that are exempt from all destructive strategies except whitespace normalization.
32+
// WithPreservedFields sets keys that are exempt from all destructive strategies except whitespace normalization.
2033
// Keys are matched against post-flatten map keys, so for nested fields like "user.html_url", the dotted key must be
21-
// added explicitly. Empty collections are still dropped. Wins over collectionFieldExtractors.
22-
var preservedFields = map[string]bool{
23-
"html_url": true,
24-
"draft": true,
25-
"prerelease": true,
34+
// added explicitly. Empty collections are still dropped. Wins over collectionExtractors.
35+
func WithPreservedFields(fields map[string]bool) OptimizeListOption {
36+
return func(c *OptimizeListConfig) {
37+
c.preservedFields = fields
38+
}
2639
}
2740

28-
// collectionFieldExtractors controls how array fields are handled instead of being summarized as "[N items]".
41+
// WithCollectionExtractors controls how array fields are handled instead of being summarized as "[N items]".
2942
// - 1 sub-field: comma-joined into a flat string ("bug, enhancement").
3043
// - Multiple sub-fields: keep the array, but trim each element to only those fields.
3144
//
3245
// These are explicitly exempt from fill-rate filtering; if we asked for the extraction, it's likely important
3346
// to preserve the data even if only one item has it.
34-
var collectionFieldExtractors = map[string][]string{
35-
"labels": {"name"},
36-
"requested_reviewers": {"login"},
37-
"requested_teams": {"name"},
47+
func WithCollectionExtractors(extractors map[string][]string) OptimizeListOption {
48+
return func(c *OptimizeListConfig) {
49+
c.collectionExtractors = extractors
50+
}
3851
}
3952

40-
// MarshalItems is the single entry point for response optimization.
41-
// Handles two shapes: plain JSON arrays and wrapped objects with metadata.
42-
// An optional maxDepth controls how many nesting levels flatten will recurse
43-
// into; it defaults to maxFlattenDepth when omitted.
44-
func MarshalItems(data any, maxDepth ...int) ([]byte, error) {
45-
depth := maxFlattenDepth
46-
if len(maxDepth) > 0 {
47-
depth = maxDepth[0]
53+
// OptimizeList optimizes a list of items by applying flattening, URL removal, zero-value removal,
54+
// whitespace normalization, collection summarization, and fill-rate filtering.
55+
func OptimizeList[T any](items []T, opts ...OptimizeListOption) ([]byte, error) {
56+
cfg := OptimizeListConfig{maxDepth: defaultMaxDepth}
57+
for _, opt := range opts {
58+
opt(&cfg)
4859
}
4960

50-
raw, err := json.Marshal(data)
61+
raw, err := json.Marshal(items)
5162
if err != nil {
5263
return nil, fmt.Errorf("failed to marshal data: %w", err)
5364
}
5465

55-
switch raw[0] {
56-
case '[':
57-
return optimizeArray(raw, depth)
58-
case '{':
59-
return optimizeObject(raw, depth)
60-
default:
61-
return raw, nil
66+
var maps []map[string]any
67+
if err := json.Unmarshal(raw, &maps); err != nil {
68+
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
6269
}
63-
}
6470

65-
// OptimizeItems runs the full optimization pipeline on a slice of items:
66-
// flatten, remove URLs, remove zero-values, normalize whitespace,
67-
// summarize collections, and fill-rate filtering.
68-
func OptimizeItems(items []map[string]any, depth int) []map[string]any {
69-
if len(items) == 0 {
70-
return items
71+
for i, item := range maps {
72+
flattenedItem := flattenTo(item, cfg.maxDepth)
73+
maps[i] = optimizeItem(flattenedItem, cfg)
7174
}
7275

73-
for i, item := range items {
74-
flattenedItem := flattenTo(item, depth)
75-
items[i] = optimizeItem(flattenedItem)
76+
if len(maps) >= minFillRateRows {
77+
maps = filterByFillRate(maps, defaultFillRateThreshold, cfg)
7678
}
7779

78-
if len(items) >= minFillRateRows {
79-
items = filterByFillRate(items, defaultFillRateThreshold)
80-
}
81-
82-
return items
80+
return json.Marshal(maps)
8381
}
8482

8583
// flattenTo recursively promotes values from nested maps into the parent
@@ -106,7 +104,7 @@ func flattenInto(item map[string]any, prefix string, result map[string]any, dept
106104

107105
// filterByFillRate drops keys that appear on less than the threshold proportion of items.
108106
// Preserved fields and extractor keys always survive.
109-
func filterByFillRate(items []map[string]any, threshold float64) []map[string]any {
107+
func filterByFillRate(items []map[string]any, threshold float64, cfg OptimizeListConfig) []map[string]any {
110108
keyCounts := make(map[string]int)
111109
for _, item := range items {
112110
for key := range item {
@@ -117,8 +115,8 @@ func filterByFillRate(items []map[string]any, threshold float64) []map[string]an
117115
minCount := int(threshold * float64(len(items)))
118116
keepKeys := make(map[string]bool, len(keyCounts))
119117
for key, count := range keyCounts {
120-
_, hasExtractor := collectionFieldExtractors[key]
121-
if count > minCount || preservedFields[key] || hasExtractor {
118+
_, hasExtractor := cfg.collectionExtractors[key]
119+
if count > minCount || cfg.preservedFields[key] || hasExtractor {
122120
keepKeys[key] = true
123121
}
124122
}
@@ -136,55 +134,13 @@ func filterByFillRate(items []map[string]any, threshold float64) []map[string]an
136134
return items
137135
}
138136

139-
// optimizeArray is the entry point for optimizing a raw JSON array.
140-
func optimizeArray(raw []byte, depth int) ([]byte, error) {
141-
var items []map[string]any
142-
if err := json.Unmarshal(raw, &items); err != nil {
143-
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
144-
}
145-
return json.Marshal(OptimizeItems(items, depth))
146-
}
147-
148-
// optimizeObject is the entry point for optimizing a raw JSON object.
149-
func optimizeObject(raw []byte, depth int) ([]byte, error) {
150-
var wrapper map[string]any
151-
if err := json.Unmarshal(raw, &wrapper); err != nil {
152-
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
153-
}
154-
155-
// find the first array field in the wrapper (data); rest is metadata to be preserved as is
156-
// assumes exactly one array field exists; if multiple are present, only the first will be optimized
157-
var dataKey string
158-
for key, value := range wrapper {
159-
if _, ok := value.([]any); ok {
160-
dataKey = key
161-
break
162-
}
163-
}
164-
// if no data array found, just return the original response
165-
if dataKey == "" {
166-
return raw, nil
167-
}
168-
169-
rawItems := wrapper[dataKey].([]any)
170-
items := make([]map[string]any, 0, len(rawItems))
171-
for _, rawItem := range rawItems {
172-
if m, ok := rawItem.(map[string]any); ok {
173-
items = append(items, m)
174-
}
175-
}
176-
wrapper[dataKey] = OptimizeItems(items, depth)
177-
178-
return json.Marshal(wrapper)
179-
}
180-
181137
// optimizeItem applies per-item strategies in a single pass: remove URLs,
182138
// remove zero-values, normalize whitespace, summarize collections.
183139
// Preserved fields skip everything except whitespace normalization.
184-
func optimizeItem(item map[string]any) map[string]any {
140+
func optimizeItem(item map[string]any, cfg OptimizeListConfig) map[string]any {
185141
result := make(map[string]any, len(item))
186142
for key, value := range item {
187-
preserved := preservedFields[key]
143+
preserved := cfg.preservedFields[key]
188144
if !preserved && isURLKey(key) {
189145
continue
190146
}
@@ -202,7 +158,7 @@ func optimizeItem(item map[string]any) map[string]any {
202158

203159
if preserved {
204160
result[key] = value
205-
} else if fields, ok := collectionFieldExtractors[key]; ok {
161+
} else if fields, ok := cfg.collectionExtractors[key]; ok {
206162
if len(fields) == 1 {
207163
result[key] = extractSubField(v, fields[0])
208164
} else {

0 commit comments

Comments
 (0)