diff --git a/pkg/parser/schema.go b/pkg/parser/schema.go index b7228d17466..5e5f8323b3a 100644 --- a/pkg/parser/schema.go +++ b/pkg/parser/schema.go @@ -23,12 +23,16 @@ var mcpConfigSchema string // ValidateMainWorkflowFrontmatterWithSchema validates main workflow frontmatter using JSON schema func ValidateMainWorkflowFrontmatterWithSchema(frontmatter map[string]any) error { - return validateWithSchema(frontmatter, mainWorkflowSchema, "main workflow file") + // Remove internal-only fields from validation + cleanedFrontmatter := removeInternalFields(frontmatter) + return validateWithSchema(cleanedFrontmatter, mainWorkflowSchema, "main workflow file") } // ValidateMainWorkflowFrontmatterWithSchemaAndLocation validates main workflow frontmatter with file location info func ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter map[string]any, filePath string) error { - return validateWithSchemaAndLocation(frontmatter, mainWorkflowSchema, "main workflow file", filePath) + // Remove internal-only fields from validation + cleanedFrontmatter := removeInternalFields(frontmatter) + return validateWithSchemaAndLocation(cleanedFrontmatter, mainWorkflowSchema, "main workflow file", filePath) } // ValidateIncludedFileFrontmatterWithSchema validates included file frontmatter using JSON schema @@ -203,3 +207,21 @@ func min(a, b int) int { } return b } + +// removeInternalFields creates a copy of the frontmatter without internal-only fields +// that should not be validated against the public schema +func removeInternalFields(frontmatter map[string]any) map[string]any { + if frontmatter == nil { + return nil + } + + // Create a copy of the frontmatter + cleaned := make(map[string]any) + for key, value := range frontmatter { + // Currently no internal-only fields need to be filtered + // This function is kept for future extensibility + cleaned[key] = value + } + + return cleaned +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index ce84ed76c78..5c68d77576d 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -721,7 +721,7 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) } // Apply defaults - c.applyDefaults(workflowData, markdownPath) + c.applyDefaults(workflowData, markdownPath, result.Frontmatter) // Apply pull request draft filter if specified c.applyPullRequestDraftFilter(workflowData, result.Frontmatter) @@ -881,7 +881,7 @@ func (c *Compiler) extractAliasName(frontmatter map[string]any) string { } // applyDefaults applies default values for missing workflow sections -func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) { +func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string, frontmatter map[string]any) { // Check if this is an alias trigger workflow (by checking if user specified "on.alias") isAliasTrigger := false if data.On == "" { @@ -988,8 +988,8 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) { models: read` } - // Generate concurrency configuration using the dedicated concurrency module - data.Concurrency = GenerateConcurrencyConfig(data, isAliasTrigger) + // Generate concurrency configuration using the new policy system + data.Concurrency = GenerateConcurrencyConfigWithFrontmatter(data, isAliasTrigger, frontmatter, c.verbose) if data.RunName == "" { data.RunName = fmt.Sprintf(`run-name: "%s"`, data.Name) diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index 0809f124501..d10e86dd4e0 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -5,13 +5,48 @@ import ( ) // GenerateConcurrencyConfig generates the concurrency configuration for a workflow -// based on its trigger types and characteristics. +// based on its trigger types and characteristics. Now supports advanced policy computation. func GenerateConcurrencyConfig(workflowData *WorkflowData, isAliasTrigger bool) string { - // Don't override if already set + // Don't override if already set by user if workflowData.Concurrency != "" { return workflowData.Concurrency } + // Try to use the new policy system first + return generateConcurrencyWithPolicySystem(workflowData, isAliasTrigger) +} + +// GenerateConcurrencyConfigWithFrontmatter generates concurrency config using the policy system +// This function maintains the same interface but no longer parses frontmatter for policies +func GenerateConcurrencyConfigWithFrontmatter(workflowData *WorkflowData, isAliasTrigger bool, frontmatter map[string]any, verbose bool) string { + // Don't override if already set by user + if workflowData.Concurrency != "" { + return workflowData.Concurrency + } + + // Use the policy system with code-based rules only + return generateConcurrencyWithPolicySystem(workflowData, isAliasTrigger) +} + +// generateConcurrencyWithPolicySystem uses the policy system with code-based rules only +func generateConcurrencyWithPolicySystem(workflowData *WorkflowData, isAliasTrigger bool) string { + // Compute policy using code-based rules only + computed, err := computeConcurrencyPolicy(workflowData, isAliasTrigger) + if err != nil { + // Fall back to legacy behavior if policy system fails + return generateLegacyConcurrency(workflowData, isAliasTrigger) + } + + yaml := generateConcurrencyYAML(computed) + if yaml == "" { + return generateLegacyConcurrency(workflowData, isAliasTrigger) + } + + return yaml +} + +// generateLegacyConcurrency provides the original concurrency generation logic as fallback +func generateLegacyConcurrency(workflowData *WorkflowData, isAliasTrigger bool) string { // Generate concurrency configuration based on workflow type // Note: Check alias trigger first since alias workflows also contain pull_request events if isAliasTrigger { diff --git a/pkg/workflow/concurrency_policy.go b/pkg/workflow/concurrency_policy.go new file mode 100644 index 00000000000..3b19837ed18 --- /dev/null +++ b/pkg/workflow/concurrency_policy.go @@ -0,0 +1,200 @@ +package workflow + +import ( + "fmt" + "strings" +) + +// ConcurrencyPolicy represents a single concurrency policy definition +type ConcurrencyPolicy struct { + Group string `json:"group" yaml:"group"` + Node string `json:"node" yaml:"node"` + CancelInProgress *bool `json:"cancel-in-progress,omitempty" yaml:"cancel-in-progress,omitempty"` +} + +// ConcurrencyPolicySet represents a set of policies for different contexts +type ConcurrencyPolicySet struct { + Default *ConcurrencyPolicy `json:"*,omitempty" yaml:"*,omitempty"` + Issues *ConcurrencyPolicy `json:"issues,omitempty" yaml:"issues,omitempty"` + PullRequest *ConcurrencyPolicy `json:"pull_requests,omitempty" yaml:"pull_requests,omitempty"` + Schedule *ConcurrencyPolicy `json:"schedule,omitempty" yaml:"schedule,omitempty"` + Manual *ConcurrencyPolicy `json:"workflow_dispatch,omitempty" yaml:"workflow_dispatch,omitempty"` + Custom map[string]*ConcurrencyPolicy `json:"-" yaml:"-"` // for any other trigger types +} + +// ComputedConcurrencyPolicy represents the final computed concurrency configuration +type ComputedConcurrencyPolicy struct { + Group string + CancelInProgress *bool +} + +// computeConcurrencyPolicy computes the final concurrency configuration based on workflow characteristics +func computeConcurrencyPolicy(workflowData *WorkflowData, isAliasTrigger bool) (*ComputedConcurrencyPolicy, error) { + // Get default policies based on workflow characteristics + policySet := getDefaultPolicySet(isAliasTrigger) + + // Determine which specific policy to use based on workflow triggers + selectedPolicy := selectPolicyForWorkflow(workflowData, isAliasTrigger, policySet) + + // Compute the final concurrency configuration + computed := &ComputedConcurrencyPolicy{} + + if selectedPolicy != nil { + // Build the group identifier + computed.Group = buildGroupIdentifier(selectedPolicy, workflowData) + computed.CancelInProgress = selectedPolicy.CancelInProgress + } else { + // Fallback to basic group + computed.Group = "gh-aw-${{ github.workflow }}" + } + + return computed, nil +} + +// getDefaultPolicySet returns the default policy set based on workflow characteristics +func getDefaultPolicySet(isAliasTrigger bool) *ConcurrencyPolicySet { + policySet := &ConcurrencyPolicySet{ + Custom: make(map[string]*ConcurrencyPolicy), + } + + // Default policy for all workflows + policySet.Default = &ConcurrencyPolicy{ + Group: "workflow", + Node: "", + } + + // Issues policy with cancel + cancelTrue := true + policySet.Issues = &ConcurrencyPolicy{ + Group: "workflow", + Node: "issue.number || github.event.pull_request.number", // Support both issue and PR for alias + CancelInProgress: &cancelTrue, + } + + // Pull request policy with cancel (use ref for backwards compatibility with existing tests) + policySet.PullRequest = &ConcurrencyPolicy{ + Group: "workflow", + Node: "github.ref", // Use ref instead of pull_request.number for compatibility + CancelInProgress: &cancelTrue, + } + + // For alias triggers, override to not use cancellation + if isAliasTrigger { + policySet.Issues.CancelInProgress = nil + policySet.PullRequest.CancelInProgress = nil + } + + return policySet +} + +// selectPolicyForWorkflow selects the most appropriate policy for the given workflow +func selectPolicyForWorkflow(workflowData *WorkflowData, isAliasTrigger bool, policySet *ConcurrencyPolicySet) *ConcurrencyPolicy { + if isAliasTrigger { + // For alias workflows, prefer issues policy if available + if policySet.Issues != nil { + return policySet.Issues + } + } + + // Check if this is a pull request workflow + if isPullRequestWorkflow(workflowData.On) { + if policySet.PullRequest != nil { + return policySet.PullRequest + } + } + + // Check for schedule workflows + if strings.Contains(workflowData.On, "schedule") { + if policySet.Schedule != nil { + return policySet.Schedule + } + } + + // Check for manual workflows + if strings.Contains(workflowData.On, "workflow_dispatch") { + if policySet.Manual != nil { + return policySet.Manual + } + } + + // Check for issues workflows + if strings.Contains(workflowData.On, "issues") { + if policySet.Issues != nil { + return policySet.Issues + } + } + + // Check for custom trigger types in the policy custom map + for triggerType, customPolicy := range policySet.Custom { + if strings.Contains(workflowData.On, triggerType) { + return customPolicy + } + } + + // Fall back to default policy + return policySet.Default +} + +// buildGroupIdentifier constructs the final group identifier string +func buildGroupIdentifier(policy *ConcurrencyPolicy, workflowData *WorkflowData) string { + if policy == nil { + return "gh-aw-${{ github.workflow }}" + } + + // Start with the base group + var parts []string + + // Always include the gh-aw prefix + parts = append(parts, "gh-aw") + + // Add the workflow identifier + if policy.Group == "workflow" { + parts = append(parts, "${{ github.workflow }}") + } else { + // Use custom group identifier + parts = append(parts, policy.Group) + } + + // Add the node identifier if specified + if policy.Node != "" { + var nodeExpr string + switch policy.Node { + case "issue.number": + nodeExpr = "${{ github.event.issue.number }}" + case "pull_request.number": + nodeExpr = "${{ github.event.pull_request.number }}" + case "github.ref": + nodeExpr = "${{ github.ref }}" + case "issue.number || github.event.pull_request.number": + // Special case for alias workflows + nodeExpr = "${{ github.event.issue.number || github.event.pull_request.number }}" + default: + // Custom node expression + if strings.HasPrefix(policy.Node, "${{") { + nodeExpr = policy.Node + } else { + nodeExpr = fmt.Sprintf("${{ %s }}", policy.Node) + } + } + parts = append(parts, nodeExpr) + } + + return strings.Join(parts, "-") +} + +// generateConcurrencyYAML generates the final YAML for the concurrency section +func generateConcurrencyYAML(computed *ComputedConcurrencyPolicy) string { + if computed == nil { + return "" + } + + var lines []string + lines = append(lines, "concurrency:") + lines = append(lines, fmt.Sprintf(" group: \"%s\"", computed.Group)) + + if computed.CancelInProgress != nil && *computed.CancelInProgress { + lines = append(lines, " cancel-in-progress: true") + } + + return strings.Join(lines, "\n") +} diff --git a/pkg/workflow/concurrency_policy_integration_test.go b/pkg/workflow/concurrency_policy_integration_test.go new file mode 100644 index 00000000000..0e9b6af646c --- /dev/null +++ b/pkg/workflow/concurrency_policy_integration_test.go @@ -0,0 +1,185 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConcurrencyPolicyIntegration(t *testing.T) { + // Test the concurrency policy system with real workflow files using code-based rules + tmpDir, err := os.MkdirTemp("", "concurrency-policy-integration-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + frontmatter string + filename string + expectedGroup string + expectedCancel *bool + description string + }{ + { + name: "basic push workflow", + frontmatter: `--- +name: basic-push-test +on: + push: + branches: [main] +---`, + filename: "basic-push.md", + expectedGroup: "gh-aw-${{ github.workflow }}", + expectedCancel: nil, + description: "Should use basic group for push workflows", + }, + { + name: "pull request workflow", + frontmatter: `--- +name: pr-test +on: + pull_request: + types: [opened, synchronize] +---`, + filename: "pr.md", + expectedGroup: "gh-aw-${{ github.workflow }}-${{ github.ref }}", + expectedCancel: boolPtr(true), + description: "Should use ref-based group with cancellation for PR workflows", + }, + { + name: "issue workflow", + frontmatter: `--- +name: issue-test +on: + issues: + types: [opened] +---`, + filename: "issue.md", + expectedGroup: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}", + expectedCancel: boolPtr(true), + description: "Should use issue number with cancellation for issue workflows", + }, + { + name: "schedule workflow", + frontmatter: `--- +name: schedule-test +on: + schedule: + - cron: "0 9 * * *" +---`, + filename: "schedule.md", + expectedGroup: "gh-aw-${{ github.workflow }}", + expectedCancel: nil, + description: "Should use basic group for scheduled workflows", + }, + { + name: "workflow_dispatch workflow", + frontmatter: `--- +name: manual-test +on: + workflow_dispatch: +---`, + filename: "manual.md", + expectedGroup: "gh-aw-${{ github.workflow }}", + expectedCancel: nil, + description: "Should use basic group for manual workflows", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testContent := tt.frontmatter + "\n\nThis is a test workflow for concurrency policy." + + testFile := filepath.Join(tmpDir, tt.filename) + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + // Parse the workflow to get its data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Errorf("Failed to parse workflow: %v", err) + return + } + + t.Logf("Workflow: %s", tt.description) + t.Logf(" Expected Group: %s", tt.expectedGroup) + t.Logf(" Generated Concurrency: %s", workflowData.Concurrency) + + // Check that the concurrency field contains the expected group + if !strings.Contains(workflowData.Concurrency, tt.expectedGroup) { + t.Errorf("Expected concurrency to contain group '%s', got: %s", tt.expectedGroup, workflowData.Concurrency) + } + + // Check for cancel-in-progress behavior + hasCancel := strings.Contains(workflowData.Concurrency, "cancel-in-progress: true") + if tt.expectedCancel != nil { + if *tt.expectedCancel && !hasCancel { + t.Errorf("Expected cancel-in-progress: true, but not found in: %s", workflowData.Concurrency) + } else if !*tt.expectedCancel && hasCancel { + t.Errorf("Did not expect cancel-in-progress: true, but found in: %s", workflowData.Concurrency) + } + } + + // Ensure it's valid YAML format + if !strings.HasPrefix(workflowData.Concurrency, "concurrency:") { + t.Errorf("Generated concurrency should start with 'concurrency:', got: %s", workflowData.Concurrency) + } + }) + } +} + +func TestConcurrencyUserOverrideRespected(t *testing.T) { + // Test that user-provided concurrency is not overridden by the policy system + tmpDir, err := os.MkdirTemp("", "concurrency-user-override-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + compiler := NewCompiler(false, "", "test") + + frontmatter := `--- +name: user-override-test +on: + push: + branches: [main] +concurrency: | + concurrency: + group: user-defined-group + cancel-in-progress: true +---` + + testContent := frontmatter + "\n\nThis is a test for user override." + testFile := filepath.Join(tmpDir, "user-override.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + // Parse the workflow + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Errorf("Failed to parse workflow: %v", err) + return + } + + // Should use the user-defined concurrency, not the auto-generated policy + if !strings.Contains(workflowData.Concurrency, "user-defined-group") { + t.Errorf("Expected user-defined concurrency to be preserved, got: %s", workflowData.Concurrency) + } + + // Should not contain auto-generated content + if strings.Contains(workflowData.Concurrency, "gh-aw-${{ github.workflow }}") { + t.Errorf("User-defined concurrency should not be overridden by auto-generated policy, got: %s", workflowData.Concurrency) + } +} + +// Helper function to create bool pointers +func boolPtr(b bool) *bool { + return &b +} diff --git a/pkg/workflow/concurrency_policy_test.go b/pkg/workflow/concurrency_policy_test.go new file mode 100644 index 00000000000..9a51852bc1e --- /dev/null +++ b/pkg/workflow/concurrency_policy_test.go @@ -0,0 +1,306 @@ +package workflow + +import ( + "testing" +) + +func TestComputeConcurrencyPolicy(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + isAliasTrigger bool + expected *ComputedConcurrencyPolicy + description string + }{ + { + name: "basic workflow without special triggers", + workflowData: &WorkflowData{ + On: "push:", + }, + isAliasTrigger: false, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}", + CancelInProgress: nil, + }, + description: "Basic workflow should use simple group", + }, + { + name: "pull request workflow", + workflowData: &WorkflowData{ + On: "pull_request:", + }, + isAliasTrigger: false, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}-${{ github.ref }}", + CancelInProgress: &[]bool{true}[0], + }, + description: "Pull request workflow should include ref and enable cancellation", + }, + { + name: "issues workflow", + workflowData: &WorkflowData{ + On: "issues:", + }, + isAliasTrigger: false, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}", + CancelInProgress: &[]bool{true}[0], + }, + description: "Issues workflow should include issue number and enable cancellation", + }, + { + name: "alias workflow", + workflowData: &WorkflowData{ + On: "issues:", + }, + isAliasTrigger: true, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}", + CancelInProgress: nil, + }, + description: "Alias workflow should not enable cancellation", + }, + { + name: "schedule workflow", + workflowData: &WorkflowData{ + On: "schedule:", + }, + isAliasTrigger: false, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}", + CancelInProgress: nil, + }, + description: "Schedule workflow should use default policy", + }, + { + name: "workflow_dispatch workflow", + workflowData: &WorkflowData{ + On: "workflow_dispatch:", + }, + isAliasTrigger: false, + expected: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}", + CancelInProgress: nil, + }, + description: "Manual workflow should use default policy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := computeConcurrencyPolicy(tt.workflowData, tt.isAliasTrigger) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result.Group != tt.expected.Group { + t.Errorf("Group mismatch.\nGot: %s\nExpected: %s", result.Group, tt.expected.Group) + } + + if !compareCancelInProgress(result.CancelInProgress, tt.expected.CancelInProgress) { + t.Errorf("CancelInProgress mismatch.\nGot: %v\nExpected: %v", result.CancelInProgress, tt.expected.CancelInProgress) + } + }) + } +} + +func TestGenerateConcurrencyYAML(t *testing.T) { + tests := []struct { + name string + computed *ComputedConcurrencyPolicy + expected string + }{ + { + name: "basic group without cancellation", + computed: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}", + }, + expected: `concurrency: + group: "gh-aw-${{ github.workflow }}"`, + }, + { + name: "group with cancellation enabled", + computed: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}-ref", + CancelInProgress: &[]bool{true}[0], + }, + expected: `concurrency: + group: "gh-aw-${{ github.workflow }}-ref" + cancel-in-progress: true`, + }, + { + name: "group with cancellation disabled", + computed: &ComputedConcurrencyPolicy{ + Group: "gh-aw-${{ github.workflow }}-ref", + CancelInProgress: &[]bool{false}[0], + }, + expected: `concurrency: + group: "gh-aw-${{ github.workflow }}-ref"`, + }, + { + name: "nil policy returns empty string", + computed: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateConcurrencyYAML(tt.computed) + if result != tt.expected { + t.Errorf("YAML output mismatch.\nGot:\n%s\nExpected:\n%s", result, tt.expected) + } + }) + } +} + +func TestGetDefaultPolicySet(t *testing.T) { + tests := []struct { + name string + isAliasTrigger bool + description string + }{ + { + name: "normal workflow policy set", + isAliasTrigger: false, + description: "Normal workflows should have cancellation enabled for issues and PR triggers", + }, + { + name: "alias workflow policy set", + isAliasTrigger: true, + description: "Alias workflows should not have cancellation enabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policySet := getDefaultPolicySet(tt.isAliasTrigger) + + // Verify basic structure + if policySet == nil { + t.Error("Policy set should not be nil") + return + } + + if policySet.Default == nil { + t.Error("Default policy should not be nil") + } + + if policySet.Issues == nil { + t.Error("Issues policy should not be nil") + } + + if policySet.PullRequest == nil { + t.Error("PullRequest policy should not be nil") + } + + // Verify cancellation behavior based on alias trigger + if tt.isAliasTrigger { + if policySet.Issues.CancelInProgress != nil { + t.Error("Alias workflow issues policy should not have cancellation enabled") + } + if policySet.PullRequest.CancelInProgress != nil { + t.Error("Alias workflow PR policy should not have cancellation enabled") + } + } else { + if policySet.Issues.CancelInProgress == nil || !*policySet.Issues.CancelInProgress { + t.Error("Normal workflow issues policy should have cancellation enabled") + } + if policySet.PullRequest.CancelInProgress == nil || !*policySet.PullRequest.CancelInProgress { + t.Error("Normal workflow PR policy should have cancellation enabled") + } + } + }) + } +} + +func TestBuildGroupIdentifier(t *testing.T) { + tests := []struct { + name string + policy *ConcurrencyPolicy + workflowData *WorkflowData + expected string + }{ + { + name: "basic workflow group", + policy: &ConcurrencyPolicy{ + Group: "workflow", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}", + }, + { + name: "custom group", + policy: &ConcurrencyPolicy{ + Group: "custom", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-custom", + }, + { + name: "with issue number node", + policy: &ConcurrencyPolicy{ + Group: "workflow", + Node: "issue.number", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}", + }, + { + name: "with pull request number node", + policy: &ConcurrencyPolicy{ + Group: "workflow", + Node: "pull_request.number", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number }}", + }, + { + name: "with github ref node", + policy: &ConcurrencyPolicy{ + Group: "workflow", + Node: "github.ref", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}-${{ github.ref }}", + }, + { + name: "with custom node expression", + policy: &ConcurrencyPolicy{ + Group: "workflow", + Node: "custom.expression", + }, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}-${{ custom.expression }}", + }, + { + name: "nil policy returns fallback", + policy: nil, + workflowData: &WorkflowData{}, + expected: "gh-aw-${{ github.workflow }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildGroupIdentifier(tt.policy, tt.workflowData) + if result != tt.expected { + t.Errorf("Group identifier mismatch.\nGot: %s\nExpected: %s", result, tt.expected) + } + }) + } +} + +// Helper functions + +func compareCancelInProgress(a, b *bool) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +}