Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 3 additions & 21 deletions pkg/cli/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,15 @@ import (
"fmt"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot remove thin wrappers

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the thin wrapper extractSecretName() function. Now directly calling workflow.ExtractSecretName() in both secrets.go and secrets_test.go. (9450c8b)

"os"
"os/exec"
"regexp"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/parser"
"github.com/githubnext/gh-aw/pkg/workflow"
)

var secretsLog = logger.New("cli:secrets")

// Pre-compiled regexes for secret extraction (performance optimization)
var (
secretPattern = regexp.MustCompile(`\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*(?:\|\|.*?)?\s*\}\}`)
)

// SecretInfo contains information about a required secret
type SecretInfo struct {
Name string // Secret name (e.g., "DD_API_KEY")
Expand Down Expand Up @@ -64,19 +59,6 @@ func checkSecretExists(secretName string) (bool, error) {
return false, nil
}

// extractSecretName extracts the secret name from a GitHub Actions expression
// Examples: "${{ secrets.DD_API_KEY }}" -> "DD_API_KEY"
//
// "${{ secrets.DD_SITE || 'datadoghq.com' }}" -> "DD_SITE"
func extractSecretName(value string) string {
// Match pattern: ${{ secrets.SECRET_NAME }} or ${{ secrets.SECRET_NAME || 'default' }}
matches := secretPattern.FindStringSubmatch(value)
if len(matches) >= 2 {
return matches[1]
}
return ""
}

// extractSecretsFromConfig extracts all required secrets from an MCP server config
func extractSecretsFromConfig(config parser.MCPServerConfig) []SecretInfo {
secretsLog.Printf("Extracting secrets from MCP config: command=%s", config.Command)
Expand All @@ -85,7 +67,7 @@ func extractSecretsFromConfig(config parser.MCPServerConfig) []SecretInfo {

// Extract from HTTP headers
for key, value := range config.Headers {
secretName := extractSecretName(value)
secretName := workflow.ExtractSecretName(value)
if secretName != "" && !seen[secretName] {
secrets = append(secrets, SecretInfo{
Name: secretName,
Expand All @@ -97,7 +79,7 @@ func extractSecretsFromConfig(config parser.MCPServerConfig) []SecretInfo {

// Extract from environment variables
for key, value := range config.Env {
secretName := extractSecretName(value)
secretName := workflow.ExtractSecretName(value)
if secretName != "" && !seen[secretName] {
secrets = append(secrets, SecretInfo{
Name: secretName,
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/githubnext/gh-aw/pkg/parser"
"github.com/githubnext/gh-aw/pkg/workflow"
)

func TestExtractSecretName(t *testing.T) {
Expand Down Expand Up @@ -47,7 +48,7 @@ func TestExtractSecretName(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractSecretName(tt.value)
result := workflow.ExtractSecretName(tt.value)
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
Expand Down
82 changes: 3 additions & 79 deletions pkg/workflow/mcp-config.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
// Extract secrets from headers for HTTP MCP tools (copilot engine only)
var headerSecrets map[string]string
if mcpConfig.Type == "http" && renderer.RequiresCopilotFields {
headerSecrets = extractSecretsFromHeaders(mcpConfig.Headers)
headerSecrets = ExtractSecretsFromMap(mcpConfig.Headers)
}

// Determine properties based on type
Expand Down Expand Up @@ -545,7 +545,7 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
// Replace secret expressions with env var references for copilot
headerValue := mcpConfig.Headers[headerKey]
if renderer.RequiresCopilotFields && len(headerSecrets) > 0 {
headerValue = replaceSecretsWithEnvVars(headerValue, headerSecrets)
headerValue = ReplaceSecretsWithEnvVars(headerValue, headerSecrets)
}

fmt.Fprintf(yaml, "%s \"%s\": \"%s\"%s\n", renderer.IndentLevel, headerKey, headerValue, headerComma)
Expand Down Expand Up @@ -650,82 +650,6 @@ func (m MapToolConfig) GetAny(key string) (any, bool) {
return value, exists
}

// extractSecretsFromValue extracts GitHub Actions secret expressions from a string value
// Returns a map of environment variable names to their secret expressions
// Example: "${{ secrets.DD_API_KEY }}" -> {"DD_API_KEY": "${{ secrets.DD_API_KEY }}"}
// Example: "${{ secrets.DD_SITE || 'datadoghq.com' }}" -> {"DD_SITE": "${{ secrets.DD_SITE || 'datadoghq.com' }}"}
func extractSecretsFromValue(value string) map[string]string {
secrets := make(map[string]string)

// Pattern to match ${{ secrets.VARIABLE_NAME }} or ${{ secrets.VARIABLE_NAME || 'default' }}
// We need to extract the variable name and the full expression
start := 0
for {
// Find the start of an expression
startIdx := strings.Index(value[start:], "${{ secrets.")
if startIdx == -1 {
break
}
startIdx += start

// Find the end of the expression
endIdx := strings.Index(value[startIdx:], "}}")
if endIdx == -1 {
break
}
endIdx += startIdx + 2 // Include the closing }}

// Extract the full expression
fullExpr := value[startIdx:endIdx]

// Extract the variable name from "secrets.VARIABLE_NAME" or "secrets.VARIABLE_NAME ||"
secretsPart := strings.TrimPrefix(fullExpr, "${{ secrets.")
secretsPart = strings.TrimSuffix(secretsPart, "}}")
secretsPart = strings.TrimSpace(secretsPart)

// Find the variable name (everything before space, ||, or end)
varName := secretsPart
if spaceIdx := strings.IndexAny(varName, " |"); spaceIdx != -1 {
varName = varName[:spaceIdx]
}

// Store the variable name and full expression
if varName != "" {
secrets[varName] = fullExpr
}

start = endIdx
}

return secrets
}

// extractSecretsFromHeaders extracts all secrets from HTTP MCP headers
// Returns a map of environment variable names to their secret expressions
func extractSecretsFromHeaders(headers map[string]string) map[string]string {
allSecrets := make(map[string]string)

for _, headerValue := range headers {
secrets := extractSecretsFromValue(headerValue)
for varName, expr := range secrets {
allSecrets[varName] = expr
}
}

return allSecrets
}

// replaceSecretsWithEnvVars replaces secret expressions in a value with environment variable references
// Example: "${{ secrets.DD_API_KEY }}" -> "\${DD_API_KEY}"
func replaceSecretsWithEnvVars(value string, secrets map[string]string) string {
result := value
for varName, secretExpr := range secrets {
// Replace ${{ secrets.VAR }} with \${VAR} (backslash-escaped for copilot JSON config)
result = strings.ReplaceAll(result, secretExpr, "\\${"+varName+"}")
}
return result
}

// collectHTTPMCPHeaderSecrets collects all secrets from HTTP MCP tool headers
// Returns a map of environment variable names to their secret expressions
func collectHTTPMCPHeaderSecrets(tools map[string]any) map[string]string {
Expand All @@ -737,7 +661,7 @@ func collectHTTPMCPHeaderSecrets(tools map[string]any) map[string]string {
if hasMcp, mcpType := hasMCPConfig(toolConfig); hasMcp && mcpType == "http" {
// Extract MCP config to get headers
if mcpConfig, err := getMCPConfig(toolConfig, toolName); err == nil {
secrets := extractSecretsFromHeaders(mcpConfig.Headers)
secrets := ExtractSecretsFromMap(mcpConfig.Headers)
for varName, expr := range secrets {
allSecrets[varName] = expr
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/workflow/mcp_http_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestExtractSecretsFromValue(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractSecretsFromValue(tt.value)
result := ExtractSecretsFromValue(tt.value)

if len(result) != len(tt.expected) {
t.Errorf("Expected %d secrets, got %d", len(tt.expected), len(result))
Expand All @@ -72,7 +72,7 @@ func TestExtractSecretsFromHeaders(t *testing.T) {
"Static": "no-secrets-here",
}

result := extractSecretsFromHeaders(headers)
result := ExtractSecretsFromMap(headers)

expected := map[string]string{
"DD_API_KEY": "${{ secrets.DD_API_KEY }}",
Expand Down Expand Up @@ -134,7 +134,7 @@ func TestReplaceSecretsWithEnvVars(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := replaceSecretsWithEnvVars(tt.value, tt.secrets)
result := ReplaceSecretsWithEnvVars(tt.value, tt.secrets)
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
Expand Down
112 changes: 112 additions & 0 deletions pkg/workflow/secret_extraction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package workflow

import (
"regexp"
"strings"
)

// Pre-compiled regex for secret extraction (performance optimization)
// Matches: ${{ secrets.SECRET_NAME }} or ${{ secrets.SECRET_NAME || 'default' }}
var secretExprPattern = regexp.MustCompile(`\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*(?:\|\|.*?)?\s*\}\}`)

// SecretExpression represents a parsed secret expression
type SecretExpression struct {
VarName string // The secret variable name (e.g., "DD_API_KEY")
FullExpr string // The full expression (e.g., "${{ secrets.DD_API_KEY }}")
}
Comment on lines +12 to +16
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SecretExpression struct is defined but never used anywhere in the codebase. This is dead code that should be removed.

Suggested change
// SecretExpression represents a parsed secret expression
type SecretExpression struct {
VarName string // The secret variable name (e.g., "DD_API_KEY")
FullExpr string // The full expression (e.g., "${{ secrets.DD_API_KEY }}")
}

Copilot uses AI. Check for mistakes.

// ExtractSecretName extracts just the secret name from a GitHub Actions expression
// Examples:
// - "${{ secrets.DD_API_KEY }}" -> "DD_API_KEY"
// - "${{ secrets.DD_SITE || 'datadoghq.com' }}" -> "DD_SITE"
// - "plain value" -> ""
func ExtractSecretName(value string) string {
matches := secretExprPattern.FindStringSubmatch(value)
if len(matches) >= 2 {
return matches[1]
}
return ""
}

// ExtractSecretsFromValue extracts all GitHub Actions secret expressions from a string value
// Returns a map of environment variable names to their full secret expressions
// Examples:
// - "${{ secrets.DD_API_KEY }}" -> {"DD_API_KEY": "${{ secrets.DD_API_KEY }}"}
// - "${{ secrets.DD_SITE || 'datadoghq.com' }}" -> {"DD_SITE": "${{ secrets.DD_SITE || 'datadoghq.com' }}"}
// - "Bearer ${{ secrets.TOKEN }}" -> {"TOKEN": "${{ secrets.TOKEN }}"}
func ExtractSecretsFromValue(value string) map[string]string {
secrets := make(map[string]string)

// Pattern to match ${{ secrets.VARIABLE_NAME }} or ${{ secrets.VARIABLE_NAME || 'default' }}
// We need to extract the variable name and the full expression
start := 0
for {
// Find the start of an expression
startIdx := strings.Index(value[start:], "${{ secrets.")
if startIdx == -1 {
break
}
startIdx += start

// Find the end of the expression
endIdx := strings.Index(value[startIdx:], "}}")
if endIdx == -1 {
break
}
endIdx += startIdx + 2 // Include the closing }}

// Extract the full expression
fullExpr := value[startIdx:endIdx]

// Extract the variable name from "secrets.VARIABLE_NAME" or "secrets.VARIABLE_NAME ||"
secretsPart := strings.TrimPrefix(fullExpr, "${{ secrets.")
secretsPart = strings.TrimSuffix(secretsPart, "}}")
secretsPart = strings.TrimSpace(secretsPart)

// Find the variable name (everything before space, ||, or end)
varName := secretsPart
if spaceIdx := strings.IndexAny(varName, " |"); spaceIdx != -1 {
varName = varName[:spaceIdx]
}

// Store the variable name and full expression
if varName != "" {
secrets[varName] = fullExpr
}

start = endIdx
}

return secrets
}

// ExtractSecretsFromMap extracts all secrets from a map of string values
// Returns a map of environment variable names to their full secret expressions
// Example:
//
// Input: {"DD_API_KEY": "${{ secrets.DD_API_KEY }}", "DD_SITE": "${{ secrets.DD_SITE || 'default' }}"}
// Output: {"DD_API_KEY": "${{ secrets.DD_API_KEY }}", "DD_SITE": "${{ secrets.DD_SITE || 'default' }}"}
func ExtractSecretsFromMap(values map[string]string) map[string]string {
allSecrets := make(map[string]string)

for _, value := range values {
secrets := ExtractSecretsFromValue(value)
for varName, expr := range secrets {
allSecrets[varName] = expr
}
}

return allSecrets
}

// ReplaceSecretsWithEnvVars replaces secret expressions in a value with environment variable references
// Example: "${{ secrets.DD_API_KEY }}" -> "\${DD_API_KEY}"
// The backslash is used to escape the ${} for proper JSON rendering in Copilot configs
func ReplaceSecretsWithEnvVars(value string, secrets map[string]string) string {
result := value
for varName, secretExpr := range secrets {
// Replace ${{ secrets.VAR }} with \${VAR} (backslash-escaped for copilot JSON config)
result = strings.ReplaceAll(result, secretExpr, "\\${"+varName+"}")
}
return result
}
Loading
Loading