Skip to content
Merged
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
73 changes: 20 additions & 53 deletions actions/setup/js/messages_run_status.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Run Status Message Module
Expand All @@ -10,6 +9,19 @@

const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs");

/**
* Renders a message using a custom template from config or a default template.
* @param {string} messageKey - Key in the messages config (e.g., "runStarted")
* @param {string} defaultTemplate - Default template string with {placeholder} syntax
* @param {Object} ctx - Context object for template substitution
* @returns {string} Rendered message
*/
function renderConfiguredMessage(messageKey, defaultTemplate, ctx) {
const messages = getMessages();
const template = messages?.[messageKey] ?? defaultTemplate;
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderConfiguredMessage uses nullish-coalescing (messages?.[messageKey] ?? defaultTemplate). This changes behavior vs the previous truthy check: an empty-string configured message will now override the default, and a non-string (e.g. false, 0) would be passed into renderTemplate and throw at runtime (template.replace is not a function). Consider preserving the prior semantics and adding a type guard, e.g. only use the configured value when it is a non-empty string; otherwise fall back to defaultTemplate (and optionally emit a warning for invalid types).

Suggested change
const template = messages?.[messageKey] ?? defaultTemplate;
const configuredTemplate = messages && messages[messageKey];
const template = typeof configuredTemplate === "string" && configuredTemplate
? configuredTemplate
: defaultTemplate;

Copilot uses AI. Check for mistakes.
return renderTemplate(template, toSnakeCase(ctx));
}

/**
* @typedef {Object} RunStartedContext
* @property {string} workflowName - Name of the workflow
Expand All @@ -23,16 +35,7 @@ const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cj
* @returns {string} Run-started message
*/
function getRunStartedMessage(ctx) {
const messages = getMessages();

// Create context with both camelCase and snake_case keys
const templateContext = toSnakeCase(ctx);

// Default run-started template
const defaultMessage = "🚀 [{workflow_name}]({run_url}) has started processing this {event_type}";

// Use custom message if configured
return messages?.runStarted ? renderTemplate(messages.runStarted, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("runStarted", "🚀 [{workflow_name}]({run_url}) has started processing this {event_type}", ctx);
}

/**
Expand All @@ -47,16 +50,7 @@ function getRunStartedMessage(ctx) {
* @returns {string} Run-success message
*/
function getRunSuccessMessage(ctx) {
const messages = getMessages();

// Create context with both camelCase and snake_case keys
const templateContext = toSnakeCase(ctx);

// Default run-success template
const defaultMessage = "✅ [{workflow_name}]({run_url}) completed successfully!";

// Use custom message if configured
return messages?.runSuccess ? renderTemplate(messages.runSuccess, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("runSuccess", "✅ [{workflow_name}]({run_url}) completed successfully!", ctx);
}

/**
Expand All @@ -72,16 +66,7 @@ function getRunSuccessMessage(ctx) {
* @returns {string} Run-failure message
*/
function getRunFailureMessage(ctx) {
const messages = getMessages();

// Create context with both camelCase and snake_case keys
const templateContext = toSnakeCase(ctx);

// Default run-failure template
const defaultMessage = "❌ [{workflow_name}]({run_url}) {status}. Please review the logs for details.";

// Use custom message if configured
return messages?.runFailure ? renderTemplate(messages.runFailure, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("runFailure", "❌ [{workflow_name}]({run_url}) {status}. Please review the logs for details.", ctx);
}

/**
Expand All @@ -96,16 +81,7 @@ function getRunFailureMessage(ctx) {
* @returns {string} Detection-failure message
*/
function getDetectionFailureMessage(ctx) {
const messages = getMessages();

// Create context with both camelCase and snake_case keys
const templateContext = toSnakeCase(ctx);

// Default detection-failure template
const defaultMessage = "⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.";

// Use custom message if configured
return messages?.detectionFailure ? renderTemplate(messages.detectionFailure, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("detectionFailure", "⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", ctx);
}

/**
Expand All @@ -120,10 +96,7 @@ function getDetectionFailureMessage(ctx) {
* @returns {string} Pull-request-created message
*/
function getPullRequestCreatedMessage(ctx) {
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
const defaultMessage = "Pull request created: [#{item_number}]({item_url})";
return messages?.pullRequestCreated ? renderTemplate(messages.pullRequestCreated, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("pullRequestCreated", "Pull request created: [#{item_number}]({item_url})", ctx);
}

/**
Expand All @@ -138,10 +111,7 @@ function getPullRequestCreatedMessage(ctx) {
* @returns {string} Issue-created message
*/
function getIssueCreatedMessage(ctx) {
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
const defaultMessage = "Issue created: [#{item_number}]({item_url})";
return messages?.issueCreated ? renderTemplate(messages.issueCreated, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("issueCreated", "Issue created: [#{item_number}]({item_url})", ctx);
}

/**
Expand All @@ -157,10 +127,7 @@ function getIssueCreatedMessage(ctx) {
* @returns {string} Commit-pushed message
*/
function getCommitPushedMessage(ctx) {
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
const defaultMessage = "Commit pushed: [`{short_sha}`]({commit_url})";
return messages?.commitPushed ? renderTemplate(messages.commitPushed, templateContext) : renderTemplate(defaultMessage, templateContext);
return renderConfiguredMessage("commitPushed", "Commit pushed: [`{short_sha}`]({commit_url})", ctx);
}

module.exports = {
Expand Down
156 changes: 156 additions & 0 deletions actions/setup/js/messages_run_status.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// @ts-check
import { describe, it, expect, beforeEach, vi } from "vitest";

// messages_core.cjs calls core.warning on parse failures - provide a stub
const mockCore = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
};
global.core = mockCore;

const { getRunStartedMessage, getRunSuccessMessage, getRunFailureMessage, getDetectionFailureMessage, getPullRequestCreatedMessage, getIssueCreatedMessage, getCommitPushedMessage } = require("./messages_run_status.cjs");

const WORKFLOW = "My Workflow";
const RUN_URL = "https://github.com/owner/repo/actions/runs/99";

describe("messages_run_status", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
});

describe("getRunStartedMessage", () => {
it("returns default template with all placeholders substituted", () => {
const msg = getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "issue" });
expect(msg).toBe(`🚀 [${WORKFLOW}](${RUN_URL}) has started processing this issue`);
});

it("supports different event types", () => {
expect(getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "pull request" })).toContain("pull request");
expect(getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "discussion" })).toContain("discussion");
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runStarted: "Custom: {workflow_name} started" });
const msg = getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "issue" });
expect(msg).toBe(`Custom: ${WORKFLOW} started`);
});

it("substitutes camelCase keys as well as snake_case", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runStarted: "{workflowName} at {runUrl}" });
const msg = getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "issue" });
expect(msg).toBe(`${WORKFLOW} at ${RUN_URL}`);
});
});

describe("getRunSuccessMessage", () => {
it("returns default template with placeholders substituted", () => {
const msg = getRunSuccessMessage({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(msg).toBe(`✅ [${WORKFLOW}](${RUN_URL}) completed successfully!`);
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runSuccess: "Done: {workflow_name}" });
const msg = getRunSuccessMessage({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(msg).toBe(`Done: ${WORKFLOW}`);
});

it("ignores unrelated config keys and uses default", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runStarted: "overridden" });
const msg = getRunSuccessMessage({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(msg).toContain("completed successfully");
});
});

describe("getRunFailureMessage", () => {
it("returns default template with status substituted", () => {
const msg = getRunFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, status: "failed" });
expect(msg).toBe(`❌ [${WORKFLOW}](${RUN_URL}) failed. Please review the logs for details.`);
});

it("handles different status values", () => {
expect(getRunFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, status: "was cancelled" })).toContain("was cancelled");
expect(getRunFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, status: "timed out" })).toContain("timed out");
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runFailure: "FAILED: {workflow_name} - {status}" });
const msg = getRunFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, status: "failed" });
expect(msg).toBe(`FAILED: ${WORKFLOW} - failed`);
});
});

describe("getDetectionFailureMessage", () => {
it("returns default template with placeholders substituted", () => {
const msg = getDetectionFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(msg).toBe(`⚠️ Security scanning failed for [${WORKFLOW}](${RUN_URL}). Review the logs for details.`);
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ detectionFailure: "Security alert for {workflow_name}" });
const msg = getDetectionFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(msg).toBe(`Security alert for ${WORKFLOW}`);
});
});

describe("getPullRequestCreatedMessage", () => {
it("returns default template with item_number and item_url substituted", () => {
const msg = getPullRequestCreatedMessage({ itemNumber: 42, itemUrl: "https://github.com/owner/repo/pull/42" });
expect(msg).toBe("Pull request created: [#42](https://github.com/owner/repo/pull/42)");
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ pullRequestCreated: "PR #{item_number} ready" });
const msg = getPullRequestCreatedMessage({ itemNumber: 7, itemUrl: "https://github.com/owner/repo/pull/7" });
expect(msg).toBe("PR #7 ready");
});
});

describe("getIssueCreatedMessage", () => {
it("returns default template with item_number and item_url substituted", () => {
const msg = getIssueCreatedMessage({ itemNumber: 15, itemUrl: "https://github.com/owner/repo/issues/15" });
expect(msg).toBe("Issue created: [#15](https://github.com/owner/repo/issues/15)");
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ issueCreated: "New issue #{item_number}" });
const msg = getIssueCreatedMessage({ itemNumber: 3, itemUrl: "https://github.com/owner/repo/issues/3" });
expect(msg).toBe("New issue #3");
});
});

describe("getCommitPushedMessage", () => {
const SHA = "abc1234def5678901234567890123456789012ab";
const SHORT = "abc1234";
const COMMIT_URL = `https://github.com/owner/repo/commit/${SHA}`;

it("returns default template with short_sha and commit_url substituted", () => {
const msg = getCommitPushedMessage({ commitSha: SHA, shortSha: SHORT, commitUrl: COMMIT_URL });
expect(msg).toBe(`Commit pushed: [\`${SHORT}\`](${COMMIT_URL})`);
});

it("uses custom template from config", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ commitPushed: "Pushed {short_sha} to repo" });
const msg = getCommitPushedMessage({ commitSha: SHA, shortSha: SHORT, commitUrl: COMMIT_URL });
expect(msg).toBe(`Pushed ${SHORT} to repo`);
});

it("supports full SHA in custom template", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ commitPushed: "{commit_sha}" });
const msg = getCommitPushedMessage({ commitSha: SHA, shortSha: SHORT, commitUrl: COMMIT_URL });
expect(msg).toBe(SHA);
});
});

describe("fallback when config is missing keys", () => {
it("uses default template when only unrelated config keys are set", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "custom footer" });
expect(getRunStartedMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, eventType: "issue" })).toContain("has started processing");
expect(getRunSuccessMessage({ workflowName: WORKFLOW, runUrl: RUN_URL })).toContain("completed successfully");
expect(getRunFailureMessage({ workflowName: WORKFLOW, runUrl: RUN_URL, status: "failed" })).toContain("failed");
});
});
});
Loading