diff --git a/.github/workflows/agentic_commands.yml b/.github/workflows/agentic_commands.yml index d7706cc1f2d..b5351fc9d73 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},{"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: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index 41c4ef267a0..3ddfd2a087e 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -298,6 +298,164 @@ async function dispatchWorkflow(workflowId, ref, inputs) { } } +function isBuiltinHelpEnabled() { + const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase(); + if (!raw || raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + core.warning(`Invalid value for GH_AW_HELP_COMMAND_ENABLED (expected 'true' or 'false', got '${raw}'). Using default: enabled.`); + return true; +} + +function parseHelpCommandsMetadata() { + const raw = process.env.GH_AW_HELP_COMMANDS || "[]"; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .flatMap(item => { + const command = typeof item?.command === "string" ? item.command.trim() : ""; + if (!command) { + return []; + } + const description = typeof item?.description === "string" ? item.description.trim() : ""; + return [ + { + command, + description, + centralized: Boolean(item?.centralized), + decentralized: Boolean(item?.decentralized), + label: Boolean(item?.label), + }, + ]; + }) + .sort((left, right) => left.command.localeCompare(right.command)); + } catch (error) { + core.warning(`Failed to parse GH_AW_HELP_COMMANDS metadata: ${String(error)}`); + return []; + } +} + +/** + * 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. + * @param {string} description + * @returns {string} + */ +function neutralizeDescriptionMentions(description) { + return description.replace(GITHUB_MENTION_RE, (_, p1, p2) => `${p1}\`@${p2}\``); +} + +function buildCommandBulletLine(entry) { + const desc = entry.description ? neutralizeDescriptionMentions(entry.description) : ""; + const suffix = desc ? ` — ${desc}` : ""; + return `- \`/${entry.command}\`${suffix}`; +} + +function buildLabelBulletLine(entry) { + 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 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**"]; + if (centralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of centralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", "**Non-centralized slash commands**"); + if (decentralized.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of decentralized) { + lines.push(buildCommandBulletLine(entry)); + } + } + + lines.push("", "**Label commands**"); + if (labels.length === 0) { + lines.push("- _None_"); + } else { + for (const entry of labels) { + lines.push(buildLabelBulletLine(entry)); + } + } + + 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"); +} + +async function postBuiltinHelpComment(commentBody) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + 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; + } + 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; + } catch (error) { + core.warning(`Failed to post builtin /help comment: ${String(error)}`); + return false; + } +} + function toWorkflowDispatchID(route) { if (!route?.workflow || typeof route.workflow !== "string" || !route.workflow.trim()) { return ""; @@ -423,6 +581,19 @@ async function main() { } const commandName = selectedCommand; + if (commandName === "help") { + if (isBuiltinHelpEnabled()) { + await addImmediateReaction("eyes"); + 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}'.`); 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..0a60cbcc686 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,13 @@ 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 }, + { 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/"; process.env.GITHUB_WORKSPACE = `${process.cwd()}`; }); @@ -149,6 +164,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 +196,166 @@ 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 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 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/"); + }); + + 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"; + + await main(); + + expect(dispatchCalls).toHaveLength(0); + expect(issueCommentCalls).toHaveLength(0); + 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 = "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 'pull_request_review_comment'")); + }); + + 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); + // 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`"); + }); + + 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/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.* 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..7d63dbd5b40 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -116,11 +116,22 @@ 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; repo-config flags (e.g. help_command) will use defaults: %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 4dfd90a5226..1311df33249 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..810e1d6d762 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" @@ -36,10 +37,18 @@ 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"` + Label bool `json:"label,omitempty"` +} + // 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 +68,18 @@ 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 := repoConfig.IsHelpCommandEnabled() + + content, err := buildCentralSlashCommandWorkflowYAML( + slashRoutesByCommand, + labelRoutesByCommand, + mergedEvents, + resolveCentralSlashRunsOn(workflowDataList), + setupActionRef, + helpCommands, + helpCommandEnabled, + ) if err != nil { return err } @@ -284,7 +304,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 +321,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 +368,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: '` + strconv.FormatBool(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 +381,121 @@ jobs: return b.String(), nil } +func buildHelpCommandEntries(workflowDataList []*WorkflowData) []helpCommandEntry { + type aggregate struct { + Description string + 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 && len(wd.LabelCommand) == 0) { + continue + } + description := strings.TrimSpace(wd.Description) + + for _, commandName := range wd.Command { + trimmed := strings.TrimSpace(commandName) + 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( + "Conflicting descriptions for /%s: keeping %q from workflow %s, ignoring %q from workflow %s", + trimmed, + existing.Description, + existing.DescriptionBy, + description, + 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 + } + if wd.CommandCentralized { + existing.Centralized = true + } else { + // 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 + } + 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)) + for command := range byCommand { + commands = append(commands, command) + } + sort.Strings(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{ + Command: command, + Description: item.Description, + Centralized: item.Centralized, + Decentralized: item.Decentralized, + }) + } + for _, labelName := range labels { + item := byLabel[labelName] + entries = append(entries, helpCommandEntry{ + Command: labelName, + Description: item.Description, + Label: true, + }) + } + + 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..d837d46db37 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,85 @@ 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", + }, + { + 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)) +} + +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 97681c0919e..04fd44ee8e1 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 // "auto_upgrade": true, // set to true to generate agentic-auto-upgrade.yml with weekly schedule // "maintenance": { // enables generation of agentics-maintenance.yml @@ -123,6 +124,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 + // AutoUpgrade enables generation of agentic-auto-upgrade.yml when true. // The workflow runs on a fuzzy weekly schedule and runs the upgrade operation // to check for and report available workflow upgrades. @@ -155,6 +161,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"` // nil = use default (enabled) UTC string `json:"utc,omitempty"` AutoUpgrade *bool `json:"auto_upgrade,omitempty"` Maintenance json.RawMessage `json:"maintenance,omitempty"` @@ -164,6 +171,7 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { } r.GHES = raw.GHES + r.HelpCommand = raw.HelpCommand r.UTC = strings.TrimSpace(raw.UTC) r.AutoUpgrade = raw.AutoUpgrade @@ -189,6 +197,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). diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index f36bc66b548..a3d78a47cdd 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) {