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
26 changes: 23 additions & 3 deletions pkg/workflow/concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string {

// hasSpecialTriggers checks if the workflow has special trigger types that require
// workflow-level concurrency handling (issues, PRs, discussions, push, command,
// or workflow_dispatch-only)
// slash_command, or workflow_dispatch-only)
func hasSpecialTriggers(workflowData *WorkflowData) bool {
// Check for specific trigger types that have special concurrency handling
on := workflowData.On
Expand All @@ -101,6 +101,11 @@ func hasSpecialTriggers(workflowData *WorkflowData) bool {
return true
}

// Check for slash_command triggers (synthetic event that expands to issue_comment + workflow_dispatch)
if isSlashCommandWorkflow(on) {
return true
}

// workflow_dispatch-only workflows represent explicit user intent, so the
// top-level workflow concurrency group is sufficient – no engine-level group needed
if isWorkflowDispatchOnly(on) {
Expand Down Expand Up @@ -129,6 +134,8 @@ func isDiscussionWorkflow(on string) bool {

// isWorkflowDispatchOnly returns true when workflow_dispatch is the only trigger in the
// "on" section, indicating the workflow is always started by explicit user intent.
// It handles both rendered YAML (standard GitHub Actions events) and input YAML
// (which may contain synthetic events like slash_command before they are expanded).
func isWorkflowDispatchOnly(on string) bool {
if !strings.Contains(on, "workflow_dispatch") {
return false
Expand All @@ -137,13 +144,17 @@ func isWorkflowDispatchOnly(on string) bool {
// workflow_dispatch-only workflow. We check for the trigger name followed by
// ':' (YAML key in object form) or as the sole inline value to avoid false
// matches from input parameter names (e.g., "push_branch" ≠ "push" trigger).
// slash_command is included here because it is a synthetic event that expands
// to issue_comment + workflow_dispatch at compile time; its presence means the
// workflow is not triggered solely by explicit user dispatch.
otherTriggers := []string{
"push", "pull_request", "pull_request_review", "pull_request_review_comment",
"pull_request_target", "issues", "issue_comment", "discussion",
"discussion_comment", "schedule", "repository_dispatch", "workflow_run",
"create", "delete", "release", "deployment", "fork", "gollum",
"label", "milestone", "page_build", "public", "registry_package",
"status", "watch", "merge_group", "check_run", "check_suite",
"slash_command",
}
for _, trigger := range otherTriggers {
// Trigger in object format: "push:" / " push:"
Expand All @@ -163,12 +174,21 @@ func isPushWorkflow(on string) bool {
return strings.Contains(on, "push")
}

// isSlashCommandWorkflow checks if a workflow's "on" section contains the slash_command
// synthetic trigger. slash_command is an input-level event that expands to
// issue_comment + workflow_dispatch at compile time. Detecting it here allows
// the concurrency helpers to produce correct results even when they are called
// with the pre-rendered "on" YAML (before the event expansion has taken place).
func isSlashCommandWorkflow(on string) bool {
return strings.Contains(on, "slash_command")
}

// buildConcurrencyGroupKeys builds an array of keys for the concurrency group
func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool) []string {
keys := []string{"gh-aw", "${{ github.workflow }}"}

if isCommandTrigger {
// For command workflows: use issue/PR number
if isCommandTrigger || isSlashCommandWorkflow(workflowData.On) {
// For command/slash_command workflows: use issue/PR number
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number }}")
} else if isPullRequestWorkflow(workflowData.On) && isIssueWorkflow(workflowData.On) {
// Mixed workflows with both issue and PR triggers: use issue/PR number
Expand Down
274 changes: 274 additions & 0 deletions pkg/workflow/concurrency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ tools:
shouldHaveCancel: false,
description: "Issue workflows use global concurrency with engine ID and slot",
},
{
name: "slash_command workflow should have dynamic concurrency with issue/PR number",
frontmatter: `---
on:
slash_command:
name: test-bot
tools:
github:
allowed: [list_issues]
---`,
filename: "slash-command-workflow.md",
expectedConcurrency: `concurrency:
group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}"`,
shouldHaveCancel: false,
description: "slash_command workflows should use dynamic concurrency with issue/PR number without cancellation",
},
{
name: "slash_command shorthand workflow should have dynamic concurrency with issue/PR number",
frontmatter: `---
on: /test-bot
tools:
github:
allowed: [list_issues]
---`,
filename: "slash-command-shorthand-workflow.md",
expectedConcurrency: `concurrency:
group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}"`,
shouldHaveCancel: false,
description: "slash_command shorthand workflows should use dynamic concurrency with issue/PR number without cancellation",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -294,6 +324,33 @@ func TestGenerateConcurrencyConfig(t *testing.T) {
group: "custom-group"`,
description: "Existing concurrency configuration should be preserved",
},
{
name: "slash_command input YAML should have dynamic concurrency with issue/PR number",
workflowData: &WorkflowData{
On: `on:
slash_command: test-bot
workflow_dispatch:`,
Concurrency: "",
},
isAliasTrigger: false,
expected: `concurrency:
group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}"`,
description: "slash_command (input-level YAML) should use issue/PR number, same as command trigger",
},
{
name: "slash_command rendered YAML (issue_comment + workflow_dispatch) should have dynamic concurrency",
workflowData: &WorkflowData{
On: `on:
issue_comment:
types: [created]
workflow_dispatch:`,
Concurrency: "",
},
isAliasTrigger: false,
expected: `concurrency:
group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}"`,
description: "Rendered slash_command YAML (issue_comment + workflow_dispatch) uses issue number via isIssueWorkflow",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -399,6 +456,24 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) {
group: "gh-aw-copilot-${{ github.workflow }}"`,
description: "workflow_dispatch combined with schedule should still get default concurrency (not workflow_dispatch-only)",
},
{
name: "No default concurrency for slash_command input YAML (pre-rendered)",
workflowData: &WorkflowData{
On: "on:\n slash_command: test-bot\n workflow_dispatch:",
EngineConfig: &EngineConfig{ID: "copilot"},
},
expected: "",
description: "slash_command in input YAML should NOT get default concurrency (isSlashCommandWorkflow detects the synthetic event)",
},
{
name: "No default concurrency for slash_command rendered YAML (issue_comment + workflow_dispatch)",
workflowData: &WorkflowData{
On: "on:\n issue_comment:\n types: [created]\n workflow_dispatch:",
EngineConfig: &EngineConfig{ID: "copilot"},
},
expected: "",
description: "Rendered slash_command YAML (issue_comment + workflow_dispatch) should NOT get default concurrency (isIssueWorkflow detects it)",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -740,6 +815,17 @@ func TestBuildConcurrencyGroupKeys(t *testing.T) {
expected: []string{"gh-aw", "${{ github.workflow }}"},
description: "Other workflows should use just workflow name",
},
{
name: "slash_command input YAML should include issue/PR number",
workflowData: &WorkflowData{
On: `on:
slash_command: test-bot
workflow_dispatch:`,
},
isAliasTrigger: false,
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.issue.number || github.event.pull_request.number }}"},
description: "slash_command (input-level YAML) should include issue/PR number in concurrency group",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -836,3 +922,191 @@ func TestShouldEnableCancelInProgress(t *testing.T) {
})
}
}

func TestIsWorkflowDispatchOnly(t *testing.T) {
tests := []struct {
name string
on string
expected bool
desc string
}{
{
name: "Pure workflow_dispatch should be identified as dispatch-only",
on: "on:\n workflow_dispatch:",
expected: true,
desc: "A workflow with only workflow_dispatch is dispatch-only",
},
{
name: "workflow_dispatch with inputs should be identified as dispatch-only",
on: `on:
workflow_dispatch:
inputs:
environment:
description: "Environment"`,
expected: true,
desc: "workflow_dispatch with inputs is still dispatch-only",
},
{
name: "No workflow_dispatch should not be identified as dispatch-only",
on: `on:
schedule:
- cron: "0 9 * * 1"`,
expected: false,
desc: "A workflow without workflow_dispatch is not dispatch-only",
},
{
name: "workflow_dispatch combined with schedule should not be dispatch-only",
on: `on:
workflow_dispatch:
schedule:
- cron: "0 9 * * 1"`,
expected: false,
desc: "schedule is a real trigger so the workflow is not dispatch-only",
},
{
name: "workflow_dispatch combined with push should not be dispatch-only",
on: `on:
workflow_dispatch:
push:
branches: [main]`,
expected: false,
desc: "push makes the workflow not dispatch-only",
},
{
name: "workflow_dispatch combined with pull_request should not be dispatch-only",
on: `on:
workflow_dispatch:
pull_request:
types: [opened]`,
expected: false,
desc: "pull_request makes the workflow not dispatch-only",
},
{
name: "workflow_dispatch combined with issues should not be dispatch-only",
on: `on:
workflow_dispatch:
issues:
types: [opened]`,
expected: false,
desc: "issues makes the workflow not dispatch-only",
},
{
name: "slash_command with workflow_dispatch should not be dispatch-only",
on: `on:
slash_command: test-bot
workflow_dispatch:`,
expected: false,
desc: "slash_command is a synthetic event that expands to issue_comment; its presence means the workflow is not dispatch-only",
},
{
name: "slash_command map format with workflow_dispatch should not be dispatch-only",
on: `on:
slash_command:
name: test-bot
workflow_dispatch:`,
expected: false,
desc: "slash_command in map format is still a synthetic event that makes the workflow not dispatch-only",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWorkflowDispatchOnly(tt.on)
if result != tt.expected {
t.Errorf("isWorkflowDispatchOnly() for %q = %v, want %v: %s", tt.name, result, tt.expected, tt.desc)
}
})
}
}

func TestHasSpecialTriggers(t *testing.T) {
tests := []struct {
name string
on string
expected bool
desc string
}{
{
name: "Issue workflow is a special trigger",
on: `on:
issues:
types: [opened]`,
expected: true,
desc: "issues trigger should be detected as special",
},
{
name: "PR workflow is a special trigger",
on: `on:
pull_request:
types: [opened]`,
expected: true,
desc: "pull_request trigger should be detected as special",
},
{
name: "Push workflow is a special trigger",
on: `on:
push:
branches: [main]`,
expected: true,
desc: "push trigger should be detected as special",
},
{
name: "Discussion workflow is a special trigger",
on: `on:
discussion:
types: [created]`,
expected: true,
desc: "discussion trigger should be detected as special",
},
{
name: "workflow_dispatch-only is a special trigger",
on: "on:\n workflow_dispatch:",
expected: true,
desc: "workflow_dispatch-only is treated as special (explicit user intent)",
},
{
name: "slash_command input YAML is a special trigger",
on: `on:
slash_command: test-bot
workflow_dispatch:`,
expected: true,
desc: "slash_command is a synthetic event that should be detected as special",
},
{
name: "slash_command map format is a special trigger",
on: `on:
slash_command:
name: test-bot
workflow_dispatch:`,
expected: true,
desc: "slash_command in map format should also be detected as special",
},
{
name: "schedule-only is NOT a special trigger",
on: `on:
schedule:
- cron: "0 9 * * 1"`,
expected: false,
desc: "schedule alone is not a special trigger and should receive default job concurrency",
},
{
name: "schedule + workflow_dispatch is NOT a special trigger",
on: `on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:`,
expected: false,
desc: "schedule + workflow_dispatch is not a special trigger and should receive default job concurrency",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wd := &WorkflowData{On: tt.on}
result := hasSpecialTriggers(wd)
if result != tt.expected {
t.Errorf("hasSpecialTriggers() for %q = %v, want %v: %s", tt.name, result, tt.expected, tt.desc)
}
})
}
}
Loading
Loading