Skip to content
50 changes: 50 additions & 0 deletions actions/setup/js/get_trigger_label.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* 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 };
141 changes: 141 additions & 0 deletions actions/setup/js/get_trigger_label.test.cjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
37 changes: 32 additions & 5 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 20 additions & 12 deletions pkg/workflow/frontmatter_extraction_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading