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
54 changes: 54 additions & 0 deletions actions/setup/js/update_entity_helpers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { sanitizeTitle } = require("./sanitize_title.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");

/**
* Build shared update payload fields for issue/PR update handlers.
*
* @param {Object} item
* @param {Object} config
* @param {Object} options
* @param {boolean} [options.allowTitle=true]
* @param {string} [options.defaultOperation] - Required when item.body may be present; used as fallback operation if item.operation and configDefaultOperation are both absent.
* @param {string | undefined} [options.configDefaultOperation]
* @param {boolean} [options.includeBodyInApiData=false]
* @param {(() => void) | undefined} [options.onBodyDisallowed]
* @returns {{updateData: Object, hasCommonUpdates: boolean}}
*/
function buildCommonEntityUpdateData(item, config, options = {}) {
const { allowTitle = true, defaultOperation, configDefaultOperation, includeBodyInApiData = false, onBodyDisallowed } = options;

const updateData = {};
let hasCommonUpdates = false;

if (allowTitle && item.title !== undefined) {
updateData.title = sanitizeTitle(item.title);
hasCommonUpdates = true;
}

const canUpdateBody = config.allow_body !== false;
if (item.body !== undefined && canUpdateBody) {
const resolvedOperation = item.operation || configDefaultOperation || defaultOperation;
if (!resolvedOperation) {
throw new Error("buildCommonEntityUpdateData: defaultOperation is required when body may be present");
}
updateData._operation = resolvedOperation;
updateData._rawBody = item.body;
if (includeBodyInApiData) {
updateData.body = item.body;
}
hasCommonUpdates = true;
} else if (item.body !== undefined && !canUpdateBody && typeof onBodyDisallowed === "function") {
onBodyDisallowed();
}

// Always populate _includeFooter: downstream executeUpdate reads it regardless of
// whether title/body changed, matching pre-refactor behavior in both callers.
updateData._includeFooter = parseBoolTemplatable(config.footer, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/zoom-out] _includeFooter is always written to updateData — even when both title and body are absent and hasCommonUpdates will be false. This matches pre-refactor behavior in both callers (intentional), but a brief comment here would protect against a future maintainer wrapping it in an if (hasCommonUpdates) guard and inadvertently breaking the footer on no-op calls.

💡 Suggested inline comment
// Always populate _includeFooter: downstream executeUpdate reads it regardless of
// whether title/body changed, matching pre-refactor behavior in both callers.
updateData._includeFooter = parseBoolTemplatable(config.footer, true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the inline comment in 8cf7e35.


return { updateData, hasCommonUpdates };
}

module.exports = { buildCommonEntityUpdateData };
83 changes: 83 additions & 0 deletions actions/setup/js/update_entity_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs");

describe("update_entity_helpers.cjs - buildCommonEntityUpdateData", () => {
it("returns hasCommonUpdates true and populates title, body fields, and footer when title and body are provided", () => {
const result = buildCommonEntityUpdateData(
{ title: "New title", body: "Body text" },
{},
{
defaultOperation: "append",
}
);

expect(result.updateData.title).toBe("New title");
expect(result.updateData._operation).toBe("append");
expect(result.updateData._rawBody).toBe("Body text");
expect(result.updateData._includeFooter).toBe(true);
expect(result.hasCommonUpdates).toBe(true);
});

it("prefers configDefaultOperation over defaultOperation for body operation", () => {
const result = buildCommonEntityUpdateData(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

item.operation priority is never exercised in this test: the test only verifies configDefaultOperation > defaultOperation fallback but never tests that an explicit item.operation wins over both.

💡 Suggested addition

The resolution chain item.operation || configDefaultOperation || defaultOperation has three levels, but this test only covers the bottom two. Add a case:

it("item.operation takes precedence over configDefaultOperation and defaultOperation", () => {
  const result = buildCommonEntityUpdateData(
    { body: "Body text", operation: "prepend" },
    {},
    {
      defaultOperation: "append",
      configDefaultOperation: "replace",
    }
  );
  expect(result.updateData._operation).toBe("prepend");
});

Without this, a regression that swaps the precedence order (e.g., configDefaultOperation || item.operation || defaultOperation) would go undetected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added "item.operation takes precedence over configDefaultOperation and defaultOperation" test covering all three levels of the resolution chain in 8cf7e35.

{ body: "Body text" },
{},
{
defaultOperation: "append",
configDefaultOperation: "replace",
}
);

expect(result.updateData._operation).toBe("replace");
});

it("includes body in api data when includeBodyInApiData is true", () => {
const result = buildCommonEntityUpdateData(
{ body: "Body text" },
{},
{
defaultOperation: "append",
includeBodyInApiData: true,
}
);

expect(result.updateData.body).toBe("Body text");
});

it("item.operation takes precedence over configDefaultOperation and defaultOperation", () => {
const result = buildCommonEntityUpdateData(
{ body: "Body text", operation: "prepend" },
{},
{
defaultOperation: "append",
configDefaultOperation: "replace",
}
);

expect(result.updateData._operation).toBe("prepend");
});

it("skips title when allowTitle is false and does not set hasCommonUpdates", () => {
const result = buildCommonEntityUpdateData({ title: "Should be ignored" }, {}, { allowTitle: false, defaultOperation: "append" });

expect(result.updateData.title).toBeUndefined();
expect(result.hasCommonUpdates).toBe(false);
});

it("invokes onBodyDisallowed when body updates are blocked", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

allowTitle: false is entirely untested: the test suite never passes allowTitle: false, so neither the title-skip behavior nor its effect on hasCommonUpdates is verified.

💡 Suggested addition

hasCommonUpdates controls whether the PR handler skips the update entirely (returns skipped: true). If allowTitle is false and a title is present, that title must not count toward hasCommonUpdates. Without a test, a future regression (e.g., hasCommonUpdates = true despite the title block) would silently affect the PR skip path.

it("blocks title update when allowTitle is false and does not set hasCommonUpdates", () => {
  const result = buildCommonEntityUpdateData(
    { title: "Should be ignored" },
    {},
    { allowTitle: false, defaultOperation: "append" }
  );
  expect(result.updateData.title).toBeUndefined();
  expect(result.hasCommonUpdates).toBe(false);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered by the same allowTitle: false test added in 8cf7e35 — verifies both updateData.title is undefined and hasCommonUpdates is false.

const onBodyDisallowed = vi.fn();

const result = buildCommonEntityUpdateData(
{ body: "Body text" },
{ allow_body: false },
{
defaultOperation: "append",
onBodyDisallowed,
}
);

expect(onBodyDisallowed).toHaveBeenCalledTimes(1);
expect(result.updateData._rawBody).toBeUndefined();
expect(result.hasCommonUpdates).toBe(false);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Missing allowTitle: false test — the key opt-out branch is unguarded.

The allowTitle option controls whether the title is included in the payload. If this logic regresses, both update_issue.cjs and update_pull_request.cjs would silently emit unwanted title updates with no failing test to catch it.

💡 Suggested test
it('skips title when allowTitle is false', () => {
  const result = buildCommonEntityUpdateData(
    { title: 'Should not appear' },
    {},
    { allowTitle: false }
  );
  expect(result.updateData.title).toBeUndefined();
  expect(result.hasCommonUpdates).toBe(false);
});

This is the primary guard used by update_pull_request.cjs (config.allow_title !== false) — a direct specification test makes the contract explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added "skips title when allowTitle is false and does not set hasCommonUpdates" test in 8cf7e35.

30 changes: 9 additions & 21 deletions actions/setup/js/update_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ const HANDLER_TYPE = "update_issue";
const { resolveTarget, checkRequiredFilter } = require("./safe_output_helpers.cjs");
const { createUpdateHandlerFactory, createStandardResolveNumber, createStandardFormatResult } = require("./update_handler_factory.cjs");
const { updateBody } = require("./update_pr_description_helpers.cjs");
const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs");
const { loadTemporaryProjectMap, replaceTemporaryProjectReferences } = require("./temporary_id.cjs");
const { sanitizeTitle } = require("./sanitize_title.cjs");
const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { fetchIssueState, mergeIssueState } = require("./safe_output_execution_metadata.cjs");
Expand Down Expand Up @@ -131,23 +130,15 @@ const resolveIssueNumber = createStandardResolveNumber({
* @returns {{success: true, data: Object} | {success: false, error: string}} Update data result
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/zoom-out] hasCommonUpdates is returned by the helper but silently discarded here, while update_pull_request.cjs uses it to gate an early-return path. The asymmetry is invisible at the call site and could trip up a future maintainer who copies the PR pattern here expecting the same guard.

Consider either documenting why it's unused (/* hasCommonUpdates not needed: issue handler always continues to check entity-specific fields */) or destructuring with a comment: const { updateData } = ... is fine as-is, but the intent of the discarded value deserves a note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added // hasCommonUpdates is not needed here: the issue handler always continues to check entity-specific fields (state, labels, assignees, milestone, title prefix). in 8cf7e35.

function buildIssueUpdateData(item, config) {
const updateData = {};
// hasCommonUpdates is not needed here: the issue handler always continues to check
// entity-specific fields (state, labels, assignees, milestone, title prefix).
const { updateData } = buildCommonEntityUpdateData(item, config, {
defaultOperation: "append",
onBodyDisallowed: () => {
core.warning("Body update not allowed by safe-outputs configuration");
},
});

if (item.title !== undefined) {
// Sanitize title for Unicode security
updateData.title = sanitizeTitle(item.title);
}
// Check if body updates are allowed (defaults to true if not specified)
const canUpdateBody = config.allow_body !== false;
if (item.body !== undefined && canUpdateBody) {
// Store operation information for consistent footer/append behavior.
// Default to "append" so we preserve the original issue text.
updateData._operation = item.operation || "append";
updateData._rawBody = item.body;
} else if (item.body !== undefined && !canUpdateBody) {
// Body update attempted but not allowed by configuration
core.warning("Body update not allowed by safe-outputs configuration");
}
// The safe-outputs schema uses "status" (open/closed), while the GitHub API uses "state".
// Accept both for compatibility.
if (item.state !== undefined) {
Expand Down Expand Up @@ -178,9 +169,6 @@ function buildIssueUpdateData(item, config) {
return { success: false, error: assigneesLimitResult.error };
}

// Pass footer config to executeUpdate (default to true)
updateData._includeFooter = parseBoolTemplatable(config.footer, true);

// Store title prefix for validation in executeIssueUpdate
if (config.title_prefix) {
updateData._titlePrefix = config.title_prefix;
Expand Down
33 changes: 8 additions & 25 deletions actions/setup/js/update_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ const HANDLER_TYPE = "update_pull_request";
const { updateBody } = require("./update_pr_description_helpers.cjs");
const { resolveTarget, checkRequiredFilter } = require("./safe_output_helpers.cjs");
const { createUpdateHandlerFactory, createStandardResolveNumber, createStandardFormatResult } = require("./update_handler_factory.cjs");
const { sanitizeTitle } = require("./sanitize_title.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
Expand Down Expand Up @@ -187,26 +186,13 @@ const resolvePRNumber = createStandardResolveNumber({
*/
function buildPRUpdateData(item, config) {
const canUpdateTitle = config.allow_title !== false; // Default true
const canUpdateBody = config.allow_body !== false; // Default true

const updateData = {};
let hasUpdates = false;

if (canUpdateTitle && item.title !== undefined) {
// Sanitize title for Unicode security (no prefix handling needed for updates)
updateData.title = sanitizeTitle(item.title);
hasUpdates = true;
}

if (canUpdateBody && item.body !== undefined) {
// Store operation information
// Use operation from item, or fall back to config default, or use "replace" as final default
const operation = item.operation || config.default_operation || "replace";
updateData._operation = operation;
updateData._rawBody = item.body;
updateData.body = item.body;
hasUpdates = true;
}
const { updateData, hasCommonUpdates } = buildCommonEntityUpdateData(item, config, {
allowTitle: canUpdateTitle,
defaultOperation: "replace",
configDefaultOperation: config.default_operation,
includeBodyInApiData: true,
});
let hasUpdates = hasCommonUpdates;

// Other fields (always allowed)
if (item.state !== undefined) {
Expand Down Expand Up @@ -236,9 +222,6 @@ function buildPRUpdateData(item, config) {
};
}

// Pass footer config to executeUpdate (default to true)
updateData._includeFooter = parseBoolTemplatable(config.footer, true);

return { success: true, data: updateData };
}

Expand Down
Loading