Skip to content

Commit 8cc1775

Browse files
authored
perf: eliminate repeated O(n) action pin scans and redundant permissions parsing in MCP workflow compilation (#24256)
1 parent 7a356df commit 8cc1775

2 files changed

Lines changed: 69 additions & 60 deletions

File tree

pkg/workflow/action_pins.go

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111

1212
"github.com/github/gh-aw/pkg/console"
1313
"github.com/github/gh-aw/pkg/logger"
14-
"github.com/github/gh-aw/pkg/sliceutil"
1514
)
1615

1716
var actionPinsLog = logger.New("workflow:action_pins")
@@ -48,6 +47,9 @@ type ActionPinsData struct {
4847
var (
4948
// cachedActionPins holds the parsed and sorted action pins
5049
cachedActionPins []ActionPin
50+
// cachedActionPinsByRepo maps repo name to its sorted (version-descending) pins.
51+
// Built alongside cachedActionPins to enable O(1) repo lookups without linear scans.
52+
cachedActionPinsByRepo map[string][]ActionPin
5153
// actionPinsOnce ensures the action pins are loaded only once
5254
actionPinsOnce sync.Once
5355
)
@@ -106,11 +108,42 @@ func getActionPins() []ActionPin {
106108

107109
actionPinsLog.Printf("Successfully unmarshaled and sorted %d action pins from JSON", len(pins))
108110
cachedActionPins = pins
111+
112+
// Build per-repo index for O(1) lookups.
113+
// Each repo's slice is sorted by semantic version (descending) so callers can
114+
// pick the highest compatible version without an additional sort pass.
115+
byRepo := make(map[string][]ActionPin, len(pins))
116+
for _, pin := range pins {
117+
byRepo[pin.Repo] = append(byRepo[pin.Repo], pin)
118+
}
119+
// The global sort above orders all pins together, so grouping them by repo
120+
// does not guarantee per-repo ordering. Re-sort each repo's slice using the
121+
// semantic comparator (compareVersions) so that the index matches what
122+
// sortPinsByVersion would return for any subset of pins.
123+
for repo, repoPins := range byRepo {
124+
sort.Slice(repoPins, func(i, j int) bool {
125+
v1 := strings.TrimPrefix(repoPins[i].Version, "v")
126+
v2 := strings.TrimPrefix(repoPins[j].Version, "v")
127+
return compareVersions(v1, v2) > 0
128+
})
129+
byRepo[repo] = repoPins
130+
}
131+
cachedActionPinsByRepo = byRepo
132+
actionPinsLog.Printf("Built per-repo action pin index for %d repos", len(byRepo))
109133
})
110134

111135
return cachedActionPins
112136
}
113137

138+
// getActionPinsByRepo returns the sorted (version-descending) list of action pins
139+
// for the given repository, initialising the cache on first call.
140+
// Returns nil if the repo has no pins.
141+
func getActionPinsByRepo(repo string) []ActionPin {
142+
// Ensure the cache is initialised (both slices are populated in one sync.Once call).
143+
getActionPins()
144+
return cachedActionPinsByRepo[repo]
145+
}
146+
114147
// sortPinsByVersion sorts action pins by version in descending order (highest first).
115148
// This function returns a new sorted slice without modifying the input.
116149
// This is an immutable operation for better safety and clarity.
@@ -135,23 +168,16 @@ func sortPinsByVersion(pins []ActionPin) []ActionPin {
135168
// If no pin is found, it returns an empty string
136169
// The returned reference includes a comment with the version tag (e.g., "repo@sha # v1")
137170
func GetActionPin(actionRepo string) string {
138-
actionPins := getActionPins()
139-
140-
// Find all pins matching the repo - using functional filter
141-
matchingPins := sliceutil.Filter(actionPins, func(pin ActionPin) bool {
142-
return pin.Repo == actionRepo
143-
})
171+
// Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
172+
matchingPins := getActionPinsByRepo(actionRepo)
144173

145174
if len(matchingPins) == 0 {
146175
// If no pin exists, return empty string to signal that this action is not pinned
147176
return ""
148177
}
149178

150-
// Sort matching pins by version (descending - latest first) - immutable operation
151-
sortedPins := sortPinsByVersion(matchingPins)
152-
153-
// Return the latest version (first after sorting)
154-
latestPin := sortedPins[0]
179+
// The per-repo slice is already sorted by version descending; pick the first entry.
180+
latestPin := matchingPins[0]
155181
return formatActionReference(actionRepo, latestPin.SHA, latestPin.Version)
156182
}
157183

@@ -187,23 +213,18 @@ func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
187213
}
188214
}
189215

190-
// Dynamic resolution failed, try hardcoded pins
216+
// Dynamic resolution failed, try hardcoded pins.
217+
// Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
191218
actionPinsLog.Printf("Falling back to hardcoded pins for %s@%s", actionRepo, version)
192-
actionPins := getActionPins()
193-
194-
// Find all pins matching the repo - using functional filter
195-
matchingPins := sliceutil.Filter(actionPins, func(pin ActionPin) bool {
196-
return pin.Repo == actionRepo
197-
})
219+
matchingPins := getActionPinsByRepo(actionRepo)
198220

199221
if len(matchingPins) == 0 {
200222
// No pins found for this repo, will handle below
201223
actionPinsLog.Printf("No hardcoded pins found for %s", actionRepo)
202224
} else {
203225
actionPinsLog.Printf("Found %d hardcoded pin(s) for %s", len(matchingPins), actionRepo)
204226

205-
// Sort matching pins by version (descending - highest first) - immutable operation
206-
matchingPins = sortPinsByVersion(matchingPins)
227+
// The per-repo slice is already sorted by version descending; no extra sort needed.
207228

208229
// First, try to find an exact version match (for version tags)
209230
for _, pin := range matchingPins {
@@ -231,18 +252,21 @@ func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
231252
// Semver compatibility means respecting major version boundaries
232253
// (e.g., v5 -> highest v5.x.x, not v6.x.x)
233254
if !data.StrictMode && len(matchingPins) > 0 {
234-
// Filter for semver-compatible pins (matching major version) - using functional filter
235-
compatiblePins := sliceutil.Filter(matchingPins, func(pin ActionPin) bool {
236-
return isSemverCompatible(pin.Version, version)
237-
})
238-
239-
// If we found compatible pins, use the highest one (first after sorting)
240-
// Otherwise fall back to the highest overall pin
255+
// Find the first semver-compatible pin (already sorted descending, so first match is highest)
241256
var selectedPin ActionPin
242-
if len(compatiblePins) > 0 {
243-
selectedPin = compatiblePins[0]
257+
foundCompatible := false
258+
for _, pin := range matchingPins {
259+
if isSemverCompatible(pin.Version, version) {
260+
selectedPin = pin
261+
foundCompatible = true
262+
break
263+
}
264+
}
265+
266+
if foundCompatible {
244267
actionPinsLog.Printf("No exact match for version %s, using highest semver-compatible version: %s", version, selectedPin.Version)
245268
} else {
269+
// Fall back to the highest overall pin
246270
selectedPin = matchingPins[0]
247271
actionPinsLog.Printf("No exact match for version %s, no semver-compatible versions found, using highest available: %s", version, selectedPin.Version)
248272
}
@@ -409,20 +433,13 @@ func ApplyActionPinsToTypedSteps(steps []*WorkflowStep, data *WorkflowData) []*W
409433
// GetActionPinByRepo returns the ActionPin for a given repository, if it exists
410434
// When multiple versions exist for the same repo, it returns the latest version by semver
411435
func GetActionPinByRepo(repo string) (ActionPin, bool) {
412-
actionPins := getActionPins()
413-
414-
// Find all pins matching the repo - using functional filter
415-
matchingPins := sliceutil.Filter(actionPins, func(pin ActionPin) bool {
416-
return pin.Repo == repo
417-
})
436+
// Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
437+
matchingPins := getActionPinsByRepo(repo)
418438

419439
if len(matchingPins) == 0 {
420440
return ActionPin{}, false
421441
}
422442

423-
// Sort matching pins by version (descending - latest first) - immutable operation
424-
sortedPins := sortPinsByVersion(matchingPins)
425-
426-
// Return the latest version (first after sorting)
427-
return sortedPins[0], true
443+
// The per-repo slice is already sorted by version descending; pick the first entry.
444+
return matchingPins[0], true
428445
}

pkg/workflow/compiler.go

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
319319
return err
320320
}
321321

322+
// Parse permissions once for reuse in the three validation checks below.
323+
// WorkflowData.Permissions contains the raw YAML string (including "permissions:" prefix).
324+
// Always produces a non-nil *Permissions (ToPermissions never returns nil).
325+
cachedPermissions := NewPermissionsParser(workflowData.Permissions).ToPermissions()
326+
322327
// Validate permissions against GitHub MCP toolsets
323328
log.Printf("Validating permissions for GitHub MCP toolsets")
324329
if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil {
@@ -334,12 +339,8 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
334339
if hasPermissions && !workflowData.HasExplicitGitHubTool {
335340
log.Printf("Skipping permission validation: permissions exist but tools.github not explicitly configured")
336341
} else {
337-
// Parse permissions from the workflow data
338-
// WorkflowData.Permissions contains the raw YAML string (including "permissions:" prefix)
339-
permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions()
340-
341342
// Validate permissions using the typed GitHub tool configuration
342-
validationResult := ValidatePermissions(permissions, workflowData.ParsedTools.GitHub)
343+
validationResult := ValidatePermissions(cachedPermissions, workflowData.ParsedTools.GitHub)
343344

344345
if validationResult.HasValidationIssues {
345346
// Format the validation message
@@ -361,18 +362,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
361362

362363
// Emit warning if id-token: write permission is detected
363364
log.Printf("Checking for id-token: write permission")
364-
if workflowData.Permissions != "" {
365-
permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions()
366-
if permissions != nil {
367-
level, exists := permissions.Get(PermissionIdToken)
368-
if exists && level == PermissionWrite {
369-
warningMsg := `This workflow grants id-token: write permission
365+
if level, exists := cachedPermissions.Get(PermissionIdToken); exists && level == PermissionWrite {
366+
warningMsg := `This workflow grants id-token: write permission
370367
OIDC tokens can authenticate to cloud providers (AWS, Azure, GCP).
371368
Ensure proper audience validation and trust policies are configured.`
372-
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", warningMsg))
373-
c.IncrementWarningCount()
374-
}
375-
}
369+
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", warningMsg))
370+
c.IncrementWarningCount()
376371
}
377372

378373
// Validate GitHub tools against enabled toolsets
@@ -399,11 +394,8 @@ Ensure proper audience validation and trust policies are configured.`
399394
// Validate permissions for agentic-workflows tool
400395
log.Printf("Validating permissions for agentic-workflows tool")
401396
if _, hasAgenticWorkflows := workflowData.Tools["agentic-workflows"]; hasAgenticWorkflows {
402-
// Parse permissions from the workflow data
403-
permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions()
404-
405397
// Check if actions: read permission exists
406-
actionsLevel, hasActions := permissions.Get(PermissionActions)
398+
actionsLevel, hasActions := cachedPermissions.Get(PermissionActions)
407399
if !hasActions || actionsLevel == PermissionNone {
408400
// Missing actions: read permission
409401
message := "ERROR: Missing required permission for agentic-workflows tool:\n"

0 commit comments

Comments
 (0)