diff --git a/.changeset/patch-add-label-command-trigger.md b/.changeset/patch-add-label-command-trigger.md new file mode 100644 index 0000000000..1d59bc3869 --- /dev/null +++ b/.changeset/patch-add-label-command-trigger.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add support for the `label_command` trigger so workflows can run when a configured label is added to an issue, pull request, or discussion. The activation job now removes the triggering label at startup and exposes `needs.activation.outputs.label_command` for downstream use. diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index c02f0dca5f..1cd2d38757 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -27,7 +27,7 @@ # - shared/jqschema.md # - shared/mcp/serena-go.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"183df887ed669775015161c11ab4c4b3f52f06a00a94ec9d64785a0eda3be9c2","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ad111cf099d958c340ed839a8e88f5053a501ce37fb239bdc2645ee1a872d14a","strict":true} name: "/cloclo" "on": @@ -35,6 +35,7 @@ name: "/cloclo" types: - created - edited + - labeled discussion_comment: types: - created @@ -44,15 +45,17 @@ name: "/cloclo" - created - edited issues: - # names: # Label filtering applied via job conditions - # - cloclo # Label filtering applied via job conditions types: + - opened + - edited + - reopened - labeled pull_request: types: - opened - edited - reopened + - labeled pull_request_review_comment: types: - created @@ -70,9 +73,7 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && ((((github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment') && ((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || (github.event.issue.body == '/cloclo')) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && (github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && @@ -82,10 +83,9 @@ jobs: ((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) || (github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) || (github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) || - (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') || - (github.event.label.name == 'cloclo')))) + (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') || + (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') && + (github.event.label.name == 'cloclo'))) runs-on: ubuntu-slim permissions: contents: read @@ -97,6 +97,7 @@ jobs: comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + label_command: ${{ steps.remove_trigger_label.outputs.label_name }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} slash_command: ${{ needs.pre_activation.outputs.matched_command }} @@ -196,6 +197,17 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Remove trigger label + id: remove_trigger_label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_LABEL_NAMES: '["cloclo"]' + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -1534,8 +1546,6 @@ jobs: pre_activation: if: > - (((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || - github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && ((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || (github.event.issue.body == '/cloclo')) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && (github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || @@ -1545,10 +1555,9 @@ jobs: ((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) || (github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) || (github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) || - (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') || - (github.event.label.name == 'cloclo'))) + (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') || + (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') && + (github.event.label.name == 'cloclo')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/cloclo.md b/.github/workflows/cloclo.md index 6cba6c17f2..77103bd6ab 100644 --- a/.github/workflows/cloclo.md +++ b/.github/workflows/cloclo.md @@ -2,9 +2,7 @@ on: slash_command: name: cloclo - issues: - types: [labeled] - names: [cloclo] + label_command: cloclo status-comment: true permissions: contents: read diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index a9a25231b6..4bec7e995d 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,24 +29,26 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1a7fa2c830c582eef2c1ca858d020772b9efc949615721d692484047ab127162","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c69fb0db5e338569de880edcb18e606cf17efe9016ab532a0c4f17c1ba71729c","strict":true} name: "Smoke Copilot" "on": pull_request: - # names: # Label filtering applied via job conditions - # - smoke # Label filtering applied via job conditions types: - labeled schedule: - cron: "47 */12 * * *" - workflow_dispatch: null + workflow_dispatch: + inputs: + item_number: + description: The number of the issue, pull request, or discussion + required: true + type: string permissions: {} concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}" - cancel-in-progress: true + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}" run-name: "Smoke Copilot" @@ -54,8 +56,8 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && (((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && - ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke')))) + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) || + (!(github.event_name == 'pull_request'))) runs-on: ubuntu-slim permissions: contents: read @@ -63,14 +65,12 @@ jobs: issues: write pull-requests: write outputs: - body: ${{ steps.sanitized.outputs.body }} comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + label_command: ${{ steps.remove_trigger_label.outputs.label_name }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - text: ${{ steps.sanitized.outputs.text }} - title: ${{ steps.sanitized.outputs.title }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -143,15 +143,6 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); - - name: Compute current body text - id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/compute_text.cjs'); - await main(); - name: Add comment with workflow run link id: add-comment if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) @@ -166,6 +157,18 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Remove trigger label + id: remove_trigger_label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_LABEL_NAMES: '["smoke"]' + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -2330,8 +2333,7 @@ jobs: pre_activation: if: > - ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && - ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke'))) + ((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) || (!(github.event_name == 'pull_request')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 8ea8a0d1fc..6c91699f5e 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -3,9 +3,9 @@ description: Smoke Copilot on: schedule: every 12h workflow_dispatch: - pull_request: - types: [labeled] - names: ["smoke"] + label_command: + name: smoke + events: [pull_request] reaction: "eyes" status-comment: true github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs new file mode 100644 index 0000000000..b4346cb203 --- /dev/null +++ b/actions/setup/js/remove_trigger_label.cjs @@ -0,0 +1,139 @@ +// @ts-check +/// + +const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); + +/** + * Remove the label that triggered this workflow from the issue, pull request, or discussion. + * This allows the same label to be applied again later to re-trigger the workflow. + * + * Supported events: issues (labeled), pull_request (labeled), discussion (labeled). + * For workflow_dispatch, the step emits an empty label_name output and exits without error. + */ +async function main() { + const labelNamesJSON = process.env.GH_AW_LABEL_NAMES; + + const { getErrorMessage } = require("./error_helpers.cjs"); + + if (!labelNamesJSON) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES not specified.`); + return; + } + + let labelNames = []; + try { + labelNames = JSON.parse(labelNamesJSON); + if (!Array.isArray(labelNames)) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES must be a JSON array.`); + return; + } + } catch (error) { + core.setFailed(`${ERR_CONFIG}: Configuration error: Failed to parse GH_AW_LABEL_NAMES: ${getErrorMessage(error)}`); + return; + } + + const eventName = context.eventName; + + // For workflow_dispatch and other non-labeled events, nothing to remove. + if (eventName === "workflow_dispatch") { + core.info("Event is workflow_dispatch – skipping label removal."); + core.setOutput("label_name", ""); + return; + } + + // Retrieve the label that was added from the event payload. + const triggerLabel = context.payload?.label?.name; + if (!triggerLabel) { + core.info(`Event ${eventName} has no label payload – skipping label removal.`); + core.setOutput("label_name", ""); + return; + } + + // Confirm that this label is one of the configured command labels. + if (!labelNames.includes(triggerLabel)) { + core.info(`Trigger label '${triggerLabel}' is not in the configured label-command list [${labelNames.join(", ")}] – skipping removal.`); + core.setOutput("label_name", triggerLabel); + return; + } + + core.info(`Removing trigger label '${triggerLabel}' (event: ${eventName})`); + + const owner = context.repo?.owner; + const repo = context.repo?.repo; + if (!owner || !repo) { + core.setFailed(`${ERR_CONFIG}: Configuration error: Unable to determine repository owner/name from context.`); + return; + } + + try { + if (eventName === "issues") { + const issueNumber = context.payload?.issue?.number; + if (!issueNumber) { + core.warning("No issue number found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: triggerLabel, + }); + core.info(`✓ Removed label '${triggerLabel}' from issue #${issueNumber}`); + } else if (eventName === "pull_request") { + // Pull requests share the issues API for labels. + const prNumber = context.payload?.pull_request?.number; + if (!prNumber) { + core.warning("No pull request number found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: triggerLabel, + }); + core.info(`✓ Removed label '${triggerLabel}' from pull request #${prNumber}`); + } else if (eventName === "discussion") { + // Discussions require the GraphQL API for label management. + const discussionNodeId = context.payload?.discussion?.node_id; + const labelNodeId = context.payload?.label?.node_id; + if (!discussionNodeId || !labelNodeId) { + core.warning("No discussion or label node_id found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.graphql( + ` + mutation RemoveLabelFromDiscussion($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + clientMutationId + } + } + `, + { + labelableId: discussionNodeId, + labelIds: [labelNodeId], + } + ); + core.info(`✓ Removed label '${triggerLabel}' from discussion`); + } else { + core.info(`Event '${eventName}' does not support label removal – skipping.`); + } + } catch (error) { + // Non-fatal: log a warning but do not fail the step. + // A 404 status means the label is no longer present on the item (e.g., another concurrent + // workflow run already removed it), which is an expected outcome in multi-workflow setups. + const status = /** @type {any} */ error?.status; + if (status === 404) { + core.info(`Label '${triggerLabel}' is no longer present on the item – already removed by another run.`); + } else { + core.warning(`${ERR_API}: Failed to remove label '${triggerLabel}': ${getErrorMessage(error)}`); + } + } + + core.setOutput("label_name", triggerLabel); +} + +module.exports = { main }; diff --git a/docs/src/content/docs/patterns/label-ops.md b/docs/src/content/docs/patterns/label-ops.md index 4672f65a78..5f2e38e252 100644 --- a/docs/src/content/docs/patterns/label-ops.md +++ b/docs/src/content/docs/patterns/label-ops.md @@ -5,14 +5,95 @@ sidebar: badge: { text: 'Event-triggered', variant: 'success' } --- -LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports label-based triggers with filtering to activate workflows only for specific label changes while maintaining secure, automated responses. +LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports two distinct approaches to label-based triggers: `label_command` for command-style one-shot activation, and `names:` filtering for persistent label-state awareness. ## When to Use LabelOps Use LabelOps for priority-based workflows (run checks when `priority: high` is added), stage transitions (trigger actions when moving between workflow states), specialized processing (different workflows for different label categories), and team coordination (automate handoffs between teams using labels). +## Label Command Trigger + +The `label_command` trigger treats a label as a one-shot command: applying the label fires the workflow, and the label is **automatically removed** so it can be re-applied to re-trigger. This is the right choice when you want a label to mean "do this now" rather than "this item has this property." + +```aw wrap +--- +on: + label_command: deploy +permissions: + contents: read + actions: write +safe-outputs: + add-comment: + max: 1 +--- + +# Deploy Preview + +A `deploy` label was applied to this pull request. Build and deploy a preview environment and post the URL as a comment. + +The matched label name is available as `${{ needs.activation.outputs.label_command }}` if needed to distinguish between multiple label commands. +``` + +After activation the `deploy` label is removed from the pull request, so a reviewer can apply it again to trigger another deployment without any cleanup step. + +### Syntax + +`label_command` accepts a shorthand string, a map with a single name, or a map with multiple names and an optional `events` restriction: + +```yaml +# Shorthand — fires on issues, pull_request, and discussion +on: "label-command deploy" + +# Map with a single name +on: + label_command: deploy + +# Restrict to specific event types +on: + label_command: + name: deploy + events: [issues, pull_request] + +# Multiple label names +on: + label_command: + names: [deploy, redeploy] + events: [pull_request] +``` + +The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, filtered to the named labels. It also adds a `workflow_dispatch` trigger with an `item_number` input so you can test the workflow manually without applying a real label. + +### Accessing the matched label + +The label that triggered the workflow is exposed as an output of the activation job: + +``` +${{ needs.activation.outputs.label_command }} +``` + +This is useful when a workflow handles multiple label commands and needs to branch on which one was applied. + +### Combining with slash commands + +`label_command` can be combined with `slash_command:` in the same workflow. The two triggers are OR'd — the workflow activates when either condition is met: + +```yaml +on: + slash_command: deploy + label_command: + name: deploy + events: [pull_request] +``` + +This lets a workflow be triggered both by a `/deploy` comment and by applying a `deploy` label, sharing the same agent logic. + +> [!NOTE] +> The automatic label removal requires `issues: write` or `pull-requests: write` permission (depending on item type). Add the relevant permission to your frontmatter when using `label_command`. + ## Label Filtering +Use `names:` filtering when you want the workflow to run whenever a label is present on an item and the label should remain attached. This is suitable for monitoring label state rather than reacting to a transient command. + GitHub Agentic Workflows allows you to filter `labeled` and `unlabeled` events to trigger only for specific label names using the `names` field: ```aw wrap @@ -42,7 +123,16 @@ Check the issue for: Respond with a comment outlining next steps and recommended actions. ``` -This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes. +This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes. The labels remain on the issue after the workflow runs. + +### Choosing between `label_command` and `names:` filtering + +| | `label_command` | `names:` filtering | +|---|---|---| +| Label lifecycle | Removed automatically after trigger | Stays on the item | +| Re-triggerable | Yes — reapply the label | Only on the next `labeled` event | +| Typical use | "Do this now" commands | State-based routing | +| Supported items | Issues, pull requests, discussions | Issues, pull requests | ### Label Filter Syntax diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index b1fead53ad..f218491fee 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -282,9 +282,32 @@ See the [Security Architecture](/gh-aw/introduction/architecture/) for details. The `slash_command:` trigger creates workflows that respond to `/command-name` mentions in issues, pull requests, and comments. See [Command Triggers](/gh-aw/reference/command-triggers/) for complete documentation including event filtering, context text, reactions, and examples. +### Label Command Trigger (`label_command:`) + +The `label_command:` trigger activates a workflow when a specific label is applied to an issue, pull request, or discussion, and **automatically removes that label** so it can be re-applied to re-trigger. This treats a label as a one-shot command rather than a persistent state marker. + +```yaml wrap +# Fires on issues, pull_request, and discussion by default +on: + label_command: deploy + +# Restrict to specific event types +on: + label_command: + name: deploy + events: [pull_request] + +# Shorthand string form +on: "label-command deploy" +``` + +The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, adds a `workflow_dispatch` trigger with `item_number` for manual testing, and injects a label removal step in the activation job. The matched label name is exposed as `needs.activation.outputs.label_command`. + +`label_command` can be combined with `slash_command:` — the workflow activates when either condition is met. See [LabelOps](/gh-aw/patterns/label-ops/) for patterns and examples. + ### Label Filtering (`names:`) -Filter issue and pull request triggers by label names using the `names:` field: +Filter issue and pull request triggers by label names using the `names:` field. Unlike `label_command`, the label stays on the item after the workflow runs. ```yaml wrap on: @@ -477,7 +500,7 @@ on: Instead of writing full YAML trigger configurations, you can use natural-language shorthand strings with `on:`. The compiler expands these into standard GitHub Actions trigger syntax and automatically includes `workflow_dispatch` so the workflow can also be run manually. -For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above. +For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above. For the label-command pattern, see [Label Command Trigger](#label-command-trigger-label_command) above. ### Push and Pull Request diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 21d35a520e..304cf603ed 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -672,6 +672,7 @@ const CheckStopTimeStepID StepID = "check_stop_time" 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 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 9b69f7cedd..21014dfdae 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -300,6 +300,86 @@ } ] }, + "label_command": { + "description": "On Label Command trigger: fires when a specific label is added to an issue, pull request, or discussion. The triggering label is automatically removed at workflow start so it can be applied again to re-trigger. Use the 'events' field to restrict which item types (issues, pull_request, discussion) activate the trigger.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Label name as a string (shorthand format). The workflow fires when this label is added to any supported item type (issue, pull request, or discussion)." + }, + { + "type": "object", + "description": "Label command configuration object with label name(s) and optional event filtering.", + "properties": { + "name": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Single label name that acts as a command (e.g., 'deploy' triggers the workflow when the 'deploy' label is added)." + }, + { + "type": "array", + "minItems": 1, + "description": "Array of label names — 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 when added to an issue, pull request, or discussion." + }, + "names": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Single label name." + }, + { + "type": "array", + "minItems": 1, + "description": "Array of label names — any of these labels will trigger the workflow.", + "items": { + "type": "string", + "minLength": 1, + "description": "A label name" + }, + "maxItems": 25 + } + ], + "description": "Alternative to 'name': label name(s) that trigger the workflow." + }, + "events": { + "description": "Item types where the label-command trigger should be active. Default is all supported types: issues, pull_request, discussion.", + "oneOf": [ + { + "type": "string", + "description": "Single item type or '*' for all types.", + "enum": ["*", "issues", "pull_request", "discussion"] + }, + { + "type": "array", + "minItems": 1, + "description": "Array of item types where the trigger is active.", + "items": { + "type": "string", + "description": "Item type.", + "enum": ["*", "issues", "pull_request", "discussion"] + }, + "maxItems": 3 + } + ] + } + }, + "additionalProperties": false + } + ] + }, "push": { "description": "Push event trigger that runs the workflow when code is pushed to the repository", "type": "object", diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 95df0b548c..a2b0a77f9f 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "errors" "fmt" "strings" @@ -114,16 +115,31 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data) steps = append(steps, checkoutSteps...) - // Mint a single activation app token upfront if a GitHub App is configured and either - // the reaction or status-comment step will need it. This avoids minting multiple tokens. + // 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. hasReaction := data.AIReaction != "" && data.AIReaction != "none" hasStatusComment := data.StatusComment != nil && *data.StatusComment - if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment) { - // Build the combined permissions needed for reactions and/or status comments + hasLabelCommand := len(data.LabelCommand) > 0 + // Compute filtered label events once and reuse below (permissions + app token scopes) + filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents) + if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || hasLabelCommand) { + // Build the combined permissions needed for all activation steps. + // For label removal we only add the scopes required by the enabled events. appPerms := NewPermissions() - appPerms.Set(PermissionIssues, PermissionWrite) - appPerms.Set(PermissionPullRequests, PermissionWrite) - appPerms.Set(PermissionDiscussions, PermissionWrite) + if hasReaction || hasStatusComment { + appPerms.Set(PermissionIssues, PermissionWrite) + appPerms.Set(PermissionPullRequests, PermissionWrite) + appPerms.Set(PermissionDiscussions, PermissionWrite) + } + if hasLabelCommand { + if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { + appPerms.Set(PermissionIssues, PermissionWrite) + } + if sliceutil.Contains(filteredLabelEvents, "discussion") { + appPerms.Set(PermissionDiscussions, PermissionWrite) + } + } steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...) } @@ -284,6 +300,35 @@ 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 { + // 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") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.RemoveTriggerLabelStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + // Pass label names as a JSON array so the script can validate the label + labelNamesJSON, err := json.Marshal(data.LabelCommand) + if err != nil { + 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))) + steps = append(steps, " with:\n") + // Use GitHub App or custom token if configured (avoids needing elevated GITHUB_TOKEN permissions) + labelToken := c.resolveActivationToken(data) + if labelToken != "${{ secrets.GITHUB_TOKEN }}" { + steps = append(steps, fmt.Sprintf(" github-token: %s\n", labelToken)) + } + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) + + // 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) + } + // If no steps have been added, add a placeholder step to make the job valid // This can happen when the activation job is created only for an if condition if len(steps) == 0 { @@ -426,6 +471,21 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate permsMap[PermissionIssues] = PermissionWrite } + // Add write permissions for label removal when label_command is configured. + // 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 { + if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { + permsMap[PermissionIssues] = PermissionWrite + } + if sliceutil.Contains(filteredLabelEvents, "discussion") { + permsMap[PermissionDiscussions] = PermissionWrite + } + } + perms := NewPermissionsFromMap(permsMap) permissions := perms.RenderToYAML() diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 23ac893f85..8799258d91 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -596,6 +596,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.Jobs = c.extractJobsFromFrontmatter(frontmatter) // Merge jobs from imported YAML workflows diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index eea5c8502d..55eab625fa 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -22,6 +22,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Check if "slash_command" or "command" (deprecated) is used as a trigger in the "on" section // Also extract "reaction" from the "on" section var hasCommand bool + var hasLabelCommand bool var hasReaction bool var hasStopAfter bool var hasStatusComment bool @@ -139,8 +140,36 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Clear the On field so applyDefaults will handle command trigger generation workflowData.On = "" } - // Extract other (non-conflicting) events excluding slash_command, command, reaction, status-comment, and stop-after - otherEvents = filterMapKeys(onMap, "slash_command", "command", "reaction", "status-comment", "stop-after", "github-token", "github-app") + + // Detect label_command trigger + if _, hasLabelCommandKey := onMap["label_command"]; hasLabelCommandKey { + hasLabelCommand = true + // Set default label names from WorkflowData if already populated by extractLabelCommandConfig + if len(workflowData.LabelCommand) == 0 { + // extractLabelCommandConfig has not been called yet or returned nothing; + // set a placeholder so applyDefaults knows this is a label-command workflow. + // The actual label names will be extracted from the frontmatter in applyDefaults + // via extractLabelCommandConfig which was called in parseOnSectionRaw. + baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") + workflowData.LabelCommand = []string{baseName} + } + // Validate: existing issues/pull_request/discussion triggers that have non-label types + // would be silently overridden by the label_command generation. Require label-only types + // (labeled/unlabeled) so the merge is deterministic and user config is not lost. + labelConflictingEvents := []string{"issues", "pull_request", "discussion"} + for _, eventName := range labelConflictingEvents { + if eventValue, hasConflict := onMap[eventName]; hasConflict { + if !parser.IsLabelOnlyEvent(eventValue) { + return fmt.Errorf("cannot use 'label_command' with '%s' trigger (non-label types); use only labeled/unlabeled types or remove this trigger", eventName) + } + } + } + // Clear the On field so applyDefaults will handle label-command trigger generation + workflowData.On = "" + } + + // Extract other (non-conflicting) events excluding slash_command, command, label_command, reaction, status-comment, and stop-after + otherEvents = filterMapKeys(onMap, "slash_command", "command", "label_command", "reaction", "status-comment", "stop-after", "github-token", "github-app") } } @@ -149,6 +178,12 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work workflowData.Command = nil } + // Clear label-command field if no label_command trigger was found + if !hasLabelCommand { + workflowData.LabelCommand = nil + workflowData.LabelCommandEvents = nil + } + // Auto-enable "eyes" reaction for command triggers if no explicit reaction was specified if hasCommand && !hasReaction && workflowData.AIReaction == "" { workflowData.AIReaction = "eyes" @@ -159,6 +194,10 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // We'll store this and handle it in applyDefaults workflowData.On = "" // This will trigger command handling in applyDefaults workflowData.CommandOtherEvents = otherEvents + } else if hasLabelCommand && len(otherEvents) > 0 { + // Store other events for label-command merging in applyDefaults + workflowData.On = "" // This will trigger label-command handling in applyDefaults + workflowData.LabelCommandOtherEvents = otherEvents } else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 { // Only re-marshal the "on" if we have to onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents}) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 0be848abe8..e267e91054 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -385,6 +385,9 @@ type WorkflowData struct { Command []string // for /command trigger support - multiple command names CommandEvents []string // events where command should be active (nil = all events) CommandOtherEvents map[string]any // for merging command with other events + 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 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 f129087d0b..7b4c2fce28 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -690,6 +690,73 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName return nil, nil } +// extractLabelCommandConfig extracts the label-command configuration from frontmatter +// including label name(s) and the events field. +// 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 +// +// Returns (labelNames, labelEvents) where labelEvents is nil for default (all events). +func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string) { + frontmatterLog.Print("Extracting label-command configuration from frontmatter") + onValue, exists := frontmatter["on"] + if !exists { + return nil, nil + } + onMap, ok := onValue.(map[string]any) + if !ok { + return nil, nil + } + labelCommandValue, hasLabelCommand := onMap["label_command"] + if !hasLabelCommand { + return nil, nil + } + + // 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 + } + + // Map form: label_command: {name: "...", names: [...], events: [...]} + if lcMap, ok := labelCommandValue.(map[string]any); ok { + var names []string + var events []string + + if nameVal, hasName := lcMap["name"]; hasName { + if nameStr, ok := nameVal.(string); ok { + names = []string{nameStr} + } else if nameArray, ok := nameVal.([]any); ok { + for _, item := range nameArray { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + } + } + if namesVal, hasNames := lcMap["names"]; hasNames { + if namesArray, ok := namesVal.([]any); ok { + for _, item := range namesArray { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + } else if namesStr, ok := namesVal.(string); ok { + names = append(names, namesStr) + } + } + + if eventsVal, hasEvents := lcMap["events"]; hasEvents { + events = ParseCommandEvents(eventsVal) + } + + frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v", names, events) + return names, events + } + + return nil, nil +} + // isGitHubAppNestedField returns true if the trimmed YAML line represents a known // nested field or array item inside an on.github-app object. func isGitHubAppNestedField(trimmedLine string) bool { diff --git a/pkg/workflow/label_command.go b/pkg/workflow/label_command.go new file mode 100644 index 0000000000..f46c3393b2 --- /dev/null +++ b/pkg/workflow/label_command.go @@ -0,0 +1,95 @@ +package workflow + +import ( + "errors" + "slices" + + "github.com/github/gh-aw/pkg/logger" +) + +var labelCommandLog = logger.New("workflow:label_command") + +// labelCommandSupportedEvents defines the GitHub Actions events that support label-command triggers +var labelCommandSupportedEvents = []string{"issues", "pull_request", "discussion"} + +// FilterLabelCommandEvents returns the label-command events to use based on the specified identifiers. +// If identifiers is nil or empty, returns all supported events. +func FilterLabelCommandEvents(identifiers []string) []string { + if len(identifiers) == 0 { + labelCommandLog.Print("No label-command event identifiers specified, returning all events") + return labelCommandSupportedEvents + } + + var result []string + for _, id := range identifiers { + if slices.Contains(labelCommandSupportedEvents, id) { + result = append(result, id) + } + } + + labelCommandLog.Printf("Filtered label-command events: %v -> %v", identifiers, result) + return result +} + +// buildLabelCommandCondition creates a condition that checks whether the triggering label +// matches one of the configured label-command names. For non-label events (e.g. +// workflow_dispatch and any other events in LabelCommandOtherEvents), the condition +// passes unconditionally so that manual runs and other triggers still work. +func buildLabelCommandCondition(labelNames []string, labelCommandEvents []string, hasOtherEvents bool) (ConditionNode, error) { + labelCommandLog.Printf("Building label-command condition: labels=%v, events=%v, has_other_events=%t", + labelNames, labelCommandEvents, hasOtherEvents) + + if len(labelNames) == 0 { + return nil, errors.New("no label names provided for label-command trigger") + } + + filteredEvents := FilterLabelCommandEvents(labelCommandEvents) + if len(filteredEvents) == 0 { + return nil, errors.New("no valid events specified for label-command trigger") + } + + // Build the label-name match condition: label1 == name OR label2 == name ... + var labelNameChecks []ConditionNode + for _, labelName := range labelNames { + labelNameChecks = append(labelNameChecks, BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral(labelName), + )) + } + var labelNameMatch ConditionNode + if len(labelNameChecks) == 1 { + labelNameMatch = labelNameChecks[0] + } else { + labelNameMatch = BuildDisjunction(false, labelNameChecks...) + } + + // Build per-event checks: (event_name == 'issues' AND label matches) OR ... + var eventChecks []ConditionNode + for _, event := range filteredEvents { + eventChecks = append(eventChecks, &AndNode{ + Left: BuildEventTypeEquals(event), + Right: labelNameMatch, + }) + } + labelCondition := BuildDisjunction(false, eventChecks...) + + if !hasOtherEvents { + // No other events — the label condition is the entire condition. + return labelCondition, nil + } + + // When there are other events (e.g. workflow_dispatch from the expanded shorthand, or + // user-supplied events), we allow non-label events through unconditionally and only + // require the label-name check for label events. + var labelEventChecks []ConditionNode + for _, event := range filteredEvents { + labelEventChecks = append(labelEventChecks, BuildEventTypeEquals(event)) + } + isLabelEvent := BuildDisjunction(false, labelEventChecks...) + isNotLabelEvent := &NotNode{Child: isLabelEvent} + + return &OrNode{ + Left: &AndNode{Left: isLabelEvent, Right: labelNameMatch}, + Right: isNotLabelEvent, + }, nil +} diff --git a/pkg/workflow/label_command_parser.go b/pkg/workflow/label_command_parser.go new file mode 100644 index 0000000000..0115a0b89b --- /dev/null +++ b/pkg/workflow/label_command_parser.go @@ -0,0 +1,19 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" +) + +var labelCommandParserLog = logger.New("workflow:label_command_parser") + +// expandLabelCommandShorthand takes a label name and returns a map that represents +// the expanded label_command + workflow_dispatch configuration. +// This is the intermediate form stored in the frontmatter "on" map before +// parseOnSection processes it into WorkflowData.LabelCommand. +func expandLabelCommandShorthand(labelName string) map[string]any { + labelCommandParserLog.Printf("Expanding label-command shorthand for label: %s", labelName) + return map[string]any{ + "label_command": labelName, + "workflow_dispatch": nil, + } +} diff --git a/pkg/workflow/label_command_test.go b/pkg/workflow/label_command_test.go new file mode 100644 index 0000000000..dcb5df80f4 --- /dev/null +++ b/pkg/workflow/label_command_test.go @@ -0,0 +1,439 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLabelCommandShorthandPreprocessing verifies that "label-command " shorthand +// is expanded into the label_command map form by the schedule preprocessor. +func TestLabelCommandShorthandPreprocessing(t *testing.T) { + tests := []struct { + name string + onValue string + wantLabelName string + wantErr bool + }{ + { + name: "simple label-command shorthand", + onValue: "label-command deploy", + wantLabelName: "deploy", + }, + { + name: "label-command with hyphenated label", + onValue: "label-command needs-review", + wantLabelName: "needs-review", + }, + { + name: "label-command without label name", + onValue: "label-command ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frontmatter := map[string]any{ + "on": tt.onValue, + } + + compiler := NewCompiler() + err := compiler.preprocessScheduleFields(frontmatter, "", "") + if tt.wantErr { + assert.Error(t, err, "expected error for input %q", tt.onValue) + return + } + + require.NoError(t, err, "preprocessScheduleFields() should not error") + + onVal := frontmatter["on"] + onMap, ok := onVal.(map[string]any) + require.True(t, ok, "on field should be a map after expansion, got %T", onVal) + + labelCmd, hasLabel := onMap["label_command"] + require.True(t, hasLabel, "on map should have label_command key") + assert.Equal(t, tt.wantLabelName, labelCmd, + "label_command value should be %q", tt.wantLabelName) + + _, hasDispatch := onMap["workflow_dispatch"] + assert.True(t, hasDispatch, "on map should have workflow_dispatch key") + }) + } +} + +// TestExpandLabelCommandShorthand verifies the expand helper function. +func TestExpandLabelCommandShorthand(t *testing.T) { + result := expandLabelCommandShorthand("deploy") + + labelCmd, ok := result["label_command"] + require.True(t, ok, "expanded map should have label_command key") + assert.Equal(t, "deploy", labelCmd, "label_command should equal the label name") + + _, hasDispatch := result["workflow_dispatch"] + assert.True(t, hasDispatch, "expanded map should have workflow_dispatch key") +} + +// TestFilterLabelCommandEvents verifies that FilterLabelCommandEvents returns correct subsets. +func TestFilterLabelCommandEvents(t *testing.T) { + tests := []struct { + name string + identifiers []string + want []string + }{ + { + name: "nil identifiers returns all events", + identifiers: nil, + want: []string{"issues", "pull_request", "discussion"}, + }, + { + name: "empty identifiers returns all events", + identifiers: []string{}, + want: []string{"issues", "pull_request", "discussion"}, + }, + { + name: "single issues event", + identifiers: []string{"issues"}, + want: []string{"issues"}, + }, + { + name: "issues and pull_request only", + identifiers: []string{"issues", "pull_request"}, + want: []string{"issues", "pull_request"}, + }, + { + name: "unsupported event is filtered out", + identifiers: []string{"issues", "unknown_event"}, + want: []string{"issues"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FilterLabelCommandEvents(tt.identifiers) + assert.Equal(t, tt.want, got, "FilterLabelCommandEvents(%v)", tt.identifiers) + }) + } +} + +// TestBuildLabelCommandCondition verifies the condition builder for label-command triggers. +func TestBuildLabelCommandCondition(t *testing.T) { + tests := []struct { + name string + labelNames []string + events []string + hasOtherEvents bool + wantErr bool + wantContains []string + wantNotContains []string + }{ + { + name: "single label all events no other events", + labelNames: []string{"deploy"}, + events: nil, + wantContains: []string{ + "github.event.label.name == 'deploy'", + "github.event_name == 'issues'", + "github.event_name == 'pull_request'", + "github.event_name == 'discussion'", + }, + }, + { + name: "multiple labels all events", + labelNames: []string{"deploy", "release"}, + events: nil, + wantContains: []string{ + "github.event.label.name == 'deploy'", + "github.event.label.name == 'release'", + }, + }, + { + name: "single label issues only", + labelNames: []string{"triage"}, + events: []string{"issues"}, + wantContains: []string{ + "github.event_name == 'issues'", + "github.event.label.name == 'triage'", + }, + wantNotContains: []string{ + "github.event_name == 'pull_request'", + "github.event_name == 'discussion'", + }, + }, + { + name: "no label names returns error", + labelNames: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + condition, err := buildLabelCommandCondition(tt.labelNames, tt.events, tt.hasOtherEvents) + if tt.wantErr { + assert.Error(t, err, "expected an error") + return + } + + require.NoError(t, err, "buildLabelCommandCondition() should not error") + rendered := condition.Render() + + for _, want := range tt.wantContains { + assert.Contains(t, rendered, want, + "condition should contain %q, got: %s", want, rendered) + } + for _, notWant := range tt.wantNotContains { + assert.NotContains(t, rendered, notWant, + "condition should NOT contain %q, got: %s", notWant, rendered) + } + }) + } +} + +// TestLabelCommandWorkflowCompile verifies that a workflow with label_command trigger +// compiles to a valid GitHub Actions workflow with: +// - label-based events (issues, pull_request, discussion) in the on: section +// - workflow_dispatch with item_number input +// - a label-name condition in the activation job's if: +// - a remove_trigger_label step in the activation job +// - a label_command output on the activation job +func TestLabelCommandWorkflowCompile(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Test +on: + label_command: deploy +engine: copilot +--- + +Deploy the application because label "deploy" was added. +` + + workflowPath := filepath.Join(tempDir, "label-command-test.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) + + // Verify the on: section includes label-based events + assert.Contains(t, lockStr, "issues:", "on section should contain issues event") + assert.Contains(t, lockStr, "pull_request:", "on section should contain pull_request event") + assert.Contains(t, lockStr, "discussion:", "on section should contain discussion event") + assert.Contains(t, lockStr, "labeled", "on section should contain labeled type") + assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch") + assert.Contains(t, lockStr, "item_number:", "workflow_dispatch should include item_number input") + + // Parse the YAML to check the activation job + 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") + + // Verify the activation job has a label_command output + activationOutputs, ok := activation["outputs"].(map[string]any) + require.True(t, ok, "activation job should have outputs") + + labelCmdOutput, hasOutput := activationOutputs["label_command"] + assert.True(t, hasOutput, "activation job should have label_command output") + assert.Contains(t, labelCmdOutput, "remove_trigger_label", + "label_command output should reference the remove_trigger_label step") + + // Verify the remove_trigger_label step exists in the activation job + activationSteps, ok := activation["steps"].([]any) + require.True(t, ok, "activation job should have steps") + + foundRemoveStep := false + for _, step := range activationSteps { + stepMap, ok := step.(map[string]any) + if !ok { + continue + } + if id, ok := stepMap["id"].(string); ok && id == "remove_trigger_label" { + foundRemoveStep = true + break + } + } + assert.True(t, foundRemoveStep, "activation job should contain a remove_trigger_label step") + + // Verify the workflow condition includes the label name check + agentJob, hasAgent := jobs["agent"].(map[string]any) + require.True(t, hasAgent, "workflow should have an agent job") + _ = agentJob // presence check is sufficient +} + +// TestLabelCommandWorkflowCompileShorthand verifies the "label-command " string shorthand. +func TestLabelCommandWorkflowCompileShorthand(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Shorthand Test +on: "label-command needs-review" +engine: copilot +--- + +Triggered by the needs-review label. +` + + workflowPath := filepath.Join(tempDir, "label-command-shorthand.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 for shorthand form") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + assert.Contains(t, lockStr, "labeled", "compiled workflow should contain labeled type") + assert.Contains(t, lockStr, "remove_trigger_label", "compiled workflow should contain remove_trigger_label step") +} + +// TestLabelCommandWorkflowWithEvents verifies that specifying events: restricts +// which GitHub Actions events are generated. +func TestLabelCommandWorkflowWithEvents(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Issues Only +on: + label_command: + name: deploy + events: [issues] +engine: copilot +--- + +Triggered by the deploy label on issues only. +` + + workflowPath := filepath.Join(tempDir, "label-command-issues-only.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) + + // Should have issues event + assert.Contains(t, lockStr, "issues:", "on section should contain issues event") + + // workflow_dispatch is always added + assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch") + + // pull_request and discussion should NOT be present since events: [issues] was specified + // (However, they may be commented or absent — check the YAML structure) + var workflow map[string]any + err = yaml.Unmarshal(lockContent, &workflow) + require.NoError(t, err, "failed to parse lock file as YAML") + + onSection, ok := workflow["on"].(map[string]any) + require.True(t, ok, "workflow on: section should be a map") + + _, hasPR := onSection["pull_request"] + assert.False(t, hasPR, "pull_request event should not be present when events=[issues]") + + _, hasDiscussion := onSection["discussion"] + assert.False(t, hasDiscussion, "discussion event should not be present when events=[issues]") +} + +// TestLabelCommandNoClashWithExistingLabelTrigger verifies that label_command can coexist +// with an existing label-only issues trigger without creating a duplicate issues: YAML block. +// The existing issues block types are merged into the label_command-generated issues block. +func TestLabelCommandNoClashWithExistingLabelTrigger(t *testing.T) { + tempDir := t.TempDir() + + // Workflow that has both an explicit "issues: types: [labeled]" block AND label_command. + // This is the exact key-clash scenario: without merging, two "issues:" keys would appear + // in the compiled YAML, which is invalid and silently broken. + workflowContent := `--- +name: No Clash Test +on: + label_command: deploy + issues: + types: [labeled] +engine: copilot +--- + +Both label-command and existing issues labeled trigger. +` + + workflowPath := filepath.Join(tempDir, "no-clash-test.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 mixing label_command with existing label trigger") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // Verify there is exactly ONE "issues:" block at the YAML top level + // (count occurrences that are a key, not embedded in other values) + issuesCount := strings.Count(lockStr, "\n issues:\n") + strings.Count(lockStr, "\nissues:\n") + assert.Equal(t, 1, issuesCount, + "there should be exactly one 'issues:' trigger block in the compiled YAML, got %d. Compiled:\n%s", + issuesCount, lockStr) +} + +// TestLabelCommandConflictWithNonLabelTrigger verifies that using label_command alongside +// an issues/pull_request trigger with non-label types returns a validation error. +func TestLabelCommandConflictWithNonLabelTrigger(t *testing.T) { + tempDir := t.TempDir() + + // Workflow with label_command and issues: types: [opened] — non-label type conflicts + workflowContent := `--- +name: Conflict Test +on: + label_command: deploy + issues: + types: [opened] +engine: copilot +--- + +This should fail validation. +` + + workflowPath := filepath.Join(tempDir, "conflict-test.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + 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") +} diff --git a/pkg/workflow/schedule_preprocessing.go b/pkg/workflow/schedule_preprocessing.go index db346221cd..09aff29557 100644 --- a/pkg/workflow/schedule_preprocessing.go +++ b/pkg/workflow/schedule_preprocessing.go @@ -128,6 +128,18 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown return nil } + // Check if it's a label-command shorthand (starts with "label-command ") + if labelName, ok := strings.CutPrefix(onStr, "label-command "); ok { + labelName = strings.TrimSpace(labelName) + if labelName == "" { + return errors.New("label-command shorthand requires a label name after 'label-command'") + } + schedulePreprocessingLog.Printf("Converting shorthand 'on: %s' to label_command + workflow_dispatch", onStr) + onMap := expandLabelCommandShorthand(labelName) + frontmatter["on"] = onMap + return nil + } + // Check if it's a label trigger shorthand (labeled label1 label2...) entityType, labelNames, isLabelTrigger, err := parseLabelTriggerShorthand(onStr) if err != nil { diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 0954a0cf01..eef5ff7293 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -22,11 +22,14 @@ 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 + isLabelCommandTrigger := false if data.On == "" { // 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 if len(data.LabelCommand) > 0 { + isLabelCommandTrigger = true } else { // Check the original frontmatter for command trigger content, err := os.ReadFile(markdownPath) @@ -40,6 +43,8 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error isCommandTrigger = true } else if _, hasCommand := onMap["command"]; hasCommand { isCommandTrigger = true + } else if _, hasLabelCommand := onMap["label_command"]; hasLabelCommand { + isLabelCommandTrigger = true } } } @@ -72,6 +77,33 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error maps.Copy(commandEventsMap, data.CommandOtherEvents) } + // If label_command is also configured alongside slash_command, merge label events + // into the existing command events map to avoid duplicate YAML keys. + if len(data.LabelCommand) > 0 { + labelEventNames := FilterLabelCommandEvents(data.LabelCommandEvents) + for _, eventName := range labelEventNames { + if existingAny, ok := commandEventsMap[eventName]; ok { + if existingMap, ok := existingAny.(map[string]any); ok { + switch t := existingMap["types"].(type) { + case []string: + newTypes := make([]any, len(t)+1) + for i, s := range t { + newTypes[i] = s + } + newTypes[len(t)] = "labeled" + existingMap["types"] = newTypes + case []any: + existingMap["types"] = append(t, "labeled") + } + } + } else { + commandEventsMap[eventName] = map[string]any{ + "types": []any{"labeled"}, + } + } + } + } + // Convert merged events to YAML mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": commandEventsMap}) if err == nil { @@ -112,7 +144,97 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error } if data.If == "" { - data.If = commandConditionTree.Render() + if len(data.LabelCommand) > 0 { + // Combine: (slash_command condition) OR (label_command condition) + // This allows the workflow to activate via either mechanism. + labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, false) + if err != nil { + return fmt.Errorf("failed to build combined label-command condition: %w", err) + } + combined := &OrNode{Left: commandConditionTree, Right: labelConditionTree} + data.If = combined.Render() + } else { + data.If = commandConditionTree.Render() + } + } + } else if isLabelCommandTrigger { + toolsLog.Print("Workflow is label-command trigger, configuring label events") + + // Build the label-command events map + // Generate events: issues, pull_request, discussion with types: [labeled] + filteredEvents := FilterLabelCommandEvents(data.LabelCommandEvents) + labelEventsMap := make(map[string]any) + for _, eventName := range filteredEvents { + labelEventsMap[eventName] = map[string]any{ + "types": []any{"labeled"}, + } + } + + // Add workflow_dispatch with item_number input for manual testing + labelEventsMap["workflow_dispatch"] = map[string]any{ + "inputs": map[string]any{ + "item_number": map[string]any{ + "description": "The number of the issue, pull request, or discussion", + "required": true, + "type": "string", + }, + }, + } + // Signal that this workflow has a dispatch item_number input so that + // applyWorkflowDispatchFallbacks and concurrency key building add the + // necessary inputs.item_number fallbacks for manual workflow_dispatch runs. + data.HasDispatchItemNumber = true + + // Merge other events (if any) — this handles the no-clash requirement: + // if the user also has e.g. "issues: {types: [labeled], names: [bug]}" as a + // regular label trigger alongside label_command, merge the "types" arrays + // rather than generating a duplicate "issues:" block or silently dropping config. + if len(data.LabelCommandOtherEvents) > 0 { + for eventKey, eventVal := range data.LabelCommandOtherEvents { + if existing, exists := labelEventsMap[eventKey]; exists { + // Merge types arrays from user config into the label_command-generated entry. + existingMap, _ := existing.(map[string]any) + userMap, _ := eventVal.(map[string]any) + if existingMap != nil && userMap != nil { + existingTypes, _ := existingMap["types"].([]any) + userTypes, _ := userMap["types"].([]any) + merged := make([]any, 0, len(existingTypes)+len(userTypes)) + merged = append(merged, existingTypes...) + merged = append(merged, userTypes...) + existingMap["types"] = merged + // Other fields (names, branches, etc.) from the user config are preserved. + for k, v := range userMap { + if k != "types" { + existingMap[k] = v + } + } + } + } else { + labelEventsMap[eventKey] = eventVal + } + } + } + + // Convert merged events to YAML + mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": labelEventsMap}) + if err != nil { + return fmt.Errorf("failed to marshal label-command events: %w", err) + } + yamlStr := strings.TrimSuffix(string(mergedEventsYAML), "\n") + yamlStr = parser.QuoteCronExpressions(yamlStr) + // Pass frontmatter so label names in "names:" fields get commented out + yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, map[string]any{}) + data.On = yamlStr + + // Build the label-command condition + hasOtherEvents := len(data.LabelCommandOtherEvents) > 0 + labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, hasOtherEvents) + if err != nil { + return fmt.Errorf("failed to build label-command condition: %w", err) + } + + if data.If == "" { + data.If = labelConditionTree.Render() } } else { data.On = `on: @@ -141,7 +263,7 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error } // Generate concurrency configuration using the dedicated concurrency module - data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger) + data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger || isLabelCommandTrigger) if data.RunName == "" { data.RunName = fmt.Sprintf(`run-name: "%s"`, data.Name)