From d20d8fccb45fba5c3e6fdd19c329e56c7a56a361 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:45:42 +0000 Subject: [PATCH 1/2] Initial plan From fd0c9910ff7d608d284d0793103fa7a2e6e24de9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:37:05 +0000 Subject: [PATCH 2/2] Add labels field and remove-label option to label_command trigger - Add labels field (array or string) as alternative to name/names - Add remove-label boolean option (default true) to control label removal - When false: skips label removal, outputs label name, skips write permissions - Update JSON schema, Go extraction, types, activation job, and JS script - Add tests for new features Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/cloclo.lock.yml | 1 + .github/workflows/smoke-copilot.lock.yml | 1 + actions/setup/js/remove_trigger_label.cjs | 13 ++ pkg/parser/schemas/main_workflow_schema.json | 25 +++ pkg/workflow/compiler_activation_job.go | 11 +- .../compiler_orchestrator_workflow.go | 2 +- pkg/workflow/compiler_types.go | 1 + pkg/workflow/frontmatter_extraction_yaml.go | 43 ++-- pkg/workflow/label_command_test.go | 187 +++++++++++++++++- 9 files changed, 269 insertions(+), 15 deletions(-) diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 652d6dddd64..736e1d17d7d 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -204,6 +204,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_LABEL_NAMES: '["cloclo"]' + GH_AW_REMOVE_LABEL: 'true' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 5af051e6415..dc48fbbdebf 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -164,6 +164,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_LABEL_NAMES: '["smoke"]' + GH_AW_REMOVE_LABEL: 'true' with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs index 102342778a8..1ff34adbc2a 100644 --- a/actions/setup/js/remove_trigger_label.cjs +++ b/actions/setup/js/remove_trigger_label.cjs @@ -9,9 +9,13 @@ const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); * * Supported events: issues (labeled), pull_request (labeled), discussion (labeled). * For workflow_dispatch, the step emits an empty label_name output and exits without error. + * + * When GH_AW_REMOVE_LABEL is set to "false", the label is NOT removed but the label_name + * output is still set. This is useful when you want to keep the label on the item. */ async function main() { const labelNamesJSON = process.env.GH_AW_LABEL_NAMES; + const removeLabelEnv = process.env.GH_AW_REMOVE_LABEL; const { getErrorMessage } = require("./error_helpers.cjs"); @@ -32,6 +36,9 @@ async function main() { return; } + // When GH_AW_REMOVE_LABEL is "false", skip label removal but still output the label name. + const shouldRemoveLabel = removeLabelEnv !== "false"; + const eventName = context.eventName; // For workflow_dispatch and other non-labeled events, nothing to remove. @@ -56,6 +63,12 @@ async function main() { return; } + if (!shouldRemoveLabel) { + core.info(`Label removal disabled (remove-label: false) – keeping label '${triggerLabel}' on the item.`); + core.setOutput("label_name", triggerLabel); + return; + } + core.info(`Removing trigger label '${triggerLabel}' (event: ${eventName})`); const owner = context.repo?.owner; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d0e54ea6bbf..d109fac3c06 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -354,6 +354,27 @@ ], "description": "Alternative to 'name': label name(s) that trigger the workflow." }, + "labels": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Single label name." + }, + { + "type": "array", + "minItems": 1, + "description": "Array of label names \u2014 any of these labels will trigger the workflow.", + "items": { + "type": "string", + "minLength": 1, + "description": "A label name" + }, + "maxItems": 25 + } + ], + "description": "Label name(s) that trigger the workflow. Preferred alternative to 'name'/'names'." + }, "events": { "description": "Item types where the label-command trigger should be active. Default is all supported types: issues, pull_request, discussion.", "oneOf": [ @@ -374,6 +395,10 @@ "maxItems": 3 } ] + }, + "remove-label": { + "type": "boolean", + "description": "Whether to remove the triggering label when the workflow starts. Default is true. Set to false to keep the label on the item and skip the label removal step (also removes the need for issues:write or discussions:write permissions)." } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 15a9637259a..fa3ba3d09c5 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -309,6 +309,8 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Add label removal step and label_command output for label-command workflows. // When a label-command trigger fires, the triggering label is immediately removed // so that the same label can be applied again to trigger the workflow in the future. + // When remove-label is false, the step still runs to identify and output the label name + // but skips the actual removal operation. if len(data.LabelCommand) > 0 { // The removal step only makes sense for actual "labeled" events; for // workflow_dispatch we skip it silently via the env-based label check. @@ -322,6 +324,12 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate return nil, fmt.Errorf("failed to marshal label-command names: %w", err) } steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: '%s'\n", string(labelNamesJSON))) + // Pass whether to remove the label (default true; false skips removal but still outputs label name) + removeLabelStr := "true" + if !data.LabelCommandRemoveLabel { + removeLabelStr = "false" + } + steps = append(steps, fmt.Sprintf(" GH_AW_REMOVE_LABEL: '%s'\n", removeLabelStr)) steps = append(steps, " with:\n") // Use GitHub App or custom token if configured (avoids needing elevated GITHUB_TOKEN permissions) labelToken := c.resolveActivationToken(data) @@ -502,7 +510,8 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // - discussion events need discussions:write // When a github-app token is configured, the GITHUB_TOKEN permissions are irrelevant // for the label removal step (it uses the app token instead), so we skip them. - if hasLabelCommand && data.ActivationGitHubApp == nil { + // When remove-label is false, the step does not remove labels so no write permissions are needed. + if hasLabelCommand && data.ActivationGitHubApp == nil && data.LabelCommandRemoveLabel { if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { permsMap[PermissionIssues] = PermissionWrite } diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index af882d0dddd..2b401118be6 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -724,7 +724,7 @@ func (c *Compiler) extractAdditionalConfigurations( // Extract and process mcp-scripts and safe-outputs workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(frontmatter) - workflowData.LabelCommand, workflowData.LabelCommandEvents = c.extractLabelCommandConfig(frontmatter) + workflowData.LabelCommand, workflowData.LabelCommandEvents, workflowData.LabelCommandRemoveLabel = c.extractLabelCommandConfig(frontmatter) workflowData.Jobs = c.extractJobsFromFrontmatter(frontmatter) // Merge jobs from imported YAML workflows diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 71c2b86ee9e..abb5edf449b 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -390,6 +390,7 @@ type WorkflowData struct { LabelCommand []string // for label-command trigger support - label names that act as commands LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) LabelCommandOtherEvents map[string]any // for merging label-command with other events + LabelCommandRemoveLabel bool // whether to remove the triggering label when the workflow starts (default: true) AIReaction string // AI reaction type like "eyes", "heart", etc. StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) ActivationGitHubToken string // custom github token from on.github-token for reactions/comments diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 4c774f827df..bb86a2e9582 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -743,37 +743,39 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName } // extractLabelCommandConfig extracts the label-command configuration from frontmatter -// including label name(s) and the events field. +// including label name(s), the events field, and the remove-label option. // It reads on.label_command which can be: // - a string: label name directly (e.g. label_command: "deploy") -// - a map with "name" or "names" and optional "events" fields +// - a map with "name", "names", or "labels" and optional "events" and "remove-label" fields // -// Returns (labelNames, labelEvents) where labelEvents is nil for default (all events). -func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string) { +// Returns (labelNames, labelEvents, removeLabelEnabled) where labelEvents is nil for default +// (all events) and removeLabelEnabled defaults to true. +func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string, removeLabelEnabled bool) { frontmatterLog.Print("Extracting label-command configuration from frontmatter") onValue, exists := frontmatter["on"] if !exists { - return nil, nil + return nil, nil, true } onMap, ok := onValue.(map[string]any) if !ok { - return nil, nil + return nil, nil, true } labelCommandValue, hasLabelCommand := onMap["label_command"] if !hasLabelCommand { - return nil, nil + return nil, nil, true } // Simple string form: label_command: "my-label" if nameStr, ok := labelCommandValue.(string); ok { frontmatterLog.Printf("Extracted label-command name (shorthand): %s", nameStr) - return []string{nameStr}, nil + return []string{nameStr}, nil, true } - // Map form: label_command: {name: "...", names: [...], events: [...]} + // Map form: label_command: {name: "...", names: [...], labels: [...], events: [...], remove-label: bool} if lcMap, ok := labelCommandValue.(map[string]any); ok { var names []string var events []string + removeLabel := true if nameVal, hasName := lcMap["name"]; hasName { if nameStr, ok := nameVal.(string); ok { @@ -797,16 +799,33 @@ func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelN names = append(names, namesStr) } } + if labelsVal, hasLabels := lcMap["labels"]; hasLabels { + if labelsArray, ok := labelsVal.([]any); ok { + for _, item := range labelsArray { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + } else if labelsStr, ok := labelsVal.(string); ok { + names = append(names, labelsStr) + } + } if eventsVal, hasEvents := lcMap["events"]; hasEvents { events = ParseCommandEvents(eventsVal) } - frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v", names, events) - return names, events + if removeLabelVal, hasRemoveLabel := lcMap["remove-label"]; hasRemoveLabel { + if removeLabelBool, ok := removeLabelVal.(bool); ok { + removeLabel = removeLabelBool + } + } + + frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v, removeLabel=%v", names, events, removeLabel) + return names, events, removeLabel } - return nil, nil + return nil, nil, true } // isGitHubAppNestedField returns true if the trimmed YAML line represents a known diff --git a/pkg/workflow/label_command_test.go b/pkg/workflow/label_command_test.go index 28beac8f530..86f2d720b50 100644 --- a/pkg/workflow/label_command_test.go +++ b/pkg/workflow/label_command_test.go @@ -426,7 +426,192 @@ Both label-command and existing issues labeled trigger. issuesCount, lockStr) } -// TestLabelCommandConflictWithNonLabelTrigger verifies that using label_command alongside +// TestLabelCommandLabelsField verifies that the "labels" field works as an alternative +// to "name"/"names" for specifying label names in the map form. +func TestLabelCommandLabelsField(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Labels Field Test +on: + label_command: + labels: [hot, cold] +engine: copilot +--- + +Triggered by hot or cold labels. +` + + workflowPath := filepath.Join(tempDir, "label-command-labels.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error with labels field") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // Verify the compiled workflow triggers on labeled events + assert.Contains(t, lockStr, "labeled", "on section should contain labeled type") + assert.Contains(t, lockStr, "remove_trigger_label", "compiled workflow should contain remove_trigger_label step") + + // Verify both label names are encoded in the step env + assert.Contains(t, lockStr, `"hot"`, "compiled workflow should reference hot label") + assert.Contains(t, lockStr, `"cold"`, "compiled workflow should reference cold label") +} + +// TestLabelCommandRemoveLabelFalse verifies that when remove-label: false is set, +// the compiled workflow still has the remove_trigger_label step (for label name output) +// but the GH_AW_REMOVE_LABEL env var is set to 'false' and no write permissions are added +// specifically for label removal. +func TestLabelCommandRemoveLabelFalse(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command No Remove Test +on: + label_command: + labels: [hot, cold] + remove-label: false +engine: copilot +--- + +Triggered by hot or cold labels, but the label is NOT removed. +` + + workflowPath := filepath.Join(tempDir, "label-command-no-remove.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + + workflowData, err := compiler.ParseWorkflowFile(workflowPath) + require.NoError(t, err, "ParseWorkflowFile() should not error") + + // Verify LabelCommandRemoveLabel is false + assert.False(t, workflowData.LabelCommandRemoveLabel, "LabelCommandRemoveLabel should be false when remove-label: false") + + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error with remove-label: false") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // The step should still be present (for label name output) + assert.Contains(t, lockStr, "remove_trigger_label", "compiled workflow should still contain remove_trigger_label step") + + // GH_AW_REMOVE_LABEL should be set to false + assert.Contains(t, lockStr, "GH_AW_REMOVE_LABEL: 'false'", "compiled workflow should set GH_AW_REMOVE_LABEL to false") +} + +// TestLabelCommandRemoveLabelFalsePermissions verifies that when remove-label: false is set +// and other write-permission-requiring features are also disabled, no write permissions are +// added to the activation job. +func TestLabelCommandRemoveLabelFalsePermissions(t *testing.T) { + tempDir := t.TempDir() + + // Disable status-comment and reaction to isolate permission effects of remove-label + workflowContent := `--- +name: Label Command No Remove Permissions Test +on: + label_command: + labels: [deploy] + remove-label: false + status-comment: false + reaction: none +engine: copilot +--- + +Triggered by deploy label, no label removal, no status comment, no reaction. +` + + workflowPath := filepath.Join(tempDir, "label-command-no-remove-perms.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error with remove-label: false") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + // Parse the YAML to verify permissions do NOT include issues:write or discussions:write + var workflow map[string]any + err = yaml.Unmarshal(lockContent, &workflow) + require.NoError(t, err, "failed to parse lock file as YAML") + + jobs, ok := workflow["jobs"].(map[string]any) + require.True(t, ok, "workflow should have jobs") + + activation, ok := jobs["activation"].(map[string]any) + require.True(t, ok, "workflow should have an activation job") + + // When remove-label is false and status-comment is disabled, + // no write permissions should be added for the activation job + perms, hasPerms := activation["permissions"].(map[string]any) + if hasPerms { + issuesPerm, hasIssues := perms["issues"] + if hasIssues { + assert.NotEqual(t, "write", issuesPerm, "issues:write should not be set when remove-label is false and status-comment is disabled") + } + discussionsPerm, hasDiscussions := perms["discussions"] + if hasDiscussions { + assert.NotEqual(t, "write", discussionsPerm, "discussions:write should not be set when remove-label is false and status-comment is disabled") + } + } +} + +// TestLabelCommandRemoveLabelTrue verifies that the default behavior (remove-label: true) +// is preserved and GH_AW_REMOVE_LABEL is set to 'true'. +func TestLabelCommandRemoveLabelTrue(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Remove True Test +on: + label_command: + labels: [deploy] + remove-label: true +engine: copilot +--- + +Triggered by deploy label, label is removed (explicit true). +` + + workflowPath := filepath.Join(tempDir, "label-command-remove-true.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + + workflowData, err := compiler.ParseWorkflowFile(workflowPath) + require.NoError(t, err, "ParseWorkflowFile() should not error") + + assert.True(t, workflowData.LabelCommandRemoveLabel, "LabelCommandRemoveLabel should be true when remove-label: true") + + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error with remove-label: true") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // GH_AW_REMOVE_LABEL should be set to true + assert.Contains(t, lockStr, "GH_AW_REMOVE_LABEL: 'true'", "compiled workflow should set GH_AW_REMOVE_LABEL to true") +} + // an issues/pull_request trigger with non-label types returns a validation error. func TestLabelCommandConflictWithNonLabelTrigger(t *testing.T) { tempDir := t.TempDir()