Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
28 changes: 28 additions & 0 deletions actions/setup/js/add_workflow_run_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -538,6 +541,31 @@ describe("add_workflow_run_comment", () => {
expect(body).toBeTruthy();
expect(body).toContain("<!-- gh-aw-comment-type: reaction -->");
});

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()", () => {
Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"slices"
"strconv"
"strings"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/compiler_safe_outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions pkg/workflow/compiler_safe_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions pkg/workflow/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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.
Expand Down
30 changes: 28 additions & 2 deletions pkg/workflow/reactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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")
}
}
42 changes: 42 additions & 0 deletions pkg/workflow/slash_command_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}