diff --git a/actions/setup/js/get_trigger_label.cjs b/actions/setup/js/get_trigger_label.cjs new file mode 100644 index 0000000000..39a880ce8a --- /dev/null +++ b/actions/setup/js/get_trigger_label.cjs @@ -0,0 +1,50 @@ +// @ts-check +/// + +/** + * Emit a unified `command_name` output (and a `label_name` alias) for the triggering command. + * + * This step is used when `label_command` is configured with `remove_label: false`. + * It resolves the command name for both label-command and slash-command triggers so that + * downstream jobs can reference a single `needs.activation.outputs.command_name` regardless + * of which trigger type fired the workflow. + * + * Resolution order: + * 1. Labeled events — `command_name` = the triggering label name. + * 2. Other events — `command_name` = GH_AW_MATCHED_COMMAND env var (slash-command name), + * falling back to an empty string if the var is absent. + * 3. workflow_dispatch — same fallback logic as (2); normally produces an empty string. + * + * Outputs: + * label_name — the triggering label name, or "" for non-labeled events. + * command_name — unified command name usable by both label_command and slash_command workflows. + */ +function main() { + // Optional: pre-computed matched slash-command name from check_command_position.cjs + const matchedCommand = process.env.GH_AW_MATCHED_COMMAND ?? ""; + + const eventName = context.eventName; + + if (eventName === "workflow_dispatch") { + core.info("Event is workflow_dispatch – no label trigger."); + core.setOutput("label_name", ""); + core.setOutput("command_name", matchedCommand); + return; + } + + // For labeled events (issues, pull_request, discussion) use the label name. + const labelName = context.payload?.label?.name ?? ""; + if (labelName) { + core.info(`Trigger label: '${labelName}'`); + core.setOutput("label_name", labelName); + core.setOutput("command_name", labelName); + return; + } + + // Non-labeled events (e.g. slash-command comments) — fall back to the matched command. + core.info(`Event '${eventName}' has no label payload – using matched command '${matchedCommand}'.`); + core.setOutput("label_name", ""); + core.setOutput("command_name", matchedCommand); +} + +module.exports = { main }; diff --git a/actions/setup/js/get_trigger_label.test.cjs b/actions/setup/js/get_trigger_label.test.cjs new file mode 100644 index 0000000000..60e2f629b1 --- /dev/null +++ b/actions/setup/js/get_trigger_label.test.cjs @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + setSecret: vi.fn(), + getInput: vi.fn(), + getBooleanInput: vi.fn(), + getMultilineInput: vi.fn(), + getState: vi.fn(), + saveState: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + group: vi.fn(), + addPath: vi.fn(), + setCommandEcho: vi.fn(), + isDebug: vi.fn().mockReturnValue(false), + getIDToken: vi.fn(), + toPlatformPath: vi.fn(), + toPosixPath: vi.fn(), + toWin32Path: vi.fn(), + summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue(undefined) }, +}; + +const mockContext = { + eventName: "issues", + payload: {}, + runId: 12345, + repo: { owner: "testowner", repo: "testrepo" }, +}; + +global.core = mockCore; +global.context = mockContext; + +describe("get_trigger_label.cjs", () => { + let scriptContent, originalEnv; + + beforeEach(() => { + vi.clearAllMocks(); + originalEnv = { GH_AW_MATCHED_COMMAND: process.env.GH_AW_MATCHED_COMMAND }; + delete process.env.GH_AW_MATCHED_COMMAND; + const scriptPath = path.join(__dirname, "get_trigger_label.cjs"); + scriptContent = fs.readFileSync(scriptPath, "utf8"); + mockContext.eventName = "issues"; + mockContext.payload = {}; + }); + + afterEach(() => { + if (originalEnv.GH_AW_MATCHED_COMMAND !== undefined) { + process.env.GH_AW_MATCHED_COMMAND = originalEnv.GH_AW_MATCHED_COMMAND; + } else { + delete process.env.GH_AW_MATCHED_COMMAND; + } + }); + + const run = () => eval(`(async () => { ${scriptContent}; await main(); })()`); + + // ── labeled events ────────────────────────────────────────────────────────── + + it("should output label name as command_name for labeled issues event", async () => { + mockContext.eventName = "issues"; + mockContext.payload = { action: "labeled", label: { name: "deploy" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", "deploy"); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "deploy"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should output label name as command_name for labeled pull_request event", async () => { + mockContext.eventName = "pull_request"; + mockContext.payload = { action: "labeled", label: { name: "ship-it" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", "ship-it"); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "ship-it"); + }); + + it("should output label name as command_name for labeled discussion event", async () => { + mockContext.eventName = "discussion"; + mockContext.payload = { action: "labeled", label: { name: "triage" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", "triage"); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "triage"); + }); + + // ── workflow_dispatch ──────────────────────────────────────────────────────── + + it("should output empty strings for workflow_dispatch without GH_AW_MATCHED_COMMAND", async () => { + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = {}; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", ""); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", ""); + }); + + it("should output matched command for workflow_dispatch when GH_AW_MATCHED_COMMAND is set", async () => { + process.env.GH_AW_MATCHED_COMMAND = "fix"; + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = {}; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", ""); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "fix"); + }); + + // ── non-labeled events (slash-command context) ─────────────────────────────── + + it("should use GH_AW_MATCHED_COMMAND as command_name for issue_comment without label", async () => { + process.env.GH_AW_MATCHED_COMMAND = "review"; + mockContext.eventName = "issue_comment"; + mockContext.payload = { comment: { body: "/review please" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", ""); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "review"); + }); + + it("should output empty strings for non-labeled event without GH_AW_MATCHED_COMMAND", async () => { + mockContext.eventName = "issue_comment"; + mockContext.payload = { comment: { body: "just a comment" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", ""); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", ""); + }); + + // ── label name takes precedence over GH_AW_MATCHED_COMMAND ────────────────── + + it("should prefer label name over GH_AW_MATCHED_COMMAND for labeled events", async () => { + process.env.GH_AW_MATCHED_COMMAND = "some-command"; + mockContext.eventName = "issues"; + mockContext.payload = { action: "labeled", label: { name: "deploy" } }; + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith("label_name", "deploy"); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_name", "deploy"); + }); +}); diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 93e530a091..dbef80bd74 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -696,6 +696,7 @@ const CheckSkipIfMatchStepID StepID = "check_skip_if_match" const CheckSkipIfNoMatchStepID StepID = "check_skip_if_no_match" const CheckCommandPositionStepID StepID = "check_command_position" const RemoveTriggerLabelStepID StepID = "remove_trigger_label" +const GetTriggerLabelStepID StepID = "get_trigger_label" const CheckRateLimitStepID StepID = "check_rate_limit" const CheckSkipRolesStepID StepID = "check_skip_roles" const CheckSkipBotsStepID StepID = "check_skip_bots" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d0e54ea6bb..4516461853 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -374,6 +374,10 @@ "maxItems": 3 } ] + }, + "remove_label": { + "type": "boolean", + "description": "Whether to automatically remove the triggering label after the workflow starts. Defaults to true. Set to false to keep the label on the item and skip the label-removal step. When false, the issues:write and discussions:write permissions required for label removal are also omitted." } }, "additionalProperties": false @@ -8221,6 +8225,13 @@ "github-app": { "$ref": "#/$defs/github_app", "description": "GitHub App credentials for minting installation access tokens used by APM to access cross-org private repositories." + }, + "env": { + "type": "object", + "description": "Environment variables to set on the APM pack step (e.g., tokens or registry URLs).", + "additionalProperties": { + "type": "string" + } } }, "required": ["packages"], diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 429f98284e..6eceeaa8ed 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -89,13 +89,15 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate hasReaction := data.AIReaction != "" && data.AIReaction != "none" hasStatusComment := data.StatusComment != nil && *data.StatusComment hasLabelCommand := len(data.LabelCommand) > 0 + // shouldRemoveLabel is true when label-command is active AND remove_label is not disabled + shouldRemoveLabel := hasLabelCommand && data.LabelCommandRemoveLabel // Compute filtered label events once and reuse below (permissions + app token scopes) filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents) // Mint a single activation app token upfront if a GitHub App is configured and any // step in the activation job will need it (reaction, status-comment, or label removal). // This avoids minting multiple tokens. - if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || hasLabelCommand) { + if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || shouldRemoveLabel) { // Build the combined permissions needed for all activation steps. // For label removal we only add the scopes required by the enabled events. appPerms := NewPermissions() @@ -104,7 +106,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate appPerms.Set(PermissionPullRequests, PermissionWrite) appPerms.Set(PermissionDiscussions, PermissionWrite) } - if hasLabelCommand { + if shouldRemoveLabel { if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { appPerms.Set(PermissionIssues, PermissionWrite) } @@ -314,7 +316,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. - if len(data.LabelCommand) > 0 { + // This step is skipped when remove_label is set to false. + if shouldRemoveLabel { // The removal step only makes sense for actual "labeled" events; for // workflow_dispatch we skip it silently via the env-based label check. steps = append(steps, " - name: Remove trigger label\n") @@ -338,6 +341,29 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Expose the matched label name as a job output for downstream jobs to consume outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.RemoveTriggerLabelStepID) + } else if hasLabelCommand { + // When remove_label is disabled, emit a github-script step that runs get_trigger_label.cjs + // (via generateGitHubScriptWithRequire) to safely resolve the triggering command name for + // both label_command and slash_command events and emit a unified `command_name` output + // (plus a `label_name` alias). + steps = append(steps, " - name: Get trigger label name\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.GetTriggerLabelStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + // Pass the pre-computed matched slash-command (if any) so the script can provide a + // unified command_name for workflows that have both label_command and slash_command. + if len(data.Command) > 0 { + steps = append(steps, " env:\n") + if preActivationJobCreated { + steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ needs.%s.outputs.%s }}\n", string(constants.PreActivationJobName), constants.MatchedCommandOutput)) + } else { + steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ steps.%s.outputs.%s }}\n", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput)) + } + } + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("get_trigger_label.cjs")) + outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.GetTriggerLabelStepID) + outputs["command_name"] = fmt.Sprintf("${{ steps.%s.outputs.command_name }}", constants.GetTriggerLabelStepID) } // If no steps have been added, add a placeholder step to make the job valid @@ -501,13 +527,14 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate permsMap[PermissionIssues] = PermissionWrite } - // Add write permissions for label removal when label_command is configured. + // Add write permissions for label removal when label_command is configured and remove_label is enabled. // Only grant the scopes required by the enabled events: // - issues/pull_request events need issues:write (PR labels use the issues REST API) // - 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, no label removal occurs so these permissions are not needed. + if shouldRemoveLabel && data.ActivationGitHubApp == nil { 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 af882d0ddd..2b401118be 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 71c2b86ee9..4071614425 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 automatically remove the triggering label (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 4c774f827d..186a9650d4 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 flag. // 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" or "names", optional "events", and optional "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, removeLabel) where labelEvents is nil for default (all events) +// and removeLabel defaults to true when not specified. +func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string, removeLabel 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: [...], events: [...], remove_label: bool} if lcMap, ok := labelCommandValue.(map[string]any); ok { var names []string var events []string + removeLabelVal := true // default to true if nameVal, hasName := lcMap["name"]; hasName { if nameStr, ok := nameVal.(string); ok { @@ -802,11 +804,17 @@ func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelN events = ParseCommandEvents(eventsVal) } - frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v", names, events) - return names, events + if removeLabelField, hasRemoveLabel := lcMap["remove_label"]; hasRemoveLabel { + if b, ok := removeLabelField.(bool); ok { + removeLabelVal = b + } + } + + frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v, remove_label=%v", names, events, removeLabelVal) + return names, events, removeLabelVal } - 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 28beac8f53..74b7fe5ed8 100644 --- a/pkg/workflow/label_command_test.go +++ b/pkg/workflow/label_command_test.go @@ -453,3 +453,116 @@ This should fail validation. require.Error(t, err, "CompileWorkflow() should error when label_command is combined with non-label issues trigger") assert.Contains(t, err.Error(), "label_command", "error should mention label_command") } + +// TestLabelCommandRemoveLabelDisabled verifies that setting remove_label: false in the object form +// skips the remove_trigger_label step and omits the label-removal permissions. +func TestLabelCommandRemoveLabelDisabled(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command No Remove +on: + label_command: + name: deploy + remove_label: false + reaction: none + status-comment: false +engine: copilot +--- + +Deploy the application because label "deploy" was added. 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() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error when remove_label is false") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // The remove_trigger_label step should NOT be present + assert.NotContains(t, lockStr, "remove_trigger_label", + "compiled workflow should NOT contain remove_trigger_label step when remove_label is false") + + // A lightweight get_trigger_label step should be present to safely read the label name + assert.Contains(t, lockStr, "get_trigger_label", + "compiled workflow should contain get_trigger_label step when remove_label is false") + + // The label_command output should still be present (referencing get_trigger_label step output) + 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") + + activationOutputs, ok := activation["outputs"].(map[string]any) + require.True(t, ok, "activation job should have outputs") + + labelCmdOutput, hasLabelCmdOutput := activationOutputs["label_command"] + assert.True(t, hasLabelCmdOutput, "activation job should still have label_command output when remove_label is false") + assert.Contains(t, labelCmdOutput, "get_trigger_label", + "label_command output should reference get_trigger_label step") + + // A unified command_name output should also be present + commandNameOutput, hasCommandName := activationOutputs["command_name"] + assert.True(t, hasCommandName, "activation job should have a unified command_name output when remove_label is false") + assert.Contains(t, commandNameOutput, "get_trigger_label", + "command_name output should reference get_trigger_label step") + + // When reactions and status-comment are also disabled, issues:write should NOT be present + // since it was only needed for label removal. + activationPerms, hasPerms := activation["permissions"].(map[string]any) + if hasPerms { + issuesPerm, hasIssues := activationPerms["issues"] + if hasIssues { + assert.NotEqual(t, "write", issuesPerm, + "activation job should not have issues:write when remove_label, reaction, and status_comment are all disabled") + } + } +} + +// TestLabelCommandRemoveLabelDefaultTrue verifies that the default behavior (remove_label not specified) +// still removes the label, preserving backward compatibility. +func TestLabelCommandRemoveLabelDefaultTrue(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Default Remove +on: + label_command: + name: deploy +engine: copilot +--- + +Deploy the application because label "deploy" was added. +` + + workflowPath := filepath.Join(tempDir, "label-command-default-remove.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") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // The remove_trigger_label step should be present (default behavior) + assert.Contains(t, lockStr, "remove_trigger_label", + "compiled workflow should contain remove_trigger_label step when remove_label is not specified (default true)") +}