From 856500e74bde913a8172c0c61cd94361ca2ad281 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:08:53 +0000 Subject: [PATCH 01/15] Implement centralized builtin /help command plumbing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 127 ++++++++++++++++++ actions/setup/js/route_slash_command.test.cjs | 45 +++++++ .../docs/reference/command-triggers.md | 10 ++ pkg/cli/compile_pipeline.go | 2 +- pkg/cli/compile_post_processing.go | 12 +- pkg/parser/schemas/repo_config_schema.json | 4 + .../central_slash_command_workflow.go | 93 ++++++++++++- .../central_slash_command_workflow_test.go | 67 ++++++++- pkg/workflow/repo_config.go | 17 +++ pkg/workflow/repo_config_test.go | 23 ++++ 10 files changed, 390 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 41c4ef267a0..a2048f5552d 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -7,6 +7,7 @@ const { matchesCommandName, parseSlashCommand } = require("./slash_command_match // Keep this aligned with the current default stable GitHub REST API version used by workflows. // Update when GitHub advances the recommended version to avoid sunset/deprecation warnings. const GITHUB_API_VERSION = "2022-11-28"; +const DEFAULT_SLASH_COMMAND_DOCS_URL = "https://github.github.com/gh-aw/reference/command-triggers/"; /** * Appends centralized command routing details to the current step summary. @@ -294,6 +295,120 @@ async function dispatchWorkflow(workflowId, ref, inputs) { core.info(`Skipping workflow '${workflowId}' because it is disabled.`); return false; } + + function isBuiltinHelpEnabled() { + const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); + return raw !== "false"; + } + + function parseHelpCommandsMetadata() { + const raw = process.env.GH_AW_HELP_COMMANDS || "[]"; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map(item => { + const command = typeof item?.command === "string" ? item.command.trim() : ""; + if (!command) { + return null; + } + const description = typeof item?.description === "string" ? item.description.trim() : ""; + return { + command, + description, + centralized: Boolean(item?.centralized), + decentralized: Boolean(item?.decentralized), + }; + }) + .filter(item => item !== null) + .sort((left, right) => left.command.localeCompare(right.command)); + } catch (error) { + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + return []; + } + } + + function helpDocsURL() { + const fromEnv = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); + return fromEnv || DEFAULT_SLASH_COMMAND_DOCS_URL; + } + + function buildCommandBulletLine(entry) { + const suffix = entry.description ? ` — ${entry.description}` : ""; + return `- \`/${entry.command}\`${suffix}`; + } + + function buildHelpCommentBody(helpCommands) { + const centralized = helpCommands.filter(entry => entry.centralized); + const decentralized = helpCommands.filter(entry => entry.decentralized); + + const lines = [ + "## Supported Slash Commands", + "", + "**Centralized commands**", + ]; + if (centralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of centralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", "**Non-centralized commands**"); + if (decentralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of decentralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", `Learn more: [Slash command documentation](${helpDocsURL()})`); + return lines.join("\n"); + } + + async function postBuiltinHelpComment(commentBody) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; + if (issueNumber) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + headers: { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }); + return true; + } + + if (context.eventName === "discussion" || context.eventName === "discussion_comment") { + const discussionID = context.payload?.discussion?.node_id; + if (!discussionID) { + core.warning("Unable to post builtin /help response: discussion node_id missing."); + return false; + } + await github.graphql( + ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { id } + } + }`, + { discussionId: discussionID, body: commentBody } + ); + return true; + } + + core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); + return false; + } throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`); } } @@ -423,6 +538,18 @@ async function main() { } const commandName = selectedCommand; + if (commandName === "help") { + if (!isBuiltinHelpEnabled()) { + core.info("Builtin /help command is disabled by aw.json (help_command=false)."); + return; + } + const posted = await postBuiltinHelpComment(buildHelpCommentBody(parseHelpCommandsMetadata())); + if (posted) { + core.info("Posted builtin /help command response."); + } + return; + } + core.info(`Resolved command '/${commandName}' for event identifier '${identifier}'.`); const configuredRoutes = resolveMatchingSlashRoutes(slashRouteMap, commandName); core.info(`Configured routes for '/${commandName}': ${configuredRoutes.length}.`); diff --git a/actions/setup/js/route_slash_command.test.cjs b/actions/setup/js/route_slash_command.test.cjs index 49fc265c4c2..bacfcbc893f 100644 --- a/actions/setup/js/route_slash_command.test.cjs +++ b/actions/setup/js/route_slash_command.test.cjs @@ -69,6 +69,8 @@ describe("route_slash_command", () => { let dispatchCalls; /** @type {any[]} */ let reactionCalls; + /** @type {any[]} */ + let issueCommentCalls; /** @type {any} */ let summaryMock; @@ -83,6 +85,7 @@ describe("route_slash_command", () => { }; dispatchCalls = []; reactionCalls = []; + issueCommentCalls = []; summaryMock = {}; summaryMock.addHeading = vi.fn(() => summaryMock); summaryMock.addRaw = vi.fn(() => summaryMock); @@ -122,6 +125,11 @@ describe("route_slash_command", () => { }, })), }, + issues: { + createComment: vi.fn(async params => { + issueCommentCalls.push(params); + }), + }, }, }; globals.context = { @@ -137,6 +145,12 @@ describe("route_slash_command", () => { archie: [{ workflow: "archie", events: ["issue_comment", "pull_request_comment"], ai_reaction: "eyes" }], }); process.env.GH_AW_LABEL_ROUTING = JSON.stringify({}); + process.env.GH_AW_HELP_COMMANDS = JSON.stringify([ + { command: "archie", description: "Run archie workflow", centralized: true, decentralized: false }, + { command: "local-summary", description: "Run summary workflow", centralized: false, decentralized: true }, + ]); + process.env.GH_AW_HELP_COMMAND_ENABLED = "true"; + process.env.GH_AW_SLASH_COMMAND_DOCS_URL = "https://github.github.com/gh-aw/reference/command-triggers/"; process.env.GITHUB_WORKSPACE = `${process.cwd()}`; }); @@ -149,6 +163,9 @@ describe("route_slash_command", () => { globals.getOctokit = savedGlobals.getOctokit; delete process.env.GH_AW_SLASH_ROUTING; delete process.env.GH_AW_LABEL_ROUTING; + delete process.env.GH_AW_HELP_COMMANDS; + delete process.env.GH_AW_HELP_COMMAND_ENABLED; + delete process.env.GH_AW_SLASH_COMMAND_DOCS_URL; delete process.env.GITHUB_WORKSPACE; delete process.env.GITHUB_REF; delete process.env.GITHUB_HEAD_REF; @@ -178,6 +195,34 @@ describe("route_slash_command", () => { expect(summaryMock.write).toHaveBeenCalledWith({ overwrite: false }); }); + it("handles builtin /help by posting a context comment and skipping dispatch", async () => { + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(dispatchCalls).toHaveLength(0); + expect(issueCommentCalls).toHaveLength(1); + expect(issueCommentCalls[0].issue_number).toBe(77); + expect(issueCommentCalls[0].body).toContain("## Supported Slash Commands"); + expect(issueCommentCalls[0].body).toContain("**Centralized commands**"); + expect(issueCommentCalls[0].body).toContain("- `/archie` — Run archie workflow"); + expect(issueCommentCalls[0].body).toContain("**Non-centralized commands**"); + expect(issueCommentCalls[0].body).toContain("- `/local-summary` — Run summary workflow"); + expect(issueCommentCalls[0].body).toContain("https://github.github.com/gh-aw/reference/command-triggers/"); + }); + + it("skips builtin /help when disabled", async () => { + process.env.GH_AW_HELP_COMMAND_ENABLED = "false"; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(dispatchCalls).toHaveLength(0); + expect(issueCommentCalls).toHaveLength(0); + expect(globals.core.info).toHaveBeenCalledWith(expect.stringContaining("Builtin /help command is disabled")); + }); + it("logs empty selected command in summary when no slash command is present", async () => { globals.context.payload.comment.body = "hello there"; diff --git a/docs/src/content/docs/reference/command-triggers.md b/docs/src/content/docs/reference/command-triggers.md index 591b8246e87..9fe929726fe 100644 --- a/docs/src/content/docs/reference/command-triggers.md +++ b/docs/src/content/docs/reference/command-triggers.md @@ -75,6 +75,16 @@ When enabled, the workflow compiles as `workflow_dispatch`-centric, and the comp shared `agentic_commands.yml` workflow that listens to merged slash-command events and dispatches matching target workflows with `aw_context`. +When centralized routing is active, a builtin `/help` command is also enabled. It posts a comment in the current issue, pull request, or discussion with the supported slash commands (both centralized and non-centralized) and their descriptions, plus a link to this documentation. + +To disable the builtin handler, set the top-level `help_command` field in `.github/workflows/aw.json`: + +```json +{ + "help_command": false +} +``` + ```yaml wrap on: slash_command: diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 66c5c723a89..84396ccf4d7 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -589,7 +589,7 @@ func runPostProcessingForDirectory( return err } } - if err := generateCentralSlashCommandWorkflowWrapper(ctx, workflowDataList, absWorkflowDir, config.Strict); err != nil { + if err := generateCentralSlashCommandWorkflowWrapper(ctx, workflowDataList, absWorkflowDir, gitRoot, config.Strict); err != nil { if config.Strict { return err } diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index e4501d49730..f66759ad205 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -116,11 +116,21 @@ func generateCentralSlashCommandWorkflowWrapper( ctx context.Context, workflowDataList []*workflow.WorkflowData, workflowsDir string, + gitRoot string, strict bool, ) error { compilePostProcessingLog.Print("Generating centralized slash-command workflow") - if err := workflow.GenerateCentralSlashCommandWorkflow(ctx, workflowDataList, workflowsDir); err != nil { + repoConfig, err := workflow.LoadRepoConfig(gitRoot) + if err != nil { + if strict { + return fmt.Errorf("failed to load repo config: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to load repo config: %v", err))) + repoConfig = nil + } + + if err := workflow.GenerateCentralSlashCommandWorkflow(ctx, workflowDataList, workflowsDir, repoConfig); err != nil { if strict { return fmt.Errorf("failed to generate centralized slash-command workflow: %w", err) } diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index e7f2442c2d9..a8cc4810c9d 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -11,6 +11,10 @@ "description": "Enable GitHub Enterprise Server (GHES) compatibility mode. When true, the compiler emits GHES-compatible artifact action versions (upload-artifact@v3, download-artifact@v3) instead of the latest v7/v8 which are not supported on GHES.", "type": "boolean" }, + "help_command": { + "description": "Enable or disable the builtin centralized /help slash command handler. Defaults to true when omitted. Set to false to disable.", + "type": "boolean" + }, "utc": { "description": "Project home UTC offset used when rendering local times in CLI output. Must be a numeric UTC offset such as +00:00 or -08:00.", "type": "string", diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 33e51c8fe40..a5fb79921f8 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -36,10 +36,17 @@ type commandsHeaderMetadata struct { Workflows []string `json:"workflows"` } +type helpCommandEntry struct { + Command string `json:"command"` + Description string `json:"description,omitempty"` + Centralized bool `json:"centralized"` + Decentralized bool `json:"decentralized"` +} + // GenerateCentralSlashCommandWorkflow generates a single centralized slash-command trigger // workflow for workflows that opt into on.slash_command.strategy: centralized. // When no centralized slash-command workflows are found, any existing generated file is deleted. -func GenerateCentralSlashCommandWorkflow(ctx context.Context, workflowDataList []*WorkflowData, workflowDir string) error { +func GenerateCentralSlashCommandWorkflow(ctx context.Context, workflowDataList []*WorkflowData, workflowDir string, repoConfig *RepoConfig) error { centralSlashCommandWorkflowLog.Printf("Generating centralized slash-command workflow from %d workflow(s)", len(workflowDataList)) slashRoutesByCommand, labelRoutesByCommand, mergedEvents := collectCentralCommandRoutes(workflowDataList) @@ -59,7 +66,21 @@ func GenerateCentralSlashCommandWorkflow(ctx context.Context, workflowDataList [ actionMode := DetectActionMode(GetVersion()) setupActionRef := ResolveSetupActionReference(ctx, actionMode, GetVersion(), "", nil) - content, err := buildCentralSlashCommandWorkflowYAML(slashRoutesByCommand, labelRoutesByCommand, mergedEvents, resolveCentralSlashRunsOn(workflowDataList), setupActionRef) + helpCommands := buildHelpCommandEntries(workflowDataList) + helpCommandEnabled := true + if repoConfig != nil { + helpCommandEnabled = repoConfig.IsHelpCommandEnabled() + } + + content, err := buildCentralSlashCommandWorkflowYAML( + slashRoutesByCommand, + labelRoutesByCommand, + mergedEvents, + resolveCentralSlashRunsOn(workflowDataList), + setupActionRef, + helpCommands, + helpCommandEnabled, + ) if err != nil { return err } @@ -284,7 +305,15 @@ func resolveCentralizedEventReaction(wd *WorkflowData, eventName string) string return "" } -func buildCentralSlashCommandWorkflowYAML(slashRoutesByCommand map[string][]slashCommandRoute, labelRoutesByCommand map[string][]slashCommandRoute, mergedEvents map[string]map[string]bool, runsOn string, setupActionRef string) (string, error) { +func buildCentralSlashCommandWorkflowYAML( + slashRoutesByCommand map[string][]slashCommandRoute, + labelRoutesByCommand map[string][]slashCommandRoute, + mergedEvents map[string]map[string]bool, + runsOn string, + setupActionRef string, + helpCommands []helpCommandEntry, + helpCommandEnabled bool, +) (string, error) { slashRoutesJSON, err := json.Marshal(slashRoutesByCommand) if err != nil { return "", fmt.Errorf("failed to marshal centralized slash-command routes: %w", err) @@ -293,6 +322,10 @@ func buildCentralSlashCommandWorkflowYAML(slashRoutesByCommand map[string][]slas if err != nil { return "", fmt.Errorf("failed to marshal decentralized label-command routes: %w", err) } + helpCommandsJSON, err := json.Marshal(helpCommands) + if err != nil { + return "", fmt.Errorf("failed to marshal help commands metadata: %w", err) + } commandsMetadata, err := json.Marshal(buildCommandsHeaderMetadata(slashRoutesByCommand, labelRoutesByCommand)) if err != nil { @@ -336,6 +369,9 @@ jobs: env: GH_AW_SLASH_ROUTING: '` + escapeYAMLSingleQuoted(string(slashRoutesJSON)) + `' GH_AW_LABEL_ROUTING: '` + escapeYAMLSingleQuoted(string(labelRoutesJSON)) + `' + GH_AW_HELP_COMMANDS: '` + escapeYAMLSingleQuoted(string(helpCommandsJSON)) + `' + GH_AW_HELP_COMMAND_ENABLED: '` + fmt.Sprintf("%t", helpCommandEnabled) + `' + GH_AW_SLASH_COMMAND_DOCS_URL: 'https://github.github.com/gh-aw/reference/command-triggers/' with: script: | const { setupGlobals } = require('` + SetupActionDestination + `/setup_globals.cjs'); @@ -346,6 +382,57 @@ jobs: return b.String(), nil } +func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntry { + type aggregate struct { + Description string + Centralized bool + Decentralized bool + } + byCommand := make(map[string]aggregate) + + for _, wd := range workflowDataList { + if wd == nil || len(wd.Command) == 0 { + continue + } + description := strings.TrimSpace(wd.Description) + for _, commandName := range wd.Command { + trimmed := strings.TrimSpace(commandName) + if trimmed == "" { + continue + } + existing := byCommand[trimmed] + if existing.Description == "" && description != "" { + existing.Description = description + } + if wd.CommandCentralized { + existing.Centralized = true + } else { + existing.Decentralized = true + } + byCommand[trimmed] = existing + } + } + + commands := make([]string, 0, len(byCommand)) + for command := range byCommand { + commands = append(commands, command) + } + sort.Strings(commands) + + entries := make([]helpCommandEntry, 0, len(commands)) + for _, command := range commands { + item := byCommand[command] + entries = append(entries, helpCommandEntry{ + Command: command, + Description: item.Description, + Centralized: item.Centralized, + Decentralized: item.Decentralized, + }) + } + + return entries +} + func writeCentralRouteSummaryComments(b *strings.Builder, slashRoutesByCommand map[string][]slashCommandRoute, labelRoutesByCommand map[string][]slashCommandRoute) { b.WriteString("# Routing summary (sorted):\n") b.WriteString("# slash commands:\n") diff --git a/pkg/workflow/central_slash_command_workflow_test.go b/pkg/workflow/central_slash_command_workflow_test.go index ae97640ee85..54583b310aa 100644 --- a/pkg/workflow/central_slash_command_workflow_test.go +++ b/pkg/workflow/central_slash_command_workflow_test.go @@ -55,9 +55,15 @@ func TestGenerateCentralSlashCommandWorkflow_GeneratesWorkflow(t *testing.T) { LabelCommandDecentralized: true, AIReaction: "eyes", }, + { + WorkflowID: "daily-summary", + Command: []string{"summary"}, + CommandCentralized: false, + Description: "Summarize recent updates", + }, } - require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir)) + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, nil)) generatedPath := filepath.Join(tmpDir, centralSlashCommandWorkflowFilename) content, err := os.ReadFile(generatedPath) @@ -98,6 +104,11 @@ func TestGenerateCentralSlashCommandWorkflow_GeneratesWorkflow(t *testing.T) { require.Contains(t, text, `"triage":[{"workflow":"triage-issue","events":["issue_comment","issues"],"ai_reaction":"eyes"},{"workflow":"triage-pr","events":["pull_request","pull_request_comment"],"ai_reaction":"rocket"}]`) require.Contains(t, text, `"cloclo":[{"workflow":"cloclo","events":["discussion_comment"],"ai_reaction":"heart"}]`) require.Contains(t, text, `"ci-doctor":[{"workflow":"ci-doctor","events":["pull_request"],"ai_reaction":"eyes"}]`) + require.Contains(t, text, `GH_AW_HELP_COMMANDS`) + require.Contains(t, text, `"command":"summary","description":"Summarize recent updates","centralized":false,"decentralized":true`) + require.Contains(t, text, `"command":"triage","centralized":true,"decentralized":false`) + require.Contains(t, text, `GH_AW_HELP_COMMAND_ENABLED: 'true'`) + require.Contains(t, text, `GH_AW_SLASH_COMMAND_DOCS_URL: 'https://github.github.com/gh-aw/reference/command-triggers/'`) require.Contains(t, text, "GH_AW_LABEL_ROUTING") require.Contains(t, text, `require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs')`) require.Contains(t, text, `setupGlobals(core, github, context, exec, io, getOctokit);`) @@ -122,7 +133,7 @@ func TestGenerateCentralSlashCommandWorkflow_DeletesWhenUnused(t *testing.T) { }, } - require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir)) + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, nil)) _, err := os.Stat(generatedPath) require.Error(t, err) require.True(t, os.IsNotExist(err)) @@ -139,7 +150,7 @@ func TestGenerateCentralSlashCommandWorkflow_GeneratesForDecentralizedLabelsOnly }, } - require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir)) + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, nil)) content, err := os.ReadFile(filepath.Join(tmpDir, centralSlashCommandWorkflowFilename)) require.NoError(t, err) text := string(content) @@ -164,7 +175,7 @@ func TestGenerateCentralSlashCommandWorkflow_IncludesPullRequestsPermissionForIs }, } - require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir)) + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, nil)) content, err := os.ReadFile(filepath.Join(tmpDir, centralSlashCommandWorkflowFilename)) require.NoError(t, err) text := string(content) @@ -321,7 +332,7 @@ func TestGenerateCentralSlashCommandWorkflow_UsesCentralizedRunsOnResolution(t * }, } - require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir)) + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, nil)) content, err := os.ReadFile(filepath.Join(tmpDir, centralSlashCommandWorkflowFilename)) require.NoError(t, err) require.Contains(t, string(content), "runs-on: self-hosted") @@ -387,6 +398,52 @@ func TestBuildCommandsHeaderMetadata_UsesReleaseVersionOnlyForReleaseBuilds(t *t require.Equal(t, "v1.2.3", metadata.Compiler) } +func TestGenerateCentralSlashCommandWorkflow_DisablesHelpCommandViaRepoConfig(t *testing.T) { + tmpDir := testutil.TempDir(t, "central-slash-workflow-help-config-test") + disabled := false + data := []*WorkflowData{ + { + WorkflowID: "triage", + Command: []string{"triage"}, + CommandEvents: []string{"issue_comment"}, + CommandCentralized: true, + }, + } + + require.NoError(t, GenerateCentralSlashCommandWorkflow(context.Background(), data, tmpDir, &RepoConfig{HelpCommand: &disabled})) + content, err := os.ReadFile(filepath.Join(tmpDir, centralSlashCommandWorkflowFilename)) + require.NoError(t, err) + require.Contains(t, string(content), `GH_AW_HELP_COMMAND_ENABLED: 'false'`) +} + +func TestBuildHelpCommandEntries(t *testing.T) { + data := []*WorkflowData{ + { + WorkflowID: "triage", + Command: []string{"triage", "helpful"}, + CommandCentralized: true, + Description: "Triage work items", + }, + { + WorkflowID: "triage-inline", + Command: []string{"triage"}, + CommandCentralized: false, + }, + { + WorkflowID: "summary", + Command: []string{"summary"}, + CommandCentralized: false, + Description: "Summarize latest updates", + }, + } + + require.Equal(t, []helpCommandEntry{ + {Command: "helpful", Description: "Triage work items", Centralized: true}, + {Command: "summary", Description: "Summarize latest updates", Decentralized: true}, + {Command: "triage", Description: "Triage work items", Centralized: true, Decentralized: true}, + }, buildHelpCommandEntries(data)) +} + func typeSetKeys(typeSet map[string]bool) []string { out := make([]string, 0, len(typeSet)) for key := range typeSet { diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 8dacfabfffa..7ada18e8e8b 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -8,6 +8,7 @@ // // { // "ghes": true, // enables GHES compatibility mode (v3 artifact pins) +// "help_command": false, // disables builtin centralized /help comment handler // "utc": "-08:00", // project home UTC offset for rendered local times // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all @@ -122,6 +123,11 @@ type RepoConfig struct { // The value must be a numeric UTC offset such as "+00:00" or "-08:00". UTC string + // HelpCommand controls builtin centralized /help command behavior. + // When nil or true, the builtin help command is enabled. + // Set to false in aw.json to disable it. + HelpCommand *bool + // MaintenanceDisabled is true when maintenance has been explicitly set to false // in aw.json, disabling agentic-maintenance generation and any features that // depend on it (such as expires). @@ -139,6 +145,7 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { // Use an intermediate struct with json.RawMessage to defer maintenance parsing. var raw struct { GHES bool `json:"ghes,omitempty"` + HelpCommand *bool `json:"help_command,omitempty"` UTC string `json:"utc,omitempty"` Maintenance json.RawMessage `json:"maintenance,omitempty"` } @@ -147,12 +154,22 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { } r.GHES = raw.GHES + r.HelpCommand = raw.HelpCommand r.UTC = strings.TrimSpace(raw.UTC) if len(raw.Maintenance) == 0 || string(raw.Maintenance) == "null" { return nil } + // IsHelpCommandEnabled returns true when the builtin centralized /help command + // handler should be enabled. The default is enabled. + func (r *RepoConfig) IsHelpCommandEnabled() bool { + if r == nil || r.HelpCommand == nil { + return true + } + return *r.HelpCommand + } + // Try boolean first: maintenance: false disables the feature. var b bool if err := json.Unmarshal(raw.Maintenance, &b); err == nil { diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 6d6d7c9fb6a..17f1e075ec5 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -59,6 +59,29 @@ func TestLoadRepoConfig_EmptyObject(t *testing.T) { require.NoError(t, err, "empty aw.json should load without error") assert.False(t, cfg.MaintenanceDisabled, "maintenance should be enabled by default") assert.Nil(t, cfg.Maintenance, "maintenance config should be nil when not specified") + assert.True(t, cfg.IsHelpCommandEnabled(), "help command should be enabled by default") +} + +func TestLoadRepoConfig_HelpCommandFalse(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"help_command": false}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + require.NotNil(t, cfg.HelpCommand, "help_command should be set") + assert.False(t, *cfg.HelpCommand, "help_command should be false when explicitly set") + assert.False(t, cfg.IsHelpCommandEnabled(), "help command should be disabled when help_command is false") +} + +func TestLoadRepoConfig_HelpCommandTrue(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"help_command": true}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + require.NotNil(t, cfg.HelpCommand, "help_command should be set") + assert.True(t, *cfg.HelpCommand, "help_command should be true when explicitly set") + assert.True(t, cfg.IsHelpCommandEnabled(), "help command should be enabled when help_command is true") } func TestLoadRepoConfig_MaintenanceEmptyObject(t *testing.T) { From 53b57a084f9a17cc10774034787495104fe5f68e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:15:58 +0000 Subject: [PATCH 02/15] Finalize help command plumbing after validation fixes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 198 +++++++++--------- .../central_slash_command_workflow.go | 3 +- pkg/workflow/repo_config.go | 18 +- 3 files changed, 108 insertions(+), 111 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index a2048f5552d..f2b99208120 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -295,122 +295,118 @@ async function dispatchWorkflow(workflowId, ref, inputs) { core.info(`Skipping workflow '${workflowId}' because it is disabled.`); return false; } + throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`); + } +} - function isBuiltinHelpEnabled() { - const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); - return raw !== "false"; - } +function isBuiltinHelpEnabled() { + const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); + return raw !== "false"; +} - function parseHelpCommandsMetadata() { - const raw = process.env.GH_AW_HELP_COMMANDS || "[]"; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .map(item => { - const command = typeof item?.command === "string" ? item.command.trim() : ""; - if (!command) { - return null; - } - const description = typeof item?.description === "string" ? item.description.trim() : ""; - return { - command, - description, - centralized: Boolean(item?.centralized), - decentralized: Boolean(item?.decentralized), - }; - }) - .filter(item => item !== null) - .sort((left, right) => left.command.localeCompare(right.command)); - } catch (error) { - core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); - return []; - } +function parseHelpCommandsMetadata() { + const raw = process.env.GH_AW_HELP_COMMANDS || "[]"; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; } + return parsed + .map(item => { + const command = typeof item?.command === "string" ? item.command.trim() : ""; + if (!command) { + return null; + } + const description = typeof item?.description === "string" ? item.description.trim() : ""; + return { + command, + description, + centralized: Boolean(item?.centralized), + decentralized: Boolean(item?.decentralized), + }; + }) + .filter(item => item !== null) + .sort((left, right) => left.command.localeCompare(right.command)); + } catch (error) { + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + return []; + } +} - function helpDocsURL() { - const fromEnv = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); - return fromEnv || DEFAULT_SLASH_COMMAND_DOCS_URL; - } +function helpDocsURL() { + const fromEnv = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); + return fromEnv || DEFAULT_SLASH_COMMAND_DOCS_URL; +} - function buildCommandBulletLine(entry) { - const suffix = entry.description ? ` — ${entry.description}` : ""; - return `- \`/${entry.command}\`${suffix}`; - } +function buildCommandBulletLine(entry) { + const suffix = entry.description ? ` — ${entry.description}` : ""; + return `- \`/${entry.command}\`${suffix}`; +} - function buildHelpCommentBody(helpCommands) { - const centralized = helpCommands.filter(entry => entry.centralized); - const decentralized = helpCommands.filter(entry => entry.decentralized); - - const lines = [ - "## Supported Slash Commands", - "", - "**Centralized commands**", - ]; - if (centralized.length === 0) { - lines.push("- _None_"); - } else { - for (const entry of centralized) { - lines.push(buildCommandBulletLine(entry)); - } - } +function buildHelpCommentBody(helpCommands) { + const centralized = helpCommands.filter(entry => entry.centralized); + const decentralized = helpCommands.filter(entry => entry.decentralized); - lines.push("", "**Non-centralized commands**"); - if (decentralized.length === 0) { - lines.push("- _None_"); - } else { - for (const entry of decentralized) { - lines.push(buildCommandBulletLine(entry)); - } - } + const lines = ["## Supported Slash Commands", "", "**Centralized commands**"]; + if (centralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of centralized) { + lines.push(buildCommandBulletLine(entry)); + } + } - lines.push("", `Learn more: [Slash command documentation](${helpDocsURL()})`); - return lines.join("\n"); + lines.push("", "**Non-centralized commands**"); + if (decentralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of decentralized) { + lines.push(buildCommandBulletLine(entry)); } + } - async function postBuiltinHelpComment(commentBody) { - const owner = context.repo.owner; - const repo = context.repo.repo; - - const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; - if (issueNumber) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: commentBody, - headers: { - "X-GitHub-Api-Version": GITHUB_API_VERSION, - }, - }); - return true; - } + lines.push("", `Learn more: [Slash command documentation](${helpDocsURL()})`); + return lines.join("\n"); +} - if (context.eventName === "discussion" || context.eventName === "discussion_comment") { - const discussionID = context.payload?.discussion?.node_id; - if (!discussionID) { - core.warning("Unable to post builtin /help response: discussion node_id missing."); - return false; - } - await github.graphql( - ` - mutation($discussionId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { - comment { id } - } - }`, - { discussionId: discussionID, body: commentBody } - ); - return true; - } +async function postBuiltinHelpComment(commentBody) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; + if (issueNumber) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + headers: { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }); + return true; + } - core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); + if (context.eventName === "discussion" || context.eventName === "discussion_comment") { + const discussionID = context.payload?.discussion?.node_id; + if (!discussionID) { + core.warning("Unable to post builtin /help response: discussion node_id missing."); return false; } - throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`); + await github.graphql( + ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { id } + } + }`, + { discussionId: discussionID, body: commentBody } + ); + return true; } + + core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); + return false; } function toWorkflowDispatchID(route) { diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index a5fb79921f8..8d12b25c970 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "sort" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -370,7 +371,7 @@ jobs: GH_AW_SLASH_ROUTING: '` + escapeYAMLSingleQuoted(string(slashRoutesJSON)) + `' GH_AW_LABEL_ROUTING: '` + escapeYAMLSingleQuoted(string(labelRoutesJSON)) + `' GH_AW_HELP_COMMANDS: '` + escapeYAMLSingleQuoted(string(helpCommandsJSON)) + `' - GH_AW_HELP_COMMAND_ENABLED: '` + fmt.Sprintf("%t", helpCommandEnabled) + `' + GH_AW_HELP_COMMAND_ENABLED: '` + strconv.FormatBool(helpCommandEnabled) + `' GH_AW_SLASH_COMMAND_DOCS_URL: 'https://github.github.com/gh-aw/reference/command-triggers/' with: script: | diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 7ada18e8e8b..7b4f044315e 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -161,15 +161,6 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { return nil } - // IsHelpCommandEnabled returns true when the builtin centralized /help command - // handler should be enabled. The default is enabled. - func (r *RepoConfig) IsHelpCommandEnabled() bool { - if r == nil || r.HelpCommand == nil { - return true - } - return *r.HelpCommand - } - // Try boolean first: maintenance: false disables the feature. var b bool if err := json.Unmarshal(raw.Maintenance, &b); err == nil { @@ -188,6 +179,15 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { return nil } +// IsHelpCommandEnabled returns true when the builtin centralized /help command +// handler should be enabled. The default is enabled. +func (r *RepoConfig) IsHelpCommandEnabled() bool { + if r == nil || r.HelpCommand == nil { + return true + } + return *r.HelpCommand +} + // LoadRepoConfig loads and validates .github/workflows/aw.json from the // provided git root directory. The function returns a non-nil *RepoConfig // with default values when the file does not exist (the file is optional). From b5b9da4b05b3593d421f11766eadce35da6b4a28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:19:41 +0000 Subject: [PATCH 03/15] Address validation feedback for help command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 23 ++++++++++--------- .../central_slash_command_workflow.go | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index f2b99208120..0b8e9e7d9f0 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -312,20 +312,21 @@ function parseHelpCommandsMetadata() { return []; } return parsed - .map(item => { + .flatMap(item => { const command = typeof item?.command === "string" ? item.command.trim() : ""; if (!command) { - return null; + return []; } const description = typeof item?.description === "string" ? item.description.trim() : ""; - return { - command, - description, - centralized: Boolean(item?.centralized), - decentralized: Boolean(item?.decentralized), - }; + return [ + { + command, + description, + centralized: Boolean(item?.centralized), + decentralized: Boolean(item?.decentralized), + }, + ]; }) - .filter(item => item !== null) .sort((left, right) => left.command.localeCompare(right.command)); } catch (error) { core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); @@ -333,7 +334,7 @@ function parseHelpCommandsMetadata() { } } -function helpDocsURL() { +function getHelpDocsUrl() { const fromEnv = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); return fromEnv || DEFAULT_SLASH_COMMAND_DOCS_URL; } @@ -365,7 +366,7 @@ function buildHelpCommentBody(helpCommands) { } } - lines.push("", `Learn more: [Slash command documentation](${helpDocsURL()})`); + lines.push("", `Learn more: [Slash command documentation](${getHelpDocsUrl()})`); return lines.join("\n"); } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 8d12b25c970..4c0e96f9f45 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -408,6 +408,8 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr if wd.CommandCentralized { existing.Centralized = true } else { + // Slash commands are either centralized or inline in current workflow metadata: + // CommandCentralized=false indicates the command is handled in its own workflow. existing.Decentralized = true } byCommand[trimmed] = existing From 9b298bd5eb3e2a2d505fdb11a4859cbe4a659db0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:22:43 +0000 Subject: [PATCH 04/15] Incorporate review feedback for help handler Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 12 ++++++++++-- pkg/workflow/central_slash_command_workflow.go | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 0b8e9e7d9f0..b8f46bc4aa3 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -301,7 +301,14 @@ async function dispatchWorkflow(workflowId, ref, inputs) { function isBuiltinHelpEnabled() { const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); - return raw !== "false"; + if (!raw || raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + core.warning(`Invalid GH_AW_HELP_COMMAND_ENABLED value '${raw}', defaulting to enabled.`); + return true; } function parseHelpCommandsMetadata() { @@ -329,7 +336,8 @@ function parseHelpCommandsMetadata() { }) .sort((left, right) => left.command.localeCompare(right.command)); } catch (error) { - core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + const preview = raw.length > 160 ? `${raw.slice(0, 157)}...` : raw; + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)} (raw: ${preview})`); return []; } } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 4c0e96f9f45..7928e3f41b3 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -408,7 +408,7 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr if wd.CommandCentralized { existing.Centralized = true } else { - // Slash commands are either centralized or inline in current workflow metadata: + // Slash commands are either centralized or decentralized in current workflow metadata: // CommandCentralized=false indicates the command is handled in its own workflow. existing.Decentralized = true } From 27653ef24ca7f8a0a41f8aba95f91d08e1f30ea9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:26:06 +0000 Subject: [PATCH 05/15] Polish help command validation feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 5 ++--- pkg/workflow/central_slash_command_workflow.go | 9 +++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index b8f46bc4aa3..9862e57061d 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -307,7 +307,7 @@ function isBuiltinHelpEnabled() { if (raw === "false") { return false; } - core.warning(`Invalid GH_AW_HELP_COMMAND_ENABLED value '${raw}', defaulting to enabled.`); + core.warning(`Invalid GH_AW_HELP_COMMAND_ENABLED value '${raw}' (expected 'true' or 'false'), defaulting to enabled.`); return true; } @@ -336,8 +336,7 @@ function parseHelpCommandsMetadata() { }) .sort((left, right) => left.command.localeCompare(right.command)); } catch (error) { - const preview = raw.length > 160 ? `${raw.slice(0, 157)}...` : raw; - core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)} (raw: ${preview})`); + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); return []; } } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 7928e3f41b3..420dd35d66d 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -402,6 +402,15 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr continue } existing := byCommand[trimmed] + if existing.Description != "" && description != "" && existing.Description != description { + centralSlashCommandWorkflowLog.Printf( + "Conflicting descriptions for /%s: keeping %q, ignoring %q from workflow %s", + trimmed, + existing.Description, + description, + wd.WorkflowID, + ) + } if existing.Description == "" && description != "" { existing.Description = description } From 9c640b1e4a945e5235a474c4e604b1c4485d671a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:29:27 +0000 Subject: [PATCH 06/15] Refine help command diagnostics Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 2 +- pkg/workflow/central_slash_command_workflow.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 9862e57061d..766d2cb3421 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -307,7 +307,7 @@ function isBuiltinHelpEnabled() { if (raw === "false") { return false; } - core.warning(`Invalid GH_AW_HELP_COMMAND_ENABLED value '${raw}' (expected 'true' or 'false'), defaulting to enabled.`); + core.warning(`GH_AW_HELP_COMMAND_ENABLED must be 'true' or 'false', got '${raw}'. Defaulting to enabled.`); return true; } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 420dd35d66d..258ad59a06e 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -386,6 +386,7 @@ jobs: func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntry { type aggregate struct { Description string + DescriptionBy string Centralized bool Decentralized bool } @@ -404,15 +405,17 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr existing := byCommand[trimmed] if existing.Description != "" && description != "" && existing.Description != description { centralSlashCommandWorkflowLog.Printf( - "Conflicting descriptions for /%s: keeping %q, ignoring %q from workflow %s", + "Conflicting descriptions for /%s: keeping %q from workflow %s, ignoring %q from workflow %s", trimmed, existing.Description, + existing.DescriptionBy, description, wd.WorkflowID, ) } if existing.Description == "" && description != "" { existing.Description = description + existing.DescriptionBy = wd.WorkflowID } if wd.CommandCentralized { existing.Centralized = true From 68e923cf49bc1898d0144c6cf6720799ca77bd04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:32:46 +0000 Subject: [PATCH 07/15] Clarify help command fallback behavior Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 2 +- pkg/workflow/central_slash_command_workflow.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 766d2cb3421..c05f1b5856a 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -307,7 +307,7 @@ function isBuiltinHelpEnabled() { if (raw === "false") { return false; } - core.warning(`GH_AW_HELP_COMMAND_ENABLED must be 'true' or 'false', got '${raw}'. Defaulting to enabled.`); + core.warning(`Invalid value for GH_AW_HELP_COMMAND_ENABLED (expected 'true' or 'false', got '${raw}'). Using default: enabled.`); return true; } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 258ad59a06e..3433c5c72e0 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -413,6 +413,8 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr wd.WorkflowID, ) } + // Conflict resolution keeps the first non-empty description encountered + // while iterating workflowDataList, which is deterministic for compilation. if existing.Description == "" && description != "" { existing.Description = description existing.DescriptionBy = wd.WorkflowID From e0058d8fb3413d63b44e3f8462778a4555b05018 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:43:16 +0000 Subject: [PATCH 08/15] Add draft ADR for builtin centralized /help slash command Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-builtin-centralized-help-slash-command.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/adr/40476-builtin-centralized-help-slash-command.md diff --git a/docs/adr/40476-builtin-centralized-help-slash-command.md b/docs/adr/40476-builtin-centralized-help-slash-command.md new file mode 100644 index 00000000000..ce8d245576d --- /dev/null +++ b/docs/adr/40476-builtin-centralized-help-slash-command.md @@ -0,0 +1,42 @@ +# ADR-40476: Builtin centralized `/help` slash command handler + +**Date**: 2026-06-20 +**Status**: Draft + +## Context + +gh-aw lets a repository centralize slash-command routing into a single generated workflow (`on.slash_command.strategy: centralized`), but there was no in-context way for users to discover which slash commands a repository actually supports — they had to read documentation or inspect workflow source. The set of available commands (and which are centralized vs. handled in their own workflow) is already known to the compiler at generation time, so this information can be surfaced automatically rather than maintained by hand. This decision concerns where the `/help` capability should live, how its command inventory is produced, and how a repository can turn it off. [TODO: verify whether a user-facing request or issue motivated this work.] + +## Decision + +We will add a builtin `/help` handler to the centralized slash-command router (`route_slash_command.cjs`) that intercepts `/help` before normal route dispatch and posts a context-aware comment (in the issue, pull request, or discussion) listing centralized and non-centralized commands with their descriptions and a link to the slash-command documentation. The command inventory, descriptions, enablement flag, and docs URL are computed deterministically at compile time (`buildHelpCommandEntries`) and embedded as environment variables (`GH_AW_HELP_COMMANDS`, `GH_AW_HELP_COMMAND_ENABLED`, `GH_AW_SLASH_COMMAND_DOCS_URL`) in the generated central workflow. The handler is enabled by default whenever centralized routing is active and can be disabled with a top-level `help_command: false` field in `.github/workflows/aw.json`. + +## Alternatives Considered + +### Alternative 1: Per-repository user-authored `/help` workflow +Require each repository to author its own `/help` workflow if it wants command discovery. Rejected because the command inventory is already known to the compiler; duplicating it by hand would drift out of sync with the actual registered commands and impose boilerplate on every repository. + +### Alternative 2: Static documentation only +Document available commands only in the generated docs site without a runtime command. Rejected because it is not context-aware — it cannot reflect a specific repository's compiled command set, and users must leave the issue/PR/discussion thread to find it. + +### Alternative 3: A separate dedicated `/help` dispatch workflow +Route `/help` to its own generated workflow rather than intercepting inside the existing router. Rejected because it adds another generated file and a dispatch round-trip for what is a synchronous, read-only comment that the router already has all metadata to produce inline. + +## Consequences + +### Positive +- Users can discover the supported slash commands in-context, in the same issue/PR/discussion they are working in. +- The command inventory and descriptions are generated from compiled workflow metadata, so they stay in sync with the actual registered commands. +- Repositories that do not want the behavior can opt out with a single `help_command: false` field, defaulting to enabled. + +### Negative +- The central router now carries `/help`-specific branching and three new environment variables, increasing the coupling and surface area of `route_slash_command.cjs`. +- Description-conflict resolution keeps the first non-empty description encountered while iterating workflows; when two workflows share a command name with different descriptions, the help output may show a description the author did not expect (a warning is logged at compile time). + +### Neutral +- A new top-level `help_command` field is added to the `aw.json` repo config schema and typed config parsing. +- Help metadata (inventory, enablement, docs URL) is now embedded into the generated central slash-command workflow YAML. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27875764916) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* From 105d71ae8a5e4151ad2016f364568aa2e84ff8fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:51:03 +0000 Subject: [PATCH 09/15] wip: plan label commands in /help documentation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentic_commands.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/agentic_commands.yml b/.github/workflows/agentic_commands.yml index d7706cc1f2d..b3d37d634b8 100644 --- a/.github/workflows/agentic_commands.yml +++ b/.github/workflows/agentic_commands.yml @@ -128,6 +128,9 @@ jobs: env: GH_AW_SLASH_ROUTING: '{"*":[{"workflow":"skillet","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"ace":[{"workflow":"ace-editor","events":["pull_request_comment"],"ai_reaction":"eyes"}],"approach-validator":[{"workflow":"approach-validator","events":["issue_comment","pull_request_comment"],"ai_reaction":"eyes"}],"archie":[{"workflow":"archie","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"brave":[{"workflow":"brave","events":["issue_comment"],"ai_reaction":"eyes"}],"cloclo":[{"workflow":"cloclo","events":["discussion","discussion_comment","issue_comment","issues","pull_request","pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"craft":[{"workflow":"craft","events":["issues"],"ai_reaction":"eyes"}],"dependabot-burner":[{"workflow":"dependabot-burner","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"grumpy":[{"workflow":"grumpy-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"matt":[{"workflow":"mattpocock-skills-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"mergefest":[{"workflow":"mergefest","events":["pull_request_comment"],"ai_reaction":"eyes"}],"nit":[{"workflow":"pr-nitpick-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"plan":[{"workflow":"plan","events":["discussion_comment","issue_comment"],"ai_reaction":"eyes"}],"poem-bot":[{"workflow":"poem-bot","events":["issues"],"ai_reaction":"eyes"}],"review":[{"workflow":"design-decision-gate","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"},{"workflow":"pr-code-quality-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"},{"workflow":"test-quality-sentinel","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"ruflo":[{"workflow":"ruflo-backed-task","events":["issue_comment"],"ai_reaction":"eyes"}],"scout":[{"workflow":"scout","events":["discussion","discussion_comment","issue_comment","issues","pull_request","pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"security-review":[{"workflow":"security-review","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"smoke-agent-all-merged":[{"workflow":"smoke-agent-all-merged","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-all-none":[{"workflow":"smoke-agent-all-none","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-public-approved":[{"workflow":"smoke-agent-public-approved","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-public-none":[{"workflow":"smoke-agent-public-none","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-scoped-approved":[{"workflow":"smoke-agent-scoped-approved","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-antigravity":[{"workflow":"smoke-antigravity","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-call-workflow":[{"workflow":"smoke-call-workflow","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-claude":[{"workflow":"smoke-claude","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"heart"}],"smoke-codex":[{"workflow":"smoke-codex","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"hooray"}],"smoke-copilot":[{"workflow":"smoke-copilot","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-aoai-apikey":[{"workflow":"smoke-copilot-aoai-apikey","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-aoai-entra":[{"workflow":"smoke-copilot-aoai-entra","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-arm":[{"workflow":"smoke-copilot-arm","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-sdk":[{"workflow":"smoke-copilot-sdk","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-create-cross-repo-pr":[{"workflow":"smoke-create-cross-repo-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-crush":[{"workflow":"smoke-crush","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-gemini":[{"workflow":"smoke-gemini","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-multi-pr":[{"workflow":"smoke-multi-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-opencode":[{"workflow":"smoke-opencode","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-otel-backends":[{"workflow":"smoke-otel-backends","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-pi":[{"workflow":"smoke-pi","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-project":[{"workflow":"smoke-project","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-service-ports":[{"workflow":"smoke-service-ports","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-temporary-id":[{"workflow":"smoke-temporary-id","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-test-tools":[{"workflow":"smoke-test-tools","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-update-cross-repo-pr":[{"workflow":"smoke-update-cross-repo-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"summarize":[{"workflow":"pdf-summary","events":["issue_comment","issues"],"ai_reaction":"eyes"}],"tidy":[{"workflow":"tidy","events":["pull_request_comment"],"ai_reaction":"eyes"}],"unbloat":[{"workflow":"unbloat-docs","events":["pull_request_comment"],"ai_reaction":"eyes"}]}' GH_AW_LABEL_ROUTING: '{"approach-proposal":[{"workflow":"approach-validator","events":["issues","pull_request"],"ai_reaction":"eyes"}],"ci-doctor":[{"workflow":"ci-doctor","events":["pull_request"],"ai_reaction":"eyes"}],"cloclo":[{"workflow":"cloclo","events":["discussion","issues","pull_request"],"ai_reaction":"eyes"}],"dev":[{"workflow":"dev","events":["discussion","issues","pull_request"],"ai_reaction":"eyes"}],"necromancer":[{"workflow":"necromancer","events":["pull_request"],"ai_reaction":"eyes"}],"needs-design":[{"workflow":"approach-validator","events":["issues","pull_request"],"ai_reaction":"eyes"}],"smoke":[{"workflow":"smoke-copilot","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-copilot-aoai-apikey","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-copilot-aoai-entra","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-otel-backends","events":["pull_request"],"ai_reaction":"eyes"}],"smoke-sdk":[{"workflow":"smoke-copilot-sdk","events":["pull_request"],"ai_reaction":"eyes"}]}' + GH_AW_HELP_COMMANDS: '[{"command":"*","description":"Reviews pull requests by mapping any slash command to a matching repository skill under .github/skills","centralized":true,"decentralized":false},{"command":"ace","description":"Generates an ACE editor session link when invoked with /ace command on pull request comments","centralized":true,"decentralized":false},{"command":"approach-validator","description":"Validates proposed technical approaches before implementation begins using a sequential multi-agent panel of Devil''s Advocate, Alternatives Scout, Implementation Estimator, and Dead End Detector","centralized":true,"decentralized":false},{"command":"archie","description":"Generates Mermaid diagrams to visualize issue and pull request relationships when invoked with the /archie command","centralized":true,"decentralized":false},{"command":"brave","description":"Performs web searches using Brave search engine when invoked with /brave command in issues or PRs","centralized":true,"decentralized":false},{"command":"cloclo","centralized":true,"decentralized":false},{"command":"craft","description":"Generates new agentic workflow markdown files based on user requests when invoked with /craft command","centralized":true,"decentralized":false},{"command":"dependabot-burner","description":"Runs one grouped Dependabot remediation wave from schedule, manual dispatch, or /dependabot-burner on pull requests","centralized":true,"decentralized":false},{"command":"grumpy","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Performs critical code review with a focus on edge cases, potential bugs, and code quality issues","centralized":true,"decentralized":false},{"command":"matt","description":"Reviews pull requests using Matt Pocock''s engineering skills to provide targeted, high-quality improvement suggestions based on the type of changes","centralized":true,"decentralized":false},{"command":"mergefest","description":"Automatically merges the main branch into pull request branches when invoked with /mergefest command","centralized":true,"decentralized":false},{"command":"nit","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Provides detailed nitpicky code review focusing on style, best practices, and minor improvements","centralized":true,"decentralized":false},{"command":"plan","description":"Generates project plans and task breakdowns when invoked with /plan command in issues or PRs","centralized":true,"decentralized":false},{"command":"poem-bot","description":"Generates creative poems on specified themes when invoked with /poem-bot command","centralized":true,"decentralized":false},{"command":"q","description":"Intelligent assistant that answers questions, analyzes repositories, and can create PRs for workflow optimizations","centralized":false,"decentralized":true},{"command":"review","description":"Enforces Architecture Decision Records (ADRs) before implementation work can merge, detecting missing design decisions and generating draft ADRs using AI analysis","centralized":true,"decentralized":false},{"command":"ruflo","description":"Runs a repository task inside GitHub Agentic Workflows while delegating inner planning and coordination to Ruflo","centralized":true,"decentralized":false},{"command":"scout","description":"Performs deep research investigations using web search to gather and synthesize comprehensive information on any topic","centralized":true,"decentralized":false},{"command":"security-review","description":"Security-focused AI agent that reviews pull requests to identify changes that could weaken security posture or extend AWF boundaries","centralized":true,"decentralized":false},{"command":"smoke-agent-all-merged","description":"Guard policy smoke test: repos=all, min-integrity=merged (most restrictive)","centralized":true,"decentralized":false},{"command":"smoke-agent-all-none","description":"Guard policy smoke test: repos=all, min-integrity=none (most permissive)","centralized":true,"decentralized":false},{"command":"smoke-agent-public-approved","description":"Smoke test that validates assign-to-agent with the agentic-workflows custom agent","centralized":true,"decentralized":false},{"command":"smoke-agent-public-none","description":"Guard policy smoke test: repos=public, min-integrity=none","centralized":true,"decentralized":false},{"command":"smoke-agent-scoped-approved","description":"Guard policy smoke test: repos=[github/gh-aw, github/*], min-integrity=approved (scoped patterns)","centralized":true,"decentralized":false},{"command":"smoke-antigravity","description":"Smoke test workflow that validates Antigravity engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-call-workflow","description":"Smoke test for the call-workflow safe output - orchestrator that calls a worker via workflow_call at compile-time fan-out","centralized":true,"decentralized":false},{"command":"smoke-claude","description":"Smoke test workflow that validates Claude engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-codex","description":"Smoke test workflow that validates Codex engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-copilot","description":"Smoke Copilot","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-apikey","description":"Smoke Copilot - AOAI (apikey)","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-entra","description":"Smoke Copilot - AOAI (Entra)","centralized":true,"decentralized":false},{"command":"smoke-copilot-arm","description":"Smoke Copilot ARM64","centralized":true,"decentralized":false},{"command":"smoke-copilot-sdk","description":"Smoke Copilot SDK","centralized":true,"decentralized":false},{"command":"smoke-create-cross-repo-pr","description":"Smoke test validating cross-repo pull request creation in github/gh-aw-side-repo","centralized":true,"decentralized":false},{"command":"smoke-crush","description":"Smoke test workflow that validates Crush engine functionality","centralized":true,"decentralized":false},{"command":"smoke-gemini","description":"Smoke test workflow that validates Gemini engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-multi-pr","description":"Test creating multiple pull requests in a single workflow run","centralized":true,"decentralized":false},{"command":"smoke-opencode","description":"Smoke test workflow that validates OpenCode engine functionality","centralized":true,"decentralized":false},{"command":"smoke-otel-backends","description":"Smoke test that validates OTEL span export and query access for Sentry, Grafana, and Datadog","centralized":true,"decentralized":false},{"command":"smoke-pi","description":"Smoke test workflow that validates Pi engine functionality","centralized":true,"decentralized":false},{"command":"smoke-project","description":"Smoke Project - Test project operations","centralized":true,"decentralized":false},{"command":"smoke-service-ports","description":"Smoke test to validate --allow-host-service-ports with Redis service container","centralized":true,"decentralized":false},{"command":"smoke-temporary-id","description":"Test temporary ID functionality for issue chaining and cross-references","centralized":true,"decentralized":false},{"command":"smoke-test-tools","description":"Smoke test to validate common development tools are available in the agent container","centralized":true,"decentralized":false},{"command":"smoke-update-cross-repo-pr","description":"Smoke test validating cross-repo pull request updates in github/gh-aw-side-repo by adding lines from Homer''s Odyssey to the README","centralized":true,"decentralized":false},{"command":"summarize","description":"pdf summarizer","centralized":true,"decentralized":false},{"command":"tidy","description":"Automatically formats and tidies code files (Go, JS, TypeScript) when code changes are pushed or on command","centralized":true,"decentralized":false},{"command":"unbloat","description":"Reviews and simplifies documentation by reducing verbosity while maintaining clarity and completeness","centralized":true,"decentralized":false}]' + GH_AW_HELP_COMMAND_ENABLED: 'true' + GH_AW_SLASH_COMMAND_DOCS_URL: 'https://github.github.com/gh-aw/reference/command-triggers/' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); From 6914a01bf9f351af862526c4400cd99c91878cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:57:20 +0000 Subject: [PATCH 10/15] Include label commands in builtin /help documentation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 20 +++++++- actions/setup/js/route_slash_command.test.cjs | 9 ++-- .../central_slash_command_workflow.go | 46 ++++++++++++++++++- .../central_slash_command_workflow_test.go | 11 +++++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index c05f1b5856a..27b90367788 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -331,6 +331,7 @@ function parseHelpCommandsMetadata() { description, centralized: Boolean(item?.centralized), decentralized: Boolean(item?.decentralized), + label: Boolean(item?.label), }, ]; }) @@ -351,11 +352,17 @@ function buildCommandBulletLine(entry) { return `- \`/${entry.command}\`${suffix}`; } +function buildLabelBulletLine(entry) { + const suffix = entry.description ? ` — ${entry.description}` : ""; + return `- \`${entry.command}\`${suffix}`; +} + function buildHelpCommentBody(helpCommands) { const centralized = helpCommands.filter(entry => entry.centralized); const decentralized = helpCommands.filter(entry => entry.decentralized); + const labels = helpCommands.filter(entry => entry.label); - const lines = ["## Supported Slash Commands", "", "**Centralized commands**"]; + const lines = ["## Supported Commands", "", "**Centralized slash commands**"]; if (centralized.length === 0) { lines.push("- _None_"); } else { @@ -364,7 +371,7 @@ function buildHelpCommentBody(helpCommands) { } } - lines.push("", "**Non-centralized commands**"); + lines.push("", "**Non-centralized slash commands**"); if (decentralized.length === 0) { lines.push("- _None_"); } else { @@ -373,6 +380,15 @@ function buildHelpCommentBody(helpCommands) { } } + lines.push("", "**Label commands**"); + if (labels.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of labels) { + lines.push(buildLabelBulletLine(entry)); + } + } + lines.push("", `Learn more: [Slash command documentation](${getHelpDocsUrl()})`); return lines.join("\n"); } diff --git a/actions/setup/js/route_slash_command.test.cjs b/actions/setup/js/route_slash_command.test.cjs index bacfcbc893f..32ac80cb1bc 100644 --- a/actions/setup/js/route_slash_command.test.cjs +++ b/actions/setup/js/route_slash_command.test.cjs @@ -148,6 +148,7 @@ describe("route_slash_command", () => { process.env.GH_AW_HELP_COMMANDS = JSON.stringify([ { command: "archie", description: "Run archie workflow", centralized: true, decentralized: false }, { command: "local-summary", description: "Run summary workflow", centralized: false, decentralized: true }, + { command: "triage", description: "Apply triage label", label: true }, ]); process.env.GH_AW_HELP_COMMAND_ENABLED = "true"; process.env.GH_AW_SLASH_COMMAND_DOCS_URL = "https://github.github.com/gh-aw/reference/command-triggers/"; @@ -204,11 +205,13 @@ describe("route_slash_command", () => { expect(dispatchCalls).toHaveLength(0); expect(issueCommentCalls).toHaveLength(1); expect(issueCommentCalls[0].issue_number).toBe(77); - expect(issueCommentCalls[0].body).toContain("## Supported Slash Commands"); - expect(issueCommentCalls[0].body).toContain("**Centralized commands**"); + expect(issueCommentCalls[0].body).toContain("## Supported Commands"); + expect(issueCommentCalls[0].body).toContain("**Centralized slash commands**"); expect(issueCommentCalls[0].body).toContain("- `/archie` — Run archie workflow"); - expect(issueCommentCalls[0].body).toContain("**Non-centralized commands**"); + expect(issueCommentCalls[0].body).toContain("**Non-centralized slash commands**"); expect(issueCommentCalls[0].body).toContain("- `/local-summary` — Run summary workflow"); + expect(issueCommentCalls[0].body).toContain("**Label commands**"); + expect(issueCommentCalls[0].body).toContain("- `triage` — Apply triage label"); expect(issueCommentCalls[0].body).toContain("https://github.github.com/gh-aw/reference/command-triggers/"); }); diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 3433c5c72e0..13a6a2ac1b5 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -42,6 +42,7 @@ type helpCommandEntry struct { Description string `json:"description,omitempty"` Centralized bool `json:"centralized"` Decentralized bool `json:"decentralized"` + Label bool `json:"label,omitempty"` } // GenerateCentralSlashCommandWorkflow generates a single centralized slash-command trigger @@ -389,14 +390,17 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr DescriptionBy string Centralized bool Decentralized bool + Label bool } byCommand := make(map[string]aggregate) + byLabel := make(map[string]aggregate) for _, wd := range workflowDataList { - if wd == nil || len(wd.Command) == 0 { + if wd == nil { continue } description := strings.TrimSpace(wd.Description) + for _, commandName := range wd.Command { trimmed := strings.TrimSpace(commandName) if trimmed == "" { @@ -428,6 +432,30 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr } byCommand[trimmed] = existing } + + for _, labelName := range wd.LabelCommand { + trimmed := strings.TrimSpace(labelName) + if trimmed == "" { + continue + } + existing := byLabel[trimmed] + if existing.Description != "" && description != "" && existing.Description != description { + centralSlashCommandWorkflowLog.Printf( + "Conflicting descriptions for label %q: keeping %q from workflow %s, ignoring %q from workflow %s", + trimmed, + existing.Description, + existing.DescriptionBy, + description, + wd.WorkflowID, + ) + } + if existing.Description == "" && description != "" { + existing.Description = description + existing.DescriptionBy = wd.WorkflowID + } + existing.Label = true + byLabel[trimmed] = existing + } } commands := make([]string, 0, len(byCommand)) @@ -436,7 +464,13 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr } sort.Strings(commands) - entries := make([]helpCommandEntry, 0, len(commands)) + labels := make([]string, 0, len(byLabel)) + for labelName := range byLabel { + labels = append(labels, labelName) + } + sort.Strings(labels) + + entries := make([]helpCommandEntry, 0, len(commands)+len(labels)) for _, command := range commands { item := byCommand[command] entries = append(entries, helpCommandEntry{ @@ -446,6 +480,14 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr Decentralized: item.Decentralized, }) } + for _, labelName := range labels { + item := byLabel[labelName] + entries = append(entries, helpCommandEntry{ + Command: labelName, + Description: item.Description, + Label: true, + }) + } return entries } diff --git a/pkg/workflow/central_slash_command_workflow_test.go b/pkg/workflow/central_slash_command_workflow_test.go index 54583b310aa..221ebf0e0fe 100644 --- a/pkg/workflow/central_slash_command_workflow_test.go +++ b/pkg/workflow/central_slash_command_workflow_test.go @@ -435,12 +435,23 @@ func TestBuildHelpCommandEntries(t *testing.T) { CommandCentralized: false, Description: "Summarize latest updates", }, + { + WorkflowID: "label-triage", + LabelCommand: []string{"triage-label", "urgent"}, + Description: "Triage via label", + }, + { + WorkflowID: "label-duplicate", + LabelCommand: []string{"triage-label"}, + }, } require.Equal(t, []helpCommandEntry{ {Command: "helpful", Description: "Triage work items", Centralized: true}, {Command: "summary", Description: "Summarize latest updates", Decentralized: true}, {Command: "triage", Description: "Triage work items", Centralized: true, Decentralized: true}, + {Command: "triage-label", Description: "Triage via label", Label: true}, + {Command: "urgent", Description: "Triage via label", Label: true}, }, buildHelpCommandEntries(data)) } From 90067b098e8fef94e461d394a16aa22fe8841e53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:02:20 +0000 Subject: [PATCH 11/15] Tighten buildHelpCommandEntries nil/empty guard Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/central_slash_command_workflow.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 13a6a2ac1b5..009841c0421 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -396,7 +396,7 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr byLabel := make(map[string]aggregate) for _, wd := range workflowDataList { - if wd == nil { + if wd == nil || (len(wd.Command) == 0 && len(wd.LabelCommand) == 0) { continue } description := strings.TrimSpace(wd.Description) From 835a6f179b39c166b2b109f7a59d981ffc6f4102 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:12:05 +0000 Subject: [PATCH 12/15] Add initial PR finisher plan Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentic_commands.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic_commands.yml b/.github/workflows/agentic_commands.yml index b3d37d634b8..b5351fc9d73 100644 --- a/.github/workflows/agentic_commands.yml +++ b/.github/workflows/agentic_commands.yml @@ -128,7 +128,7 @@ jobs: env: GH_AW_SLASH_ROUTING: '{"*":[{"workflow":"skillet","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"ace":[{"workflow":"ace-editor","events":["pull_request_comment"],"ai_reaction":"eyes"}],"approach-validator":[{"workflow":"approach-validator","events":["issue_comment","pull_request_comment"],"ai_reaction":"eyes"}],"archie":[{"workflow":"archie","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"brave":[{"workflow":"brave","events":["issue_comment"],"ai_reaction":"eyes"}],"cloclo":[{"workflow":"cloclo","events":["discussion","discussion_comment","issue_comment","issues","pull_request","pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"craft":[{"workflow":"craft","events":["issues"],"ai_reaction":"eyes"}],"dependabot-burner":[{"workflow":"dependabot-burner","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"grumpy":[{"workflow":"grumpy-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"matt":[{"workflow":"mattpocock-skills-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"mergefest":[{"workflow":"mergefest","events":["pull_request_comment"],"ai_reaction":"eyes"}],"nit":[{"workflow":"pr-nitpick-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"plan":[{"workflow":"plan","events":["discussion_comment","issue_comment"],"ai_reaction":"eyes"}],"poem-bot":[{"workflow":"poem-bot","events":["issues"],"ai_reaction":"eyes"}],"review":[{"workflow":"design-decision-gate","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"},{"workflow":"pr-code-quality-reviewer","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"},{"workflow":"test-quality-sentinel","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"ruflo":[{"workflow":"ruflo-backed-task","events":["issue_comment"],"ai_reaction":"eyes"}],"scout":[{"workflow":"scout","events":["discussion","discussion_comment","issue_comment","issues","pull_request","pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"security-review":[{"workflow":"security-review","events":["pull_request_comment","pull_request_review_comment"],"ai_reaction":"eyes"}],"smoke-agent-all-merged":[{"workflow":"smoke-agent-all-merged","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-all-none":[{"workflow":"smoke-agent-all-none","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-public-approved":[{"workflow":"smoke-agent-public-approved","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-public-none":[{"workflow":"smoke-agent-public-none","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-agent-scoped-approved":[{"workflow":"smoke-agent-scoped-approved","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-antigravity":[{"workflow":"smoke-antigravity","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-call-workflow":[{"workflow":"smoke-call-workflow","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-claude":[{"workflow":"smoke-claude","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"heart"}],"smoke-codex":[{"workflow":"smoke-codex","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"hooray"}],"smoke-copilot":[{"workflow":"smoke-copilot","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-aoai-apikey":[{"workflow":"smoke-copilot-aoai-apikey","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-aoai-entra":[{"workflow":"smoke-copilot-aoai-entra","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-arm":[{"workflow":"smoke-copilot-arm","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-copilot-sdk":[{"workflow":"smoke-copilot-sdk","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-create-cross-repo-pr":[{"workflow":"smoke-create-cross-repo-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-crush":[{"workflow":"smoke-crush","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-gemini":[{"workflow":"smoke-gemini","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-multi-pr":[{"workflow":"smoke-multi-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-opencode":[{"workflow":"smoke-opencode","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-otel-backends":[{"workflow":"smoke-otel-backends","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-pi":[{"workflow":"smoke-pi","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"rocket"}],"smoke-project":[{"workflow":"smoke-project","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-service-ports":[{"workflow":"smoke-service-ports","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-temporary-id":[{"workflow":"smoke-temporary-id","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-test-tools":[{"workflow":"smoke-test-tools","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"smoke-update-cross-repo-pr":[{"workflow":"smoke-update-cross-repo-pr","events":["issue_comment","issues","pull_request","pull_request_comment"],"ai_reaction":"eyes"}],"summarize":[{"workflow":"pdf-summary","events":["issue_comment","issues"],"ai_reaction":"eyes"}],"tidy":[{"workflow":"tidy","events":["pull_request_comment"],"ai_reaction":"eyes"}],"unbloat":[{"workflow":"unbloat-docs","events":["pull_request_comment"],"ai_reaction":"eyes"}]}' GH_AW_LABEL_ROUTING: '{"approach-proposal":[{"workflow":"approach-validator","events":["issues","pull_request"],"ai_reaction":"eyes"}],"ci-doctor":[{"workflow":"ci-doctor","events":["pull_request"],"ai_reaction":"eyes"}],"cloclo":[{"workflow":"cloclo","events":["discussion","issues","pull_request"],"ai_reaction":"eyes"}],"dev":[{"workflow":"dev","events":["discussion","issues","pull_request"],"ai_reaction":"eyes"}],"necromancer":[{"workflow":"necromancer","events":["pull_request"],"ai_reaction":"eyes"}],"needs-design":[{"workflow":"approach-validator","events":["issues","pull_request"],"ai_reaction":"eyes"}],"smoke":[{"workflow":"smoke-copilot","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-copilot-aoai-apikey","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-copilot-aoai-entra","events":["pull_request"],"ai_reaction":"eyes"},{"workflow":"smoke-otel-backends","events":["pull_request"],"ai_reaction":"eyes"}],"smoke-sdk":[{"workflow":"smoke-copilot-sdk","events":["pull_request"],"ai_reaction":"eyes"}]}' - GH_AW_HELP_COMMANDS: '[{"command":"*","description":"Reviews pull requests by mapping any slash command to a matching repository skill under .github/skills","centralized":true,"decentralized":false},{"command":"ace","description":"Generates an ACE editor session link when invoked with /ace command on pull request comments","centralized":true,"decentralized":false},{"command":"approach-validator","description":"Validates proposed technical approaches before implementation begins using a sequential multi-agent panel of Devil''s Advocate, Alternatives Scout, Implementation Estimator, and Dead End Detector","centralized":true,"decentralized":false},{"command":"archie","description":"Generates Mermaid diagrams to visualize issue and pull request relationships when invoked with the /archie command","centralized":true,"decentralized":false},{"command":"brave","description":"Performs web searches using Brave search engine when invoked with /brave command in issues or PRs","centralized":true,"decentralized":false},{"command":"cloclo","centralized":true,"decentralized":false},{"command":"craft","description":"Generates new agentic workflow markdown files based on user requests when invoked with /craft command","centralized":true,"decentralized":false},{"command":"dependabot-burner","description":"Runs one grouped Dependabot remediation wave from schedule, manual dispatch, or /dependabot-burner on pull requests","centralized":true,"decentralized":false},{"command":"grumpy","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Performs critical code review with a focus on edge cases, potential bugs, and code quality issues","centralized":true,"decentralized":false},{"command":"matt","description":"Reviews pull requests using Matt Pocock''s engineering skills to provide targeted, high-quality improvement suggestions based on the type of changes","centralized":true,"decentralized":false},{"command":"mergefest","description":"Automatically merges the main branch into pull request branches when invoked with /mergefest command","centralized":true,"decentralized":false},{"command":"nit","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Provides detailed nitpicky code review focusing on style, best practices, and minor improvements","centralized":true,"decentralized":false},{"command":"plan","description":"Generates project plans and task breakdowns when invoked with /plan command in issues or PRs","centralized":true,"decentralized":false},{"command":"poem-bot","description":"Generates creative poems on specified themes when invoked with /poem-bot command","centralized":true,"decentralized":false},{"command":"q","description":"Intelligent assistant that answers questions, analyzes repositories, and can create PRs for workflow optimizations","centralized":false,"decentralized":true},{"command":"review","description":"Enforces Architecture Decision Records (ADRs) before implementation work can merge, detecting missing design decisions and generating draft ADRs using AI analysis","centralized":true,"decentralized":false},{"command":"ruflo","description":"Runs a repository task inside GitHub Agentic Workflows while delegating inner planning and coordination to Ruflo","centralized":true,"decentralized":false},{"command":"scout","description":"Performs deep research investigations using web search to gather and synthesize comprehensive information on any topic","centralized":true,"decentralized":false},{"command":"security-review","description":"Security-focused AI agent that reviews pull requests to identify changes that could weaken security posture or extend AWF boundaries","centralized":true,"decentralized":false},{"command":"smoke-agent-all-merged","description":"Guard policy smoke test: repos=all, min-integrity=merged (most restrictive)","centralized":true,"decentralized":false},{"command":"smoke-agent-all-none","description":"Guard policy smoke test: repos=all, min-integrity=none (most permissive)","centralized":true,"decentralized":false},{"command":"smoke-agent-public-approved","description":"Smoke test that validates assign-to-agent with the agentic-workflows custom agent","centralized":true,"decentralized":false},{"command":"smoke-agent-public-none","description":"Guard policy smoke test: repos=public, min-integrity=none","centralized":true,"decentralized":false},{"command":"smoke-agent-scoped-approved","description":"Guard policy smoke test: repos=[github/gh-aw, github/*], min-integrity=approved (scoped patterns)","centralized":true,"decentralized":false},{"command":"smoke-antigravity","description":"Smoke test workflow that validates Antigravity engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-call-workflow","description":"Smoke test for the call-workflow safe output - orchestrator that calls a worker via workflow_call at compile-time fan-out","centralized":true,"decentralized":false},{"command":"smoke-claude","description":"Smoke test workflow that validates Claude engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-codex","description":"Smoke test workflow that validates Codex engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-copilot","description":"Smoke Copilot","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-apikey","description":"Smoke Copilot - AOAI (apikey)","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-entra","description":"Smoke Copilot - AOAI (Entra)","centralized":true,"decentralized":false},{"command":"smoke-copilot-arm","description":"Smoke Copilot ARM64","centralized":true,"decentralized":false},{"command":"smoke-copilot-sdk","description":"Smoke Copilot SDK","centralized":true,"decentralized":false},{"command":"smoke-create-cross-repo-pr","description":"Smoke test validating cross-repo pull request creation in github/gh-aw-side-repo","centralized":true,"decentralized":false},{"command":"smoke-crush","description":"Smoke test workflow that validates Crush engine functionality","centralized":true,"decentralized":false},{"command":"smoke-gemini","description":"Smoke test workflow that validates Gemini engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-multi-pr","description":"Test creating multiple pull requests in a single workflow run","centralized":true,"decentralized":false},{"command":"smoke-opencode","description":"Smoke test workflow that validates OpenCode engine functionality","centralized":true,"decentralized":false},{"command":"smoke-otel-backends","description":"Smoke test that validates OTEL span export and query access for Sentry, Grafana, and Datadog","centralized":true,"decentralized":false},{"command":"smoke-pi","description":"Smoke test workflow that validates Pi engine functionality","centralized":true,"decentralized":false},{"command":"smoke-project","description":"Smoke Project - Test project operations","centralized":true,"decentralized":false},{"command":"smoke-service-ports","description":"Smoke test to validate --allow-host-service-ports with Redis service container","centralized":true,"decentralized":false},{"command":"smoke-temporary-id","description":"Test temporary ID functionality for issue chaining and cross-references","centralized":true,"decentralized":false},{"command":"smoke-test-tools","description":"Smoke test to validate common development tools are available in the agent container","centralized":true,"decentralized":false},{"command":"smoke-update-cross-repo-pr","description":"Smoke test validating cross-repo pull request updates in github/gh-aw-side-repo by adding lines from Homer''s Odyssey to the README","centralized":true,"decentralized":false},{"command":"summarize","description":"pdf summarizer","centralized":true,"decentralized":false},{"command":"tidy","description":"Automatically formats and tidies code files (Go, JS, TypeScript) when code changes are pushed or on command","centralized":true,"decentralized":false},{"command":"unbloat","description":"Reviews and simplifies documentation by reducing verbosity while maintaining clarity and completeness","centralized":true,"decentralized":false}]' + GH_AW_HELP_COMMANDS: '[{"command":"*","description":"Reviews pull requests by mapping any slash command to a matching repository skill under .github/skills","centralized":true,"decentralized":false},{"command":"ace","description":"Generates an ACE editor session link when invoked with /ace command on pull request comments","centralized":true,"decentralized":false},{"command":"approach-validator","description":"Validates proposed technical approaches before implementation begins using a sequential multi-agent panel of Devil''s Advocate, Alternatives Scout, Implementation Estimator, and Dead End Detector","centralized":true,"decentralized":false},{"command":"archie","description":"Generates Mermaid diagrams to visualize issue and pull request relationships when invoked with the /archie command","centralized":true,"decentralized":false},{"command":"brave","description":"Performs web searches using Brave search engine when invoked with /brave command in issues or PRs","centralized":true,"decentralized":false},{"command":"cloclo","centralized":true,"decentralized":false},{"command":"craft","description":"Generates new agentic workflow markdown files based on user requests when invoked with /craft command","centralized":true,"decentralized":false},{"command":"dependabot-burner","description":"Runs one grouped Dependabot remediation wave from schedule, manual dispatch, or /dependabot-burner on pull requests","centralized":true,"decentralized":false},{"command":"grumpy","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Performs critical code review with a focus on edge cases, potential bugs, and code quality issues","centralized":true,"decentralized":false},{"command":"matt","description":"Reviews pull requests using Matt Pocock''s engineering skills to provide targeted, high-quality improvement suggestions based on the type of changes","centralized":true,"decentralized":false},{"command":"mergefest","description":"Automatically merges the main branch into pull request branches when invoked with /mergefest command","centralized":true,"decentralized":false},{"command":"nit","description":"⚠️ DEPRECATED: Use PR Code Quality Reviewer (pr-code-quality-reviewer) instead. Provides detailed nitpicky code review focusing on style, best practices, and minor improvements","centralized":true,"decentralized":false},{"command":"plan","description":"Generates project plans and task breakdowns when invoked with /plan command in issues or PRs","centralized":true,"decentralized":false},{"command":"poem-bot","description":"Generates creative poems on specified themes when invoked with /poem-bot command","centralized":true,"decentralized":false},{"command":"q","description":"Intelligent assistant that answers questions, analyzes repositories, and can create PRs for workflow optimizations","centralized":false,"decentralized":true},{"command":"review","description":"Enforces Architecture Decision Records (ADRs) before implementation work can merge, detecting missing design decisions and generating draft ADRs using AI analysis","centralized":true,"decentralized":false},{"command":"ruflo","description":"Runs a repository task inside GitHub Agentic Workflows while delegating inner planning and coordination to Ruflo","centralized":true,"decentralized":false},{"command":"scout","description":"Performs deep research investigations using web search to gather and synthesize comprehensive information on any topic","centralized":true,"decentralized":false},{"command":"security-review","description":"Security-focused AI agent that reviews pull requests to identify changes that could weaken security posture or extend AWF boundaries","centralized":true,"decentralized":false},{"command":"smoke-agent-all-merged","description":"Guard policy smoke test: repos=all, min-integrity=merged (most restrictive)","centralized":true,"decentralized":false},{"command":"smoke-agent-all-none","description":"Guard policy smoke test: repos=all, min-integrity=none (most permissive)","centralized":true,"decentralized":false},{"command":"smoke-agent-public-approved","description":"Smoke test that validates assign-to-agent with the agentic-workflows custom agent","centralized":true,"decentralized":false},{"command":"smoke-agent-public-none","description":"Guard policy smoke test: repos=public, min-integrity=none","centralized":true,"decentralized":false},{"command":"smoke-agent-scoped-approved","description":"Guard policy smoke test: repos=[github/gh-aw, github/*], min-integrity=approved (scoped patterns)","centralized":true,"decentralized":false},{"command":"smoke-antigravity","description":"Smoke test workflow that validates Antigravity engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-call-workflow","description":"Smoke test for the call-workflow safe output - orchestrator that calls a worker via workflow_call at compile-time fan-out","centralized":true,"decentralized":false},{"command":"smoke-claude","description":"Smoke test workflow that validates Claude engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-codex","description":"Smoke test workflow that validates Codex engine functionality by reviewing recent PRs twice daily","centralized":true,"decentralized":false},{"command":"smoke-copilot","description":"Smoke Copilot","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-apikey","description":"Smoke Copilot - AOAI (apikey)","centralized":true,"decentralized":false},{"command":"smoke-copilot-aoai-entra","description":"Smoke Copilot - AOAI (Entra)","centralized":true,"decentralized":false},{"command":"smoke-copilot-arm","description":"Smoke Copilot ARM64","centralized":true,"decentralized":false},{"command":"smoke-copilot-sdk","description":"Smoke Copilot SDK","centralized":true,"decentralized":false},{"command":"smoke-create-cross-repo-pr","description":"Smoke test validating cross-repo pull request creation in github/gh-aw-side-repo","centralized":true,"decentralized":false},{"command":"smoke-crush","description":"Smoke test workflow that validates Crush engine functionality","centralized":true,"decentralized":false},{"command":"smoke-gemini","description":"Smoke test workflow that validates Gemini engine functionality twice daily","centralized":true,"decentralized":false},{"command":"smoke-multi-pr","description":"Test creating multiple pull requests in a single workflow run","centralized":true,"decentralized":false},{"command":"smoke-opencode","description":"Smoke test workflow that validates OpenCode engine functionality","centralized":true,"decentralized":false},{"command":"smoke-otel-backends","description":"Smoke test that validates OTEL span export and query access for Sentry, Grafana, and Datadog","centralized":true,"decentralized":false},{"command":"smoke-pi","description":"Smoke test workflow that validates Pi engine functionality","centralized":true,"decentralized":false},{"command":"smoke-project","description":"Smoke Project - Test project operations","centralized":true,"decentralized":false},{"command":"smoke-service-ports","description":"Smoke test to validate --allow-host-service-ports with Redis service container","centralized":true,"decentralized":false},{"command":"smoke-temporary-id","description":"Test temporary ID functionality for issue chaining and cross-references","centralized":true,"decentralized":false},{"command":"smoke-test-tools","description":"Smoke test to validate common development tools are available in the agent container","centralized":true,"decentralized":false},{"command":"smoke-update-cross-repo-pr","description":"Smoke test validating cross-repo pull request updates in github/gh-aw-side-repo by adding lines from Homer''s Odyssey to the README","centralized":true,"decentralized":false},{"command":"summarize","description":"pdf summarizer","centralized":true,"decentralized":false},{"command":"tidy","description":"Automatically formats and tidies code files (Go, JS, TypeScript) when code changes are pushed or on command","centralized":true,"decentralized":false},{"command":"unbloat","description":"Reviews and simplifies documentation by reducing verbosity while maintaining clarity and completeness","centralized":true,"decentralized":false},{"command":"approach-proposal","description":"Validates proposed technical approaches before implementation begins using a sequential multi-agent panel of Devil''s Advocate, Alternatives Scout, Implementation Estimator, and Dead End Detector","centralized":false,"decentralized":false,"label":true},{"command":"ci-doctor","description":"Investigates failed CI workflows to identify root causes and patterns, creating issues with diagnostic information; also reviews PR check failures when the ci-doctor label is applied","centralized":false,"decentralized":false,"label":true},{"command":"cloclo","centralized":false,"decentralized":false,"label":true},{"command":"dev","description":"Daily status report for gh-aw project","centralized":false,"decentralized":false,"label":true},{"command":"necromancer","description":"Investigates merge-ready pull requests, traces root-cause issues, and adds regression tests before merge","centralized":false,"decentralized":false,"label":true},{"command":"needs-design","description":"Validates proposed technical approaches before implementation begins using a sequential multi-agent panel of Devil''s Advocate, Alternatives Scout, Implementation Estimator, and Dead End Detector","centralized":false,"decentralized":false,"label":true},{"command":"smoke","description":"Smoke Copilot - AOAI (apikey)","centralized":false,"decentralized":false,"label":true},{"command":"smoke-sdk","description":"Smoke Copilot SDK","centralized":false,"decentralized":false,"label":true}]' GH_AW_HELP_COMMAND_ENABLED: 'true' GH_AW_SLASH_COMMAND_DOCS_URL: 'https://github.github.com/gh-aw/reference/command-triggers/' with: From 29617c1ce97e3717314e46b30d9db330b84c4922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:35:43 +0000 Subject: [PATCH 13/15] Address all review threads: fix /help fallthrough, error handling, reaction, sanitization, deduplication, dead code, compile warning Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 107 +++++++------- actions/setup/js/route_slash_command.test.cjs | 130 +++++++++++++++++- pkg/cli/compile_post_processing.go | 3 +- .../central_slash_command_workflow.go | 12 +- .../central_slash_command_workflow_test.go | 22 +++ pkg/workflow/repo_config.go | 2 +- 6 files changed, 223 insertions(+), 53 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 27b90367788..284736c20f7 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -7,7 +7,6 @@ const { matchesCommandName, parseSlashCommand } = require("./slash_command_match // Keep this aligned with the current default stable GitHub REST API version used by workflows. // Update when GitHub advances the recommended version to avoid sunset/deprecation warnings. const GITHUB_API_VERSION = "2022-11-28"; -const DEFAULT_SLASH_COMMAND_DOCS_URL = "https://github.github.com/gh-aw/reference/command-triggers/"; /** * Appends centralized command routing details to the current step summary. @@ -342,24 +341,34 @@ function parseHelpCommandsMetadata() { } } -function getHelpDocsUrl() { - const fromEnv = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); - return fromEnv || DEFAULT_SLASH_COMMAND_DOCS_URL; +/** + * Neutralizes bare @mentions in a description string so they do not trigger + * GitHub notifications. Wraps matched mentions in backticks. + * @param {string} description + * @returns {string} + */ +function neutralizeDescriptionMentions(description) { + return description.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?)/g, (_, p1, p2) => `${p1}\`@${p2}\``); } function buildCommandBulletLine(entry) { - const suffix = entry.description ? ` — ${entry.description}` : ""; + const desc = entry.description ? neutralizeDescriptionMentions(entry.description) : ""; + const suffix = desc ? ` — ${desc}` : ""; return `- \`/${entry.command}\`${suffix}`; } function buildLabelBulletLine(entry) { - const suffix = entry.description ? ` — ${entry.description}` : ""; + const desc = entry.description ? neutralizeDescriptionMentions(entry.description) : ""; + const suffix = desc ? ` — ${desc}` : ""; return `- \`${entry.command}\`${suffix}`; } function buildHelpCommentBody(helpCommands) { + // Commands that are centralized should appear only in the centralized section even if + // they are also registered as decentralized (e.g. two workflows for the same command). const centralized = helpCommands.filter(entry => entry.centralized); - const decentralized = helpCommands.filter(entry => entry.decentralized); + const centralizedNames = new Set(centralized.map(entry => entry.command)); + const decentralized = helpCommands.filter(entry => entry.decentralized && !centralizedNames.has(entry.command)); const labels = helpCommands.filter(entry => entry.label); const lines = ["## Supported Commands", "", "**Centralized slash commands**"]; @@ -389,7 +398,7 @@ function buildHelpCommentBody(helpCommands) { } } - lines.push("", `Learn more: [Slash command documentation](${getHelpDocsUrl()})`); + lines.push("", `Learn more: [Slash command documentation](${(process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim()})`); return lines.join("\n"); } @@ -397,40 +406,45 @@ async function postBuiltinHelpComment(commentBody) { const owner = context.repo.owner; const repo = context.repo.repo; - const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; - if (issueNumber) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: commentBody, - headers: { - "X-GitHub-Api-Version": GITHUB_API_VERSION, - }, - }); - return true; - } + try { + const issueNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number; + if (issueNumber) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + headers: { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }); + return true; + } - if (context.eventName === "discussion" || context.eventName === "discussion_comment") { - const discussionID = context.payload?.discussion?.node_id; - if (!discussionID) { - core.warning("Unable to post builtin /help response: discussion node_id missing."); - return false; + if (context.eventName === "discussion" || context.eventName === "discussion_comment") { + const discussionID = context.payload?.discussion?.node_id; + if (!discussionID) { + core.warning("Unable to post builtin /help response: discussion node_id missing."); + return false; + } + await github.graphql( + ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { id } + } + }`, + { discussionId: discussionID, body: commentBody } + ); + return true; } - await github.graphql( - ` - mutation($discussionId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { - comment { id } - } - }`, - { discussionId: discussionID, body: commentBody } - ); - return true; - } - core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); - return false; + core.warning(`Unable to post builtin /help response for event '${context.eventName}'.`); + return false; + } catch (error) { + core.warning(`Failed to post builtin /help comment: ${String(error)}`); + return false; + } } function toWorkflowDispatchID(route) { @@ -559,15 +573,16 @@ async function main() { const commandName = selectedCommand; if (commandName === "help") { - if (!isBuiltinHelpEnabled()) { - core.info("Builtin /help command is disabled by aw.json (help_command=false)."); + if (isBuiltinHelpEnabled()) { + await addImmediateReaction("eyes"); + const posted = await postBuiltinHelpComment(buildHelpCommentBody(parseHelpCommandsMetadata())); + if (posted) { + core.info("Posted builtin /help command response."); + } return; } - const posted = await postBuiltinHelpComment(buildHelpCommentBody(parseHelpCommandsMetadata())); - if (posted) { - core.info("Posted builtin /help command response."); - } - return; + // Builtin /help is disabled — fall through so custom /help workflows still dispatch. + core.info("Builtin /help command is disabled by aw.json (help_command=false); routing normally."); } core.info(`Resolved command '/${commandName}' for event identifier '${identifier}'.`); diff --git a/actions/setup/js/route_slash_command.test.cjs b/actions/setup/js/route_slash_command.test.cjs index 32ac80cb1bc..65adeab4e7a 100644 --- a/actions/setup/js/route_slash_command.test.cjs +++ b/actions/setup/js/route_slash_command.test.cjs @@ -215,7 +215,17 @@ describe("route_slash_command", () => { expect(issueCommentCalls[0].body).toContain("https://github.github.com/gh-aw/reference/command-triggers/"); }); - it("skips builtin /help when disabled", async () => { + it("adds immediate reaction before posting builtin /help comment", async () => { + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(reactionCalls).toHaveLength(1); + expect(issueCommentCalls).toHaveLength(1); + }); + + it("skips builtin /help when disabled and falls through to normal routing", async () => { process.env.GH_AW_HELP_COMMAND_ENABLED = "false"; globals.context.payload.comment.body = "/help"; @@ -226,6 +236,124 @@ describe("route_slash_command", () => { expect(globals.core.info).toHaveBeenCalledWith(expect.stringContaining("Builtin /help command is disabled")); }); + it("dispatches custom /help workflow when builtin is disabled", async () => { + process.env.GH_AW_HELP_COMMAND_ENABLED = "false"; + process.env.GH_AW_SLASH_ROUTING = JSON.stringify({ + archie: [{ workflow: "archie", events: ["issue_comment"] }], + help: [{ workflow: "custom-help", events: ["issue_comment"] }], + }); + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(dispatchCalls).toHaveLength(1); + expect(dispatchCalls[0].workflow_id).toBe("custom-help.lock.yml"); + expect(issueCommentCalls).toHaveLength(0); + }); + + it("handles builtin /help on discussion_comment events via GraphQL", async () => { + globals.context.eventName = "discussion_comment"; + globals.context.payload = { + discussion: { node_id: "D_test123" }, + comment: { body: "/help", id: 123456 }, + }; + + await main(); + + expect(dispatchCalls).toHaveLength(0); + expect(issueCommentCalls).toHaveLength(0); + const graphqlCalls = globals.github.graphql.mock.calls; + const helpCall = graphqlCalls.find(([query]) => query.includes("addDiscussionComment")); + expect(helpCall).toBeDefined(); + expect(helpCall[1].discussionId).toBe("D_test123"); + expect(helpCall[1].body).toContain("## Supported Commands"); + }); + + it("warns and returns false for /help on unsupported event type", async () => { + globals.context.eventName = "push"; + globals.context.payload = { comment: { body: "/help", id: 123456 } }; + + await main(); + + expect(issueCommentCalls).toHaveLength(0); + expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Unable to post builtin /help response for event 'push'")); + }); + + it("warns on invalid GH_AW_HELP_COMMAND_ENABLED value and still posts help", async () => { + process.env.GH_AW_HELP_COMMAND_ENABLED = "banana"; + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Invalid value for GH_AW_HELP_COMMAND_ENABLED")); + expect(issueCommentCalls).toHaveLength(1); + }); + + it("handles malformed JSON in GH_AW_HELP_COMMANDS gracefully", async () => { + process.env.GH_AW_HELP_COMMANDS = "{not valid json}"; + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to parse GH_AW_HELP_COMMANDS metadata")); + expect(issueCommentCalls).toHaveLength(1); + expect(issueCommentCalls[0].body).toContain("## Supported Commands"); + }); + + it("handles non-array JSON in GH_AW_HELP_COMMANDS gracefully", async () => { + process.env.GH_AW_HELP_COMMANDS = '{"command":"foo"}'; + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(issueCommentCalls).toHaveLength(1); + expect(issueCommentCalls[0].body).toContain("- _None_"); + }); + + it("neutralizes @mentions in descriptions within /help output", async () => { + process.env.GH_AW_HELP_COMMANDS = JSON.stringify([{ command: "archie", description: "Run @admin workflow", centralized: true }]); + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(issueCommentCalls).toHaveLength(1); + expect(issueCommentCalls[0].body).not.toContain("@admin"); + expect(issueCommentCalls[0].body).toContain("`@admin`"); + }); + + it("shows command with both centralized and decentralized flags only under centralized section", async () => { + process.env.GH_AW_HELP_COMMANDS = JSON.stringify([{ command: "triage", description: "Triage items", centralized: true, decentralized: true }]); + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + const body = issueCommentCalls[0].body; + const centralizedIdx = body.indexOf("**Centralized slash commands**"); + const decentralizedIdx = body.indexOf("**Non-centralized slash commands**"); + const triageInCentralized = body.indexOf("- `/triage`"); + expect(triageInCentralized).toBeGreaterThan(centralizedIdx); + expect(triageInCentralized).toBeLessThan(decentralizedIdx); + // Should not appear again after the non-centralized heading + expect(body.indexOf("- `/triage`", decentralizedIdx)).toBe(-1); + }); + + it("warns when postBuiltinHelpComment fails due to API error", async () => { + globals.github.rest.issues.createComment = vi.fn(async () => { + throw new Error("API rate limit exceeded"); + }); + globals.context.payload.issue.number = 77; + globals.context.payload.comment.body = "/help"; + + await main(); + + expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to post builtin /help comment")); + }); + it("logs empty selected command in summary when no slash command is present", async () => { globals.context.payload.comment.body = "hello there"; diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index f66759ad205..7d63dbd5b40 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -126,7 +126,8 @@ func generateCentralSlashCommandWorkflowWrapper( if strict { return fmt.Errorf("failed to load repo config: %w", err) } - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to load repo config: %v", err))) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf( + "Failed to load repo config; repo-config flags (e.g. help_command) will use defaults: %v", err))) repoConfig = nil } diff --git a/pkg/workflow/central_slash_command_workflow.go b/pkg/workflow/central_slash_command_workflow.go index 009841c0421..810e1d6d762 100644 --- a/pkg/workflow/central_slash_command_workflow.go +++ b/pkg/workflow/central_slash_command_workflow.go @@ -69,10 +69,7 @@ func GenerateCentralSlashCommandWorkflow(ctx context.Context, workflowDataList [ setupActionRef := ResolveSetupActionReference(ctx, actionMode, GetVersion(), "", nil) helpCommands := buildHelpCommandEntries(workflowDataList) - helpCommandEnabled := true - if repoConfig != nil { - helpCommandEnabled = repoConfig.IsHelpCommandEnabled() - } + helpCommandEnabled := repoConfig.IsHelpCommandEnabled() content, err := buildCentralSlashCommandWorkflowYAML( slashRoutesByCommand, @@ -406,6 +403,13 @@ func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntr if trimmed == "" { continue } + if trimmed == "help" { + centralSlashCommandWorkflowLog.Printf( + "Warning: 'help' is reserved for the builtin /help handler in workflow %s; "+ + "this command will not be dispatched unless help_command is disabled via aw.json", + wd.WorkflowID, + ) + } existing := byCommand[trimmed] if existing.Description != "" && description != "" && existing.Description != description { centralSlashCommandWorkflowLog.Printf( diff --git a/pkg/workflow/central_slash_command_workflow_test.go b/pkg/workflow/central_slash_command_workflow_test.go index 221ebf0e0fe..d837d46db37 100644 --- a/pkg/workflow/central_slash_command_workflow_test.go +++ b/pkg/workflow/central_slash_command_workflow_test.go @@ -455,6 +455,28 @@ func TestBuildHelpCommandEntries(t *testing.T) { }, buildHelpCommandEntries(data)) } +func TestBuildHelpCommandEntries_ConflictingDescriptions(t *testing.T) { + // Two workflows register the same command with different descriptions — first-wins. + data := []*WorkflowData{ + {WorkflowID: "deploy-a", Command: []string{"deploy"}, CommandCentralized: true, Description: "Deploy service A"}, + {WorkflowID: "deploy-b", Command: []string{"deploy"}, CommandCentralized: true, Description: "Deploy service B"}, + } + entries := buildHelpCommandEntries(data) + require.Len(t, entries, 1) + require.Equal(t, "Deploy service A", entries[0].Description, "first description should win on conflict") +} + +func TestBuildHelpCommandEntries_ReservedHelpCommandName(t *testing.T) { + // The 'help' command name is reserved for the builtin handler; it should still be + // included in entries so the metadata accurately reflects the registered commands. + data := []*WorkflowData{ + {WorkflowID: "custom-help", Command: []string{"help"}, CommandCentralized: true}, + } + entries := buildHelpCommandEntries(data) + require.Len(t, entries, 1) + require.Equal(t, "help", entries[0].Command) +} + func typeSetKeys(typeSet map[string]bool) []string { out := make([]string, 0, len(typeSet)) for key := range typeSet { diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 7b4f044315e..c4abff7db47 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -145,7 +145,7 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { // Use an intermediate struct with json.RawMessage to defer maintenance parsing. var raw struct { GHES bool `json:"ghes,omitempty"` - HelpCommand *bool `json:"help_command,omitempty"` + HelpCommand *bool `json:"help_command,omitempty"` // nil = use default (enabled) UTC string `json:"utc,omitempty"` Maintenance json.RawMessage `json:"maintenance,omitempty"` } From 591172ce4333cc65a9f50323e5734f83d2a2e0ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:37:31 +0000 Subject: [PATCH 14/15] Extract GITHUB_MENTION_RE constant, omit docs link when URL env var is empty Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.cjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 284736c20f7..3ddfd2a087e 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -341,6 +341,12 @@ function parseHelpCommandsMetadata() { } } +/** + * Regex matching bare GitHub @mentions outside inline code spans. + * Captures the preceding non-word character (p1) and the username (p2). + */ +const GITHUB_MENTION_RE = /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?)/g; + /** * Neutralizes bare @mentions in a description string so they do not trigger * GitHub notifications. Wraps matched mentions in backticks. @@ -348,7 +354,7 @@ function parseHelpCommandsMetadata() { * @returns {string} */ function neutralizeDescriptionMentions(description) { - return description.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?)/g, (_, p1, p2) => `${p1}\`@${p2}\``); + return description.replace(GITHUB_MENTION_RE, (_, p1, p2) => `${p1}\`@${p2}\``); } function buildCommandBulletLine(entry) { @@ -398,7 +404,10 @@ function buildHelpCommentBody(helpCommands) { } } - lines.push("", `Learn more: [Slash command documentation](${(process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim()})`); + const docsUrl = (process.env.GH_AW_SLASH_COMMAND_DOCS_URL || "").trim(); + if (docsUrl) { + lines.push("", `Learn more: [Slash command documentation](${docsUrl})`); + } return lines.join("\n"); } From db7acbb3ca77c394f4f7b26bd63d47cbb1d648da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:41:22 +0000 Subject: [PATCH 15/15] Fix two failing tests: unsupported event type and @mention neutralization assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/route_slash_command.test.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/route_slash_command.test.cjs b/actions/setup/js/route_slash_command.test.cjs index 65adeab4e7a..0a60cbcc686 100644 --- a/actions/setup/js/route_slash_command.test.cjs +++ b/actions/setup/js/route_slash_command.test.cjs @@ -270,13 +270,13 @@ describe("route_slash_command", () => { }); it("warns and returns false for /help on unsupported event type", async () => { - globals.context.eventName = "push"; + globals.context.eventName = "pull_request_review_comment"; globals.context.payload = { comment: { body: "/help", id: 123456 } }; await main(); expect(issueCommentCalls).toHaveLength(0); - expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Unable to post builtin /help response for event 'push'")); + expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("Unable to post builtin /help response for event 'pull_request_review_comment'")); }); it("warns on invalid GH_AW_HELP_COMMAND_ENABLED value and still posts help", async () => { @@ -321,7 +321,9 @@ describe("route_slash_command", () => { await main(); expect(issueCommentCalls).toHaveLength(1); - expect(issueCommentCalls[0].body).not.toContain("@admin"); + // The raw unquoted mention should not appear + expect(issueCommentCalls[0].body).not.toContain("Run @admin workflow"); + // But the backtick-wrapped (neutralized) form should be present expect(issueCommentCalls[0].body).toContain("`@admin`"); });