From 0f30d70832054df36e83739b4ff63e07a2bee018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:23:34 +0000 Subject: [PATCH 1/2] Initial plan From 041c234916615967c59cf08b25f5f8a3ffe008e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:50:36 +0000 Subject: [PATCH 2/2] Fix concurrency helpers to handle synthetic events (slash_command, schedule) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/concurrency.go | 26 ++- pkg/workflow/concurrency_test.go | 274 +++++++++++++++++++++++++++++++ pkg/workflow/tools.go | 28 ++-- 3 files changed, 314 insertions(+), 14 deletions(-) diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index 782999ae5b..2a80092645 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -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 @@ -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) { @@ -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 @@ -137,6 +144,9 @@ 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", @@ -144,6 +154,7 @@ func isWorkflowDispatchOnly(on string) bool { "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:" @@ -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 diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index c12537394a..4c2c507deb 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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) + } + }) + } +} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 5495757b65..0954a0cf01 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -23,18 +23,24 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error // Check if this is a command trigger workflow (by checking if user specified "on.command") isCommandTrigger := false if data.On == "" { - // Check the original frontmatter for command trigger - content, err := os.ReadFile(markdownPath) - if err == nil { - result, err := parser.ExtractFrontmatterFromContent(string(content)) + // parseOnSection may have already detected the command trigger and populated data.Command + // (this covers slash_command map format, slash_command shorthand "on: /name", and deprecated "command:") + if len(data.Command) > 0 { + isCommandTrigger = true + } else { + // Check the original frontmatter for command trigger + content, err := os.ReadFile(markdownPath) if err == nil { - if onValue, exists := result.Frontmatter["on"]; exists { - // Check for slash_command or command (deprecated) - if onMap, ok := onValue.(map[string]any); ok { - if _, hasSlashCommand := onMap["slash_command"]; hasSlashCommand { - isCommandTrigger = true - } else if _, hasCommand := onMap["command"]; hasCommand { - isCommandTrigger = true + result, err := parser.ExtractFrontmatterFromContent(string(content)) + if err == nil { + if onValue, exists := result.Frontmatter["on"]; exists { + // Check for slash_command or command (deprecated) + if onMap, ok := onValue.(map[string]any); ok { + if _, hasSlashCommand := onMap["slash_command"]; hasSlashCommand { + isCommandTrigger = true + } else if _, hasCommand := onMap["command"]; hasCommand { + isCommandTrigger = true + } } } }