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
20 changes: 10 additions & 10 deletions pkg/cli/copilot_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import (

var copilotCodingAgentLog = logger.New("cli:copilot_agent")

// agentLogPatterns contains patterns that indicate GitHub Copilot coding agent (not Copilot CLI)
var agentLogPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)github.*copilot.*agent`),
regexp.MustCompile(`(?i)copilot-swe-agent`),
regexp.MustCompile(`(?i)@github/copilot-swe-agent`),
regexp.MustCompile(`(?i)agent.*task.*execution`),
regexp.MustCompile(`(?i)copilot.*agent.*v\d+\.\d+`),
}

// CopilotCodingAgentDetector contains heuristics to detect if a workflow run was executed by GitHub Copilot coding agent
type CopilotCodingAgentDetector struct {
runDir string
Expand Down Expand Up @@ -97,15 +106,6 @@ func (d *CopilotCodingAgentDetector) hasAgentWorkflowPath() bool {

// hasAgentLogPatterns checks log files for patterns specific to GitHub Copilot coding agent
func (d *CopilotCodingAgentDetector) hasAgentLogPatterns() bool {
// Patterns that indicate GitHub Copilot coding agent (not Copilot CLI)
agentPatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)github.*copilot.*agent`),
regexp.MustCompile(`(?i)copilot-swe-agent`),
regexp.MustCompile(`(?i)@github/copilot-swe-agent`),
regexp.MustCompile(`(?i)agent.*task.*execution`),
regexp.MustCompile(`(?i)copilot.*agent.*v\d+\.\d+`),
}

found := false
// Check log files for agent-specific patterns
_ = filepath.Walk(d.runDir, func(path string, info os.FileInfo, err error) error {
Expand All @@ -121,7 +121,7 @@ func (d *CopilotCodingAgentDetector) hasAgentLogPatterns() bool {
return nil
}

for _, pattern := range agentPatterns {
for _, pattern := range agentLogPatterns {
if pattern.MatchString(content) {
if d.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(
Expand Down
29 changes: 14 additions & 15 deletions pkg/cli/copilot_agent_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import (
"github.com/github/gh-aw/pkg/workflow"
)

var (
agentTurnPattern = regexp.MustCompile(`(?i)task.*iteration|agent.*turn|step.*\d+`)
agentToolCallPattern = regexp.MustCompile(`(?i)tool.*call|executing.*tool|calling.*(\w+)`)
toolNamePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)tool[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)calling[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)executing[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)using[:\s]+tool[:\s]+([a-zA-Z0-9_-]+)`),
}
)

// ParseCopilotCodingAgentLogMetrics extracts metrics from GitHub Copilot coding agent logs
// This is different from Copilot CLI logs and requires specialized parsing
func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow.LogMetrics {
Expand All @@ -20,19 +31,14 @@ func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow
var currentSequence []string
turns := 0

// GitHub Copilot coding agent log patterns
// These patterns are designed to match the specific log format of the agent
turnPattern := regexp.MustCompile(`(?i)task.*iteration|agent.*turn|step.*\d+`)
toolCallPattern := regexp.MustCompile(`(?i)tool.*call|executing.*tool|calling.*(\w+)`)

for _, line := range lines {
// Skip empty lines
if strings.TrimSpace(line) == "" {
continue
}

// Count turns based on agent iteration patterns
if turnPattern.MatchString(line) {
if agentTurnPattern.MatchString(line) {
turns++
// Start of a new turn, save previous sequence if any
if len(currentSequence) > 0 {
Expand All @@ -42,7 +48,7 @@ func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow
}

// Extract tool calls from agent logs
if matches := toolCallPattern.FindStringSubmatch(line); len(matches) > 1 {
if matches := agentToolCallPattern.FindStringSubmatch(line); len(matches) > 1 {
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

Tool-call detection is gated on FindStringSubmatch returning a capture group (len(matches) > 1), but agentToolCallPattern has alternations that don’t define any capturing group (e.g., tool.*call, executing.*tool). Lines matching those branches will never enter this block, so tool calls will be missed. Use agentToolCallPattern.MatchString(line) (or update the regex to always capture) since the tool name is extracted separately by extractToolName.

Suggested change
if matches := agentToolCallPattern.FindStringSubmatch(line); len(matches) > 1 {
if agentToolCallPattern.MatchString(line) {

Copilot uses AI. Check for mistakes.
toolName := extractToolName(line)
if toolName != "" {
// Track tool call
Expand Down Expand Up @@ -97,14 +103,7 @@ func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow
// extractToolName extracts a tool name from a log line
func extractToolName(line string) string {
// Try to extract tool name from various patterns
patterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)tool[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)calling[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)executing[:\s]+([a-zA-Z0-9_-]+)`),
regexp.MustCompile(`(?i)using[:\s]+tool[:\s]+([a-zA-Z0-9_-]+)`),
}

for _, pattern := range patterns {
for _, pattern := range toolNamePatterns {
if matches := pattern.FindStringSubmatch(line); len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
Expand Down
14 changes: 6 additions & 8 deletions pkg/workflow/action_sha_checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import (

var actionSHACheckerLog = logger.New("workflow:action_sha_checker")

// actionUsesPattern matches action references in lock files:
// owner/repo@40-char-hex-sha with optional version comment
var actionUsesPattern = regexp.MustCompile(`([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([0-9a-f]{40})(?:\s*#\s*([^\s]+))?`)

// ActionUsage represents an action used in a workflow with its SHA
type ActionUsage struct {
Repo string // e.g., "actions/checkout"
Expand Down Expand Up @@ -47,16 +51,10 @@ func ExtractActionsFromLockFile(lockFilePath string) ([]ActionUsage, error) {
return nil, fmt.Errorf("failed to parse lock file YAML: %w", err)
}

// Regular expression to match uses: owner/repo@sha with optional version comment
// This matches: owner/repo@40-char-hex-sha # version
// Captures: (1) repo, (2) sha, (3) version (optional)
usesPattern := regexp.MustCompile(`([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([0-9a-f]{40})(?:\s*#\s*([^\s]+))?`)

actions := make(map[string]ActionUsage) // Use map to deduplicate

// Convert to string and extract all uses fields
contentStr := string(content)
matches := usesPattern.FindAllStringSubmatch(contentStr, -1)
actions := make(map[string]ActionUsage) // Use map to deduplicate
matches := actionUsesPattern.FindAllStringSubmatch(contentStr, -1)

for _, match := range matches {
if len(match) >= 3 {
Expand Down
11 changes: 7 additions & 4 deletions pkg/workflow/concurrency_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import (

var concurrencyValidationLog = newValidationLogger("concurrency")

var (
concurrencyExpressionPattern = regexp.MustCompile(`\$\{\{([^}]*)\}\}`)
concurrencyGroupPattern = regexp.MustCompile(`(?m)^\s*group:\s*["']?([^"'\n]+?)["']?\s*$`)
)

// validateConcurrencyGroupExpression validates the syntax of a custom concurrency group expression.
// It checks for common syntactic errors that would cause runtime failures:
// - Unbalanced ${{ }} braces
Expand Down Expand Up @@ -125,8 +130,7 @@ func validateBalancedBraces(group string) error {
// validateExpressionSyntax validates the syntax of expressions within ${{ }}
func validateExpressionSyntax(group string) error {
// Pattern to extract content between ${{ and }}
expressionPattern := regexp.MustCompile(`\$\{\{([^}]*)\}\}`)
matches := expressionPattern.FindAllStringSubmatch(group, -1)
matches := concurrencyExpressionPattern.FindAllStringSubmatch(group, -1)

concurrencyValidationLog.Printf("Found %d expression(s) to validate", len(matches))

Expand Down Expand Up @@ -300,8 +304,7 @@ func containsLogicalOperators(expr string) bool {
func extractConcurrencyGroupFromYAML(concurrencyYAML string) string {
// First, check if it's object format with explicit "group:" field
// Pattern: group: "value" or group: 'value' or group: value (at start of line or after spaces)
groupPattern := regexp.MustCompile(`(?m)^\s*group:\s*["']?([^"'\n]+?)["']?\s*$`)
matches := groupPattern.FindStringSubmatch(concurrencyYAML)
matches := concurrencyGroupPattern.FindStringSubmatch(concurrencyYAML)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
Expand Down
11 changes: 7 additions & 4 deletions pkg/workflow/lock_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import (

var lockSchemaLog = logger.New("workflow:lock_schema")

var (
lockMetadataPattern = regexp.MustCompile(`#\s*gh-aw-metadata:\s*(\{.+\})`)
lockHashPattern = regexp.MustCompile(`#\s*frontmatter-hash:\s*([0-9a-f]{64})`)
)

// LockSchemaVersion represents a lock file schema version
type LockSchemaVersion string

Expand Down Expand Up @@ -47,8 +52,7 @@ func IsSchemaVersionSupported(version LockSchemaVersion) bool {
func ExtractMetadataFromLockFile(content string) (*LockMetadata, bool, error) {
// Look for JSON metadata in comments (format: # gh-aw-metadata: {...})
// Use .+ to capture to end of line since metadata is single-line JSON
metadataPattern := regexp.MustCompile(`#\s*gh-aw-metadata:\s*(\{.+\})`)
matches := metadataPattern.FindStringSubmatch(content)
matches := lockMetadataPattern.FindStringSubmatch(content)

if len(matches) >= 2 {
jsonStr := matches[1]
Expand All @@ -61,8 +65,7 @@ func ExtractMetadataFromLockFile(content string) (*LockMetadata, bool, error) {
}

// Legacy format: look for frontmatter-hash without JSON metadata
hashPattern := regexp.MustCompile(`#\s*frontmatter-hash:\s*([0-9a-f]{64})`)
if matches := hashPattern.FindStringSubmatch(content); len(matches) >= 2 {
if matches := lockHashPattern.FindStringSubmatch(content); len(matches) >= 2 {
lockSchemaLog.Print("Legacy lock file detected (no schema version)")
// Return a minimal metadata struct with just the hash for legacy files
return &LockMetadata{FrontmatterHash: matches[1]}, true, nil
Expand Down
7 changes: 4 additions & 3 deletions pkg/workflow/redact_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (

var secretMaskingLog = logger.New("workflow:secret_masking")

// secretReferencePattern matches ${{ secrets.SECRET_NAME }} or secrets.SECRET_NAME
var secretReferencePattern = regexp.MustCompile(`secrets\.([A-Z][A-Z0-9_]*)`)

// escapeSingleQuote escapes single quotes and backslashes in a string to prevent injection
// when embedding data in single-quoted YAML strings
func escapeSingleQuote(s string) string {
Expand All @@ -28,9 +31,7 @@ func CollectSecretReferences(yamlContent string) []string {

// Pattern to match ${{ secrets.SECRET_NAME }} or secrets.SECRET_NAME
// This matches both with and without the ${{ }} wrapper
secretPattern := regexp.MustCompile(`secrets\.([A-Z][A-Z0-9_]*)`)

matches := secretPattern.FindAllStringSubmatch(yamlContent, -1)
matches := secretReferencePattern.FindAllStringSubmatch(yamlContent, -1)
for _, match := range matches {
if len(match) > 1 {
secretsMap[match[1]] = true
Expand Down
4 changes: 3 additions & 1 deletion pkg/workflow/secrets_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ var secretsValidationLog = newValidationLogger("secrets")
// This is the same pattern used in the github_token schema definition ($defs/github_token).
var secretsExpressionPattern = regexp.MustCompile(`^\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*\s*\}\}$`)

// secretNamePattern validates that a secret name follows environment variable naming conventions
var secretNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)

// validateSecretsExpression validates that a value is a proper GitHub Actions secrets expression.
// Returns an error if the value is not in the format: ${{ secrets.NAME }} or ${{ secrets.NAME || secrets.NAME2 }}
// Note: This function intentionally does not accept the secret key name as a parameter to prevent
Expand All @@ -29,7 +32,6 @@ func validateSecretsExpression(value string) error {
func validateSecretReferences(secrets []string) error {
secretsValidationLog.Printf("Validating secret references: checking %d secrets", len(secrets))
// Secret names must be valid environment variable names
secretNamePattern := regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)

for _, secret := range secrets {
if !secretNamePattern.MatchString(secret) {
Expand Down
Loading