Skip to content

Commit 45f2beb

Browse files
authored
feat: append slash command run-again hint to generated footer when applicable (#35337)
1 parent 53fd508 commit 45f2beb

10 files changed

Lines changed: 232 additions & 8 deletions

actions/setup/js/messages.test.cjs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,88 @@ describe("messages.cjs", () => {
456456

457457
expect(result).toBe("> Custom: [Test Workflow](https://github.com/test/repo/actions/runs/123) · gpt55 5K");
458458
});
459+
460+
it("should append slash command hint when slashCommand is provided", async () => {
461+
const { getFooterMessage } = await import("./messages.cjs");
462+
463+
const result = getFooterMessage({
464+
workflowName: "Test Workflow",
465+
runUrl: "https://github.com/test/repo/actions/runs/123",
466+
slashCommand: "review-bot",
467+
});
468+
469+
expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> <sub>Comment <em>/review-bot</em> to run again</sub>");
470+
});
471+
472+
it("should not append slash command hint when slashCommand is not provided", async () => {
473+
const { getFooterMessage } = await import("./messages.cjs");
474+
475+
const result = getFooterMessage({
476+
workflowName: "Test Workflow",
477+
runUrl: "https://github.com/test/repo/actions/runs/123",
478+
});
479+
480+
expect(result).not.toContain("<sub>");
481+
expect(result).not.toContain("<em>");
482+
});
483+
484+
it("should include slash command hint after tokens and history link", async () => {
485+
process.env.GH_AW_EFFECTIVE_TOKENS = "5000";
486+
const historyUrl = "https://github.com/search?q=repo:test";
487+
488+
const { getFooterMessage } = await import("./messages.cjs");
489+
490+
const result = getFooterMessage({
491+
workflowName: "Test Workflow",
492+
runUrl: "https://github.com/test/repo/actions/runs/123",
493+
historyUrl,
494+
slashCommand: "deploy",
495+
});
496+
497+
expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · 5K · [◷](${historyUrl})\n> <sub>Comment <em>/deploy</em> to run again</sub>`);
498+
});
499+
500+
it("should NOT include slash command hint in custom footer templates", async () => {
501+
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
502+
footer: "> Custom: [{workflow_name}]({run_url})",
503+
});
504+
505+
const { getFooterMessage } = await import("./messages.cjs");
506+
507+
const result = getFooterMessage({
508+
workflowName: "Test Workflow",
509+
runUrl: "https://github.com/test/repo/actions/runs/123",
510+
slashCommand: "review-bot",
511+
});
512+
513+
expect(result).toBe("> Custom: [Test Workflow](https://github.com/test/repo/actions/runs/123)");
514+
expect(result).not.toContain("<sub>");
515+
});
516+
517+
it("should use slashCommandPlaceholder as hint text when provided", async () => {
518+
const { getFooterMessage } = await import("./messages.cjs");
519+
520+
const result = getFooterMessage({
521+
workflowName: "Test Workflow",
522+
runUrl: "https://github.com/test/repo/actions/runs/123",
523+
slashCommand: "review-bot",
524+
slashCommandPlaceholder: "to review this PR",
525+
});
526+
527+
expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> <sub>Comment <em>/review-bot</em> to review this PR</sub>");
528+
});
529+
530+
it("should fall back to 'to run again' when slashCommandPlaceholder is not provided", async () => {
531+
const { getFooterMessage } = await import("./messages.cjs");
532+
533+
const result = getFooterMessage({
534+
workflowName: "Test Workflow",
535+
runUrl: "https://github.com/test/repo/actions/runs/123",
536+
slashCommand: "review-bot",
537+
});
538+
539+
expect(result).toContain("to run again");
540+
});
459541
});
460542

461543
describe("getFooterInstallMessage", () => {
@@ -576,6 +658,57 @@ describe("messages.cjs", () => {
576658
delete process.env.GH_AW_ENGINE_VERSION;
577659
delete process.env.GH_AW_ENGINE_MODEL;
578660
});
661+
662+
it("should include slash command hint when GH_AW_COMMANDS is set", async () => {
663+
process.env.GH_AW_COMMANDS = JSON.stringify(["review-bot"]);
664+
665+
const { generateFooterWithMessages } = await import("./messages.cjs");
666+
667+
const result = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "", "", undefined, undefined, undefined);
668+
669+
expect(result).toContain("<sub>Comment <em>/review-bot</em> to run again</sub>");
670+
671+
delete process.env.GH_AW_COMMANDS;
672+
});
673+
674+
it("should not include slash command hint when GH_AW_COMMANDS is not set", async () => {
675+
delete process.env.GH_AW_COMMANDS;
676+
677+
const { generateFooterWithMessages } = await import("./messages.cjs");
678+
679+
const result = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "", "", undefined, undefined, undefined);
680+
681+
expect(result).not.toContain("<sub>");
682+
expect(result).not.toContain("<em>");
683+
});
684+
685+
it("should use first command from GH_AW_COMMANDS when multiple commands are configured", async () => {
686+
process.env.GH_AW_COMMANDS = JSON.stringify(["deploy", "analyze"]);
687+
688+
const { generateFooterWithMessages } = await import("./messages.cjs");
689+
690+
const result = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "", "", undefined, undefined, undefined);
691+
692+
expect(result).toContain("<sub>Comment <em>/deploy</em> to run again</sub>");
693+
expect(result).not.toContain("/analyze");
694+
695+
delete process.env.GH_AW_COMMANDS;
696+
});
697+
698+
it("should use GH_AW_COMMAND_PLACEHOLDER as hint text when set", async () => {
699+
process.env.GH_AW_COMMANDS = JSON.stringify(["review-bot"]);
700+
process.env.GH_AW_COMMAND_PLACEHOLDER = "to analyze this PR";
701+
702+
const { generateFooterWithMessages } = await import("./messages.cjs");
703+
704+
const result = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "", "", undefined, undefined, undefined);
705+
706+
expect(result).toContain("<sub>Comment <em>/review-bot</em> to analyze this PR</sub>");
707+
expect(result).not.toContain("to run again");
708+
709+
delete process.env.GH_AW_COMMANDS;
710+
delete process.env.GH_AW_COMMAND_PLACEHOLDER;
711+
});
579712
});
580713

581714
describe("generateXMLMarker", () => {

actions/setup/js/messages_footer.cjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ function buildModelPrefix(modelName) {
7373
* @property {number} [effectiveTokens] - Total effective token count for the run (shown as N when > 0, in compact format)
7474
* @property {string} [model] - Model name used for the run, used to build a compact model identifier in ET suffixes
7575
* @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter)
76+
* @property {string} [slashCommand] - Slash command name (without leading slash) for the run-again hint, when applicable
77+
* @property {string} [slashCommandPlaceholder] - Custom hint text appended after the command name (replaces default "to run again")
7678
*/
7779

7880
/**
@@ -135,6 +137,11 @@ function getFooterMessage(ctx) {
135137
if (ctx.historyUrl) {
136138
defaultFooter += " · [◷]({history_url})";
137139
}
140+
// Append slash command hint when applicable (workflow has a slash command trigger)
141+
if (ctx.slashCommand) {
142+
const hintText = ctx.slashCommandPlaceholder || "to run again";
143+
defaultFooter += `\n> <sub>Comment <em>/{slash_command}</em> ${hintText}</sub>`;
144+
}
138145
return renderTemplate(defaultFooter, templateContext);
139146
}
140147

@@ -422,6 +429,27 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl
422429
// Read workflow emoji from environment variable if available.
423430
const emoji = process.env.GH_AW_WORKFLOW_EMOJI || undefined;
424431

432+
// Read slash command from GH_AW_COMMANDS (JSON array) when available.
433+
// Use the first command as the hint. This is only set when the workflow has a slash command trigger.
434+
let slashCommand;
435+
const commandsJSON = process.env.GH_AW_COMMANDS;
436+
if (commandsJSON) {
437+
try {
438+
const commands = JSON.parse(commandsJSON);
439+
if (Array.isArray(commands) && commands.length > 0 && typeof commands[0] === "string") {
440+
slashCommand = commands[0];
441+
}
442+
} catch {
443+
// Silently ignore malformed GH_AW_COMMANDS; the hint is a non-critical enhancement
444+
// and omitting it is always safe. The value is compiler-generated JSON, so this
445+
// path should not occur in practice.
446+
}
447+
}
448+
449+
// Read optional footer hint placeholder from GH_AW_COMMAND_PLACEHOLDER.
450+
// When set, it replaces the default "to run again" suffix in the slash command hint.
451+
const slashCommandPlaceholder = process.env.GH_AW_COMMAND_PLACEHOLDER;
452+
425453
const ctx = {
426454
workflowName,
427455
runUrl,
@@ -431,6 +459,8 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl
431459
historyUrl: historyUrl || undefined,
432460
effectiveTokens,
433461
emoji,
462+
slashCommand,
463+
slashCommandPlaceholder,
434464
};
435465

436466
const { skipDetectionCaution = false } = options || {};

pkg/workflow/compiler_orchestrator_workflow.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ func (c *Compiler) extractAdditionalConfigurations(
373373
workflowData.RepoMemoryConfig = repoMemoryConfig
374374

375375
// Extract and process mcp-scripts and safe-outputs
376-
workflowData.Command, workflowData.CommandEvents, workflowData.CommandCentralized = c.extractCommandConfig(frontmatter)
376+
workflowData.Command, workflowData.CommandEvents, workflowData.CommandCentralized, workflowData.CommandPlaceholder = c.extractCommandConfig(frontmatter)
377377
workflowData.LabelCommand, workflowData.LabelCommandEvents, workflowData.LabelCommandDecentralized, workflowData.LabelCommandRemoveLabel = c.extractLabelCommandConfig(frontmatter)
378378
workflowData.Jobs = c.extractJobsFromFrontmatter(frontmatter)
379379

pkg/workflow/compiler_pre_activation_job.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
229229
// Pass commands as JSON array
230230
commandsJSON, _ := json.Marshal(data.Command)
231231
steps = append(steps, fmt.Sprintf(" GH_AW_COMMANDS: %q\n", string(commandsJSON)))
232+
if data.CommandPlaceholder != "" {
233+
steps = append(steps, fmt.Sprintf(" GH_AW_COMMAND_PLACEHOLDER: %q\n", data.CommandPlaceholder))
234+
}
232235
steps = append(steps, " with:\n")
233236
steps = append(steps, " script: |\n")
234237
steps = append(steps, generateGitHubScriptWithRequire("check_command_position.cjs"))

pkg/workflow/compiler_safe_outputs_job.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package workflow
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"sort"
67
"strings"
@@ -670,6 +671,16 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID
670671
// An empty/missing value is handled gracefully by getEffectiveTokensFromEnv() in messages_footer.cjs.
671672
envVars["GH_AW_EFFECTIVE_TOKENS"] = fmt.Sprintf("${{ needs.%s.outputs.effective_tokens }}", constants.AgentJobName)
672673

674+
// Add slash command metadata so safe output handlers can render run-again footer hints.
675+
if len(data.Command) > 0 {
676+
if commandsJSON, err := json.Marshal(data.Command); err == nil {
677+
envVars["GH_AW_COMMANDS"] = fmt.Sprintf("%q", string(commandsJSON))
678+
}
679+
if data.CommandPlaceholder != "" {
680+
envVars["GH_AW_COMMAND_PLACEHOLDER"] = fmt.Sprintf("%q", data.CommandPlaceholder)
681+
}
682+
}
683+
673684
// Add safe output job environment variables (staged/target repo)
674685
if data.SafeOutputs != nil && (c.trialMode || data.SafeOutputs.Staged) {
675686
envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\""

pkg/workflow/compiler_safe_outputs_job_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,21 @@ func TestBuildJobLevelSafeOutputEnvVars(t *testing.T) {
437437
workflowID: "test-workflow",
438438
checkContains: true,
439439
},
440+
{
441+
name: "with slash command and placeholder",
442+
workflowData: &WorkflowData{
443+
Name: "Test Workflow",
444+
Command: []string{"review-bot"},
445+
CommandPlaceholder: "to review this PR",
446+
SafeOutputs: &SafeOutputsConfig{},
447+
},
448+
workflowID: "test-workflow",
449+
expectedVars: map[string]string{
450+
"GH_AW_COMMANDS": `"[\"review-bot\"]"`,
451+
"GH_AW_COMMAND_PLACEHOLDER": `"to review this PR"`,
452+
},
453+
checkContains: true,
454+
},
440455
}
441456

442457
for _, tt := range tests {

pkg/workflow/compiler_safe_outputs_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ func TestCompilerMergeSafeJobsFromIncludedConfigs(t *testing.T) {
417417

418418
func TestExtractCommandConfig_CentralizedStrategy(t *testing.T) {
419419
c := &Compiler{}
420-
names, events, centralized := c.extractCommandConfig(map[string]any{
420+
names, events, centralized, placeholder := c.extractCommandConfig(map[string]any{
421421
"on": map[string]any{
422422
"slash_command": map[string]any{
423423
"name": "deploy",
@@ -430,6 +430,24 @@ func TestExtractCommandConfig_CentralizedStrategy(t *testing.T) {
430430
assert.Equal(t, []string{"deploy"}, names)
431431
assert.Equal(t, []string{"issue_comment"}, events)
432432
assert.True(t, centralized)
433+
assert.Empty(t, placeholder)
434+
}
435+
436+
func TestExtractCommandConfig_Placeholder(t *testing.T) {
437+
c := &Compiler{}
438+
names, events, centralized, placeholder := c.extractCommandConfig(map[string]any{
439+
"on": map[string]any{
440+
"slash_command": map[string]any{
441+
"name": "review-bot",
442+
"placeholder": "to review this PR",
443+
},
444+
},
445+
})
446+
447+
assert.Equal(t, []string{"review-bot"}, names)
448+
assert.Nil(t, events)
449+
assert.False(t, centralized)
450+
assert.Equal(t, "to review this PR", placeholder)
433451
}
434452

435453
// TestApplyDefaultTools tests default tool application logic

pkg/workflow/compiler_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ type WorkflowData struct {
503503
Command []string // for /command trigger support - multiple command names
504504
CommandEvents []string // events where command should be active (nil = all events)
505505
CommandCentralized bool // when true, slash_command uses centralized dispatch routing via workflow_dispatch
506+
CommandPlaceholder string // optional footer hint text from slash_command.placeholder
506507
CommandOtherEvents map[string]any // for merging command with other events
507508
LabelCommand []string // for label-command trigger support - label names that act as commands
508509
LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion)

pkg/workflow/compiler_yaml.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,9 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor
972972
if commandsJSON, err := json.Marshal(data.Command); err == nil {
973973
fmt.Fprintf(yaml, " GH_AW_COMMANDS: %q\n", string(commandsJSON))
974974
}
975+
if data.CommandPlaceholder != "" {
976+
fmt.Fprintf(yaml, " GH_AW_COMMAND_PLACEHOLDER: %q\n", data.CommandPlaceholder)
977+
}
975978
}
976979

977980
yaml.WriteString(" with:\n")

pkg/workflow/frontmatter_extraction_yaml.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -982,8 +982,8 @@ func (c *Compiler) extractExpressionFromIfString(ifString string) string {
982982
}
983983

984984
// extractCommandConfig extracts command configuration from frontmatter including name, events,
985-
// and centralized routing strategy for slash_command.
986-
func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandNames []string, commandEvents []string, commandCentralized bool) {
985+
// centralized routing strategy, and optional footer placeholder for slash_command.
986+
func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandNames []string, commandEvents []string, commandCentralized bool, commandPlaceholder string) {
987987
frontmatterLog.Print("Extracting command configuration from frontmatter")
988988
// Check new format: on.slash_command or on.slash_command.name (preferred)
989989
// Also check legacy format: on.command or on.command.name (deprecated)
@@ -1015,13 +1015,14 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName
10151015
// Check if command is a string (shorthand format)
10161016
if commandStr, ok := commandValue.(string); ok {
10171017
frontmatterLog.Printf("Extracted command name (shorthand): %s", commandStr)
1018-
return []string{commandStr}, nil, false // nil means default (all events)
1018+
return []string{commandStr}, nil, false, "" // nil means default (all events)
10191019
}
10201020
// Check if command is a map with a name key (object format)
10211021
if commandMap, ok := commandValue.(map[string]any); ok {
10221022
var names []string
10231023
var events []string
10241024
centralized := false
1025+
placeholder := ""
10251026

10261027
if nameValue, hasName := commandMap["name"]; hasName {
10271028
// Handle string or array of strings
@@ -1047,14 +1048,23 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName
10471048
}
10481049
}
10491050

1050-
frontmatterLog.Printf("Extracted command config: names=%v, events=%v, centralized=%v", names, events, centralized)
1051-
return names, events, centralized
1051+
// Extract optional placeholder for footer hint text
1052+
if placeholderRaw, hasPlaceholder := commandMap["placeholder"]; hasPlaceholder {
1053+
if placeholderStr, ok := placeholderRaw.(string); ok {
1054+
if trimmed := strings.TrimSpace(placeholderStr); trimmed != "" {
1055+
placeholder = trimmed
1056+
}
1057+
}
1058+
}
1059+
1060+
frontmatterLog.Printf("Extracted command config: names=%v, events=%v, centralized=%v, placeholder=%q", names, events, centralized, placeholder)
1061+
return names, events, centralized, placeholder
10521062
}
10531063
}
10541064
}
10551065
}
10561066

1057-
return nil, nil, false
1067+
return nil, nil, false, ""
10581068
}
10591069

10601070
// extractLabelCommandConfig extracts the label-command configuration from frontmatter

0 commit comments

Comments
 (0)