diff --git a/actions/setup/js/add_workflow_run_comment.cjs b/actions/setup/js/add_workflow_run_comment.cjs index b199783565..b746c07b2b 100644 --- a/actions/setup/js/add_workflow_run_comment.cjs +++ b/actions/setup/js/add_workflow_run_comment.cjs @@ -152,10 +152,17 @@ async function main() { function buildCommentBody(eventName, runUrl) { const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; + const reactionEnabled = parseBoolTemplatable(process.env.GH_AW_REACTION_ENABLED, false); + const boldSlashCommandInComment = parseBoolTemplatable(process.env.GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT, true); + const matchedCommand = (process.env.GH_AW_MATCHED_COMMAND || "").trim().replace(/^\/+/, ""); // Sanitize before adding markers (defense in depth for custom message templates) let body = sanitizeContent(getRunStartedMessage({ workflowName, runUrl, eventType: eventTypeDescription })); + if (reactionEnabled && boldSlashCommandInComment && matchedCommand) { + body += `\n\nTriggered by **/${matchedCommand}**.`; + } + // Add lock notice if lock-for-agent is enabled for issues or issue_comment if (process.env.GH_AW_LOCK_FOR_AGENT === "true" && (eventName === "issues" || eventName === "issue_comment")) { body += "\n\nšŸ”’ This issue has been locked while the workflow is running to prevent concurrent modifications."; diff --git a/actions/setup/js/add_workflow_run_comment.test.cjs b/actions/setup/js/add_workflow_run_comment.test.cjs index 51cde9c5c4..93c3c6d443 100644 --- a/actions/setup/js/add_workflow_run_comment.test.cjs +++ b/actions/setup/js/add_workflow_run_comment.test.cjs @@ -49,6 +49,9 @@ describe("add_workflow_run_comment", () => { delete process.env.GITHUB_WORKFLOW; delete process.env.GH_AW_TRACKER_ID; delete process.env.GH_AW_LOCK_FOR_AGENT; + delete process.env.GH_AW_REACTION_ENABLED; + delete process.env.GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT; + delete process.env.GH_AW_MATCHED_COMMAND; delete process.env.GITHUB_SERVER_URL; delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; @@ -538,6 +541,31 @@ describe("add_workflow_run_comment", () => { expect(body).toBeTruthy(); expect(body).toContain(""); }); + + it("should include bold slash command text when reaction is enabled and matched command is provided", async () => { + process.env.GH_AW_REACTION_ENABLED = "true"; + process.env.GH_AW_MATCHED_COMMAND = "review"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issue_comment", "https://example.com/run/1"); + expect(body).toContain("Triggered by **/review**."); + }); + + it("should not include slash command text when reaction is disabled", async () => { + process.env.GH_AW_REACTION_ENABLED = "false"; + process.env.GH_AW_MATCHED_COMMAND = "review"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issue_comment", "https://example.com/run/1"); + expect(body).not.toContain("Triggered by **/review**."); + }); + + it("should not include slash command text when bold-slash-command behavior is disabled", async () => { + process.env.GH_AW_REACTION_ENABLED = "true"; + process.env.GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT = "false"; + process.env.GH_AW_MATCHED_COMMAND = "review"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issue_comment", "https://example.com/run/1"); + expect(body).not.toContain("Triggered by **/review**."); + }); }); describe("postDiscussionComment()", () => { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d1b3c563c4..98f7d9f4e2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1909,6 +1909,10 @@ "discussions": { "type": "boolean", "description": "Whether reactions are allowed for discussion and discussion_comment triggers." + }, + "bold-slash-command": { + "type": "boolean", + "description": "Whether activation status comments should include a bolded slash command marker (for slash_command workflows) when reactions are enabled. Defaults to true." } } } diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 751bb94356..01c840da77 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -334,6 +335,17 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Add environment variables steps = append(steps, " env:\n") steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", data.Name)) + if hasReaction { + steps = append(steps, " GH_AW_REACTION_ENABLED: \"true\"\n") + steps = append(steps, fmt.Sprintf(" GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT: %q\n", strconv.FormatBool(shouldBoldSlashCommandInActivationComment(data)))) + if len(data.Command) > 0 { + if preActivationJobCreated { + steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ needs.%s.outputs.%s }}\n", string(constants.PreActivationJobName), constants.MatchedCommandOutput)) + } else { + steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ steps.%s.outputs.%s }}\n", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput)) + } + } + } // Add tracker-id if present if data.TrackerID != "" { @@ -844,6 +856,13 @@ func shouldIncludeDiscussionStatusComments(data *WorkflowData) bool { return *data.StatusCommentDiscussions } +func shouldBoldSlashCommandInActivationComment(data *WorkflowData) bool { + if data == nil || data.ReactionBoldSlashCommand == nil { + return true + } + return *data.ReactionBoldSlashCommand +} + func activationEventSet(onSection string) (map[string]bool, bool) { events := make(map[string]bool) var onData map[string]any diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index fb3b7e8eb0..21dd48cd3a 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -50,7 +50,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Extract reaction from on section if reactionValue, hasReactionField := onMap["reaction"]; hasReactionField { hasReaction = true - reactionStr, reactionIssues, reactionPullRequests, reactionDiscussions, err := parseReactionConfig(reactionValue) + reactionStr, reactionIssues, reactionPullRequests, reactionDiscussions, reactionBoldSlashCommand, err := parseReactionConfig(reactionValue) if err != nil { return err } @@ -63,6 +63,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work workflowData.ReactionIssues = reactionIssues workflowData.ReactionPullRequests = reactionPullRequests workflowData.ReactionDiscussions = reactionDiscussions + workflowData.ReactionBoldSlashCommand = reactionBoldSlashCommand } // Extract status-comment from on section diff --git a/pkg/workflow/compiler_safe_outputs_test.go b/pkg/workflow/compiler_safe_outputs_test.go index 6735e33324..655a51aa65 100644 --- a/pkg/workflow/compiler_safe_outputs_test.go +++ b/pkg/workflow/compiler_safe_outputs_test.go @@ -1087,6 +1087,28 @@ func TestParseOnSectionReactionMapFormat(t *testing.T) { assert.False(t, *workflowData.ReactionPullRequests, "reaction pull request target should match parsed value") require.NotNil(t, workflowData.ReactionDiscussions, "reaction discussion target flag should be set") assert.True(t, *workflowData.ReactionDiscussions, "reaction discussions target should default to true") + require.NotNil(t, workflowData.ReactionBoldSlashCommand, "reaction bold slash command flag should be set") + assert.True(t, *workflowData.ReactionBoldSlashCommand, "reaction bold slash command should default to true") +} + +func TestParseOnSectionReactionMapFormatBoldSlashCommandDisabled(t *testing.T) { + c := &Compiler{} + workflowData := &WorkflowData{} + + frontmatter := map[string]any{ + "on": map[string]any{ + "reaction": map[string]any{ + "type": "heart", + "bold-slash-command": false, + }, + }, + } + + err := c.parseOnSection(frontmatter, workflowData, "/path/to/test.md") + require.NoError(t, err, "reaction map format with bold-slash-command should be accepted") + assert.Equal(t, "heart", workflowData.AIReaction, "reaction type should be parsed from reaction.type") + require.NotNil(t, workflowData.ReactionBoldSlashCommand, "reaction bold slash command flag should be set") + assert.False(t, *workflowData.ReactionBoldSlashCommand, "reaction bold slash command should match parsed value") } // TestCompilerNeedsGitCommandsAllOutputTypes tests all safe output types for git command requirements diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index c1ba519b78..5563ca2295 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -442,6 +442,7 @@ type WorkflowData struct { ReactionIssues *bool // whether reactions are allowed on issues/issue_comment triggers (default: true) ReactionPullRequests *bool // whether reactions are allowed on pull_request/pull_request_review_comment triggers (default: true) ReactionDiscussions *bool // whether reactions are allowed on discussion/discussion_comment triggers (default: true) + ReactionBoldSlashCommand *bool // whether to include a bold slash-command marker in activation comments when reactions are enabled (default: true) StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) StatusCommentIssues *bool // whether status comments are allowed on issues/issue_comment triggers (default: true) StatusCommentPullRequests *bool // whether status comments are allowed on pull_request/pull_request_review_comment triggers (default: true) diff --git a/pkg/workflow/reactions.go b/pkg/workflow/reactions.go index a85dab1566..596b643f02 100644 --- a/pkg/workflow/reactions.go +++ b/pkg/workflow/reactions.go @@ -86,14 +86,14 @@ func parseReactionValue(value any) (string, error) { // parseReactionConfig parses reaction configuration from frontmatter. // Supported formats: // - scalar (string/int): reaction type only -// - object: {type, issues, pull-requests, discussions} -func parseReactionConfig(value any) (string, *bool, *bool, *bool, error) { +// - object: {type, issues, pull-requests, discussions, bold-slash-command} +func parseReactionConfig(value any) (string, *bool, *bool, *bool, *bool, error) { if reactionMap, ok := value.(map[string]any); ok { reactionType := "eyes" if typeValue, hasType := reactionMap["type"]; hasType { parsedType, err := parseReactionValue(typeValue) if err != nil { - return "", nil, nil, nil, err + return "", nil, nil, nil, nil, err } reactionType = parsedType } @@ -102,7 +102,7 @@ func parseReactionConfig(value any) (string, *bool, *bool, *bool, error) { if issuesValue, hasIssues := reactionMap["issues"]; hasIssues { issuesBool, ok := issuesValue.(bool) if !ok { - return "", nil, nil, nil, fmt.Errorf("reaction.issues must be a boolean value, got %T", issuesValue) + return "", nil, nil, nil, nil, fmt.Errorf("reaction.issues must be a boolean value, got %T", issuesValue) } reactionIssues = issuesBool } @@ -111,7 +111,7 @@ func parseReactionConfig(value any) (string, *bool, *bool, *bool, error) { if pullRequestsValue, hasPullRequests := reactionMap["pull-requests"]; hasPullRequests { pullRequestsBool, ok := pullRequestsValue.(bool) if !ok { - return "", nil, nil, nil, fmt.Errorf("reaction.pull-requests must be a boolean value, got %T", pullRequestsValue) + return "", nil, nil, nil, nil, fmt.Errorf("reaction.pull-requests must be a boolean value, got %T", pullRequestsValue) } reactionPullRequests = pullRequestsBool } @@ -120,23 +120,32 @@ func parseReactionConfig(value any) (string, *bool, *bool, *bool, error) { if discussionsValue, hasDiscussions := reactionMap["discussions"]; hasDiscussions { discussionsBool, ok := discussionsValue.(bool) if !ok { - return "", nil, nil, nil, fmt.Errorf("reaction.discussions must be a boolean value, got %T", discussionsValue) + return "", nil, nil, nil, nil, fmt.Errorf("reaction.discussions must be a boolean value, got %T", discussionsValue) } reactionDiscussions = discussionsBool } + boldSlashCommand := true + if boldSlashCommandValue, hasBoldSlashCommand := reactionMap["bold-slash-command"]; hasBoldSlashCommand { + boldSlashCommandBool, ok := boldSlashCommandValue.(bool) + if !ok { + return "", nil, nil, nil, nil, fmt.Errorf("reaction.bold-slash-command must be a boolean value, got %T", boldSlashCommandValue) + } + boldSlashCommand = boldSlashCommandBool + } + if !reactionIssues && !reactionPullRequests && !reactionDiscussions { - return "", nil, nil, nil, errors.New("reaction object requires at least one target to be enabled (issues, pull-requests, or discussions)") + return "", nil, nil, nil, nil, errors.New("reaction object requires at least one target to be enabled (issues, pull-requests, or discussions)") } - return reactionType, &reactionIssues, &reactionPullRequests, &reactionDiscussions, nil + return reactionType, &reactionIssues, &reactionPullRequests, &reactionDiscussions, &boldSlashCommand, nil } reactionType, err := parseReactionValue(value) if err != nil { - return "", nil, nil, nil, err + return "", nil, nil, nil, nil, err } - return reactionType, nil, nil, nil, nil + return reactionType, nil, nil, nil, nil, nil } // intToReactionString converts an integer to a reaction string. diff --git a/pkg/workflow/reactions_test.go b/pkg/workflow/reactions_test.go index f08b8984b1..f6630eb023 100644 --- a/pkg/workflow/reactions_test.go +++ b/pkg/workflow/reactions_test.go @@ -176,7 +176,7 @@ func TestIntToReactionString(t *testing.T) { } func TestParseReactionConfigMap(t *testing.T) { - reaction, issues, pullRequests, discussions, err := parseReactionConfig(map[string]any{ + reaction, issues, pullRequests, discussions, boldSlashCommand, err := parseReactionConfig(map[string]any{ "type": "rocket", "issues": false, "pull-requests": true, @@ -197,10 +197,13 @@ func TestParseReactionConfigMap(t *testing.T) { if discussions == nil || *discussions { t.Errorf("Expected discussions target false, got %v", discussions) } + if boldSlashCommand == nil || !*boldSlashCommand { + t.Errorf("Expected bold-slash-command default true, got %v", boldSlashCommand) + } } func TestParseReactionConfigMapAllTargetsDisabled(t *testing.T) { - _, _, _, _, err := parseReactionConfig(map[string]any{ + _, _, _, _, _, err := parseReactionConfig(map[string]any{ "type": "eyes", "issues": false, "pull-requests": false, @@ -210,3 +213,26 @@ func TestParseReactionConfigMapAllTargetsDisabled(t *testing.T) { t.Fatal("Expected parseReactionConfig to fail when all reaction targets are disabled") } } + +func TestParseReactionConfigMapBoldSlashCommandFalse(t *testing.T) { + _, _, _, _, boldSlashCommand, err := parseReactionConfig(map[string]any{ + "type": "eyes", + "bold-slash-command": false, + }) + if err != nil { + t.Errorf("parseReactionConfig(map) returned unexpected error: %v", err) + } + if boldSlashCommand == nil || *boldSlashCommand { + t.Errorf("Expected bold-slash-command false, got %v", boldSlashCommand) + } +} + +func TestParseReactionConfigMapBoldSlashCommandInvalidType(t *testing.T) { + _, _, _, _, _, err := parseReactionConfig(map[string]any{ + "type": "eyes", + "bold-slash-command": "false", + }) + if err == nil { + t.Fatal("Expected parseReactionConfig to fail when bold-slash-command is not a boolean") + } +} diff --git a/pkg/workflow/slash_command_output_test.go b/pkg/workflow/slash_command_output_test.go index e75ddc944e..654e306466 100644 --- a/pkg/workflow/slash_command_output_test.go +++ b/pkg/workflow/slash_command_output_test.go @@ -50,6 +50,7 @@ Test workflow content // Read the compiled workflow lockContent, err := os.ReadFile(lockFilePath) require.NoError(t, err) + lockContentStr := string(lockContent) // Parse the YAML var workflow map[string]any @@ -89,4 +90,45 @@ Test workflow content // Verify it does NOT reference steps.check_command_position assert.NotContains(t, slashCommand, "steps.check_command_position", "Expected slash_command to NOT reference steps.check_command_position directly") + + // Activation comment step should receive matched slash command and bold marker config. + assert.Contains(t, lockContentStr, "GH_AW_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }}", + "Expected activation comment env to include matched slash command from pre_activation") + assert.Contains(t, lockContentStr, "GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT: \"true\"", + "Expected activation comment env to default bold slash command behavior to true") +} + +func TestSlashCommandActivationCommentBoldSlashCommandCanBeDisabled(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Test Slash Command Bold Toggle +on: + slash_command: + name: test + reaction: + type: eyes + bold-slash-command: false + status-comment: true +engine: copilot +--- + +Test workflow content +` + + workflowPath := filepath.Join(tempDir, "test-workflow-bold-toggle.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err) + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "Failed to compile workflow") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err) + lockContentStr := string(lockContent) + + assert.Contains(t, lockContentStr, "GH_AW_BOLD_SLASH_COMMAND_IN_COMMENT: \"false\"", + "Expected activation comment env to disable bold slash command behavior when configured") }