diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 48e828c81a3..1c321ffd477 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -31,7 +31,8 @@ const { isStagedMode } = require("./safe_output_helpers.cjs"); const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); +const { generatePatchPreview } = require("./patch_preview.cjs"); +const { parseAllowedBaseBranches, isBaseBranchAllowed } = require("./pr_validation_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -89,50 +90,6 @@ const LABEL_MAX_RETRIES = 3; /** @type {number} Initial delay in ms before the first label retry (3 seconds) */ const LABEL_INITIAL_DELAY_MS = 3000; -/** - * Parse allowed base branch patterns from config value (array or comma-separated string) - * @param {string[]|string|undefined} allowedBaseBranchesValue - * @returns {Set} - */ -function parseAllowedBaseBranches(allowedBaseBranchesValue) { - const set = new Set(); - if (Array.isArray(allowedBaseBranchesValue)) { - allowedBaseBranchesValue - .map(branch => String(branch).trim()) - .filter(Boolean) - .forEach(branch => set.add(branch)); - } else if (typeof allowedBaseBranchesValue === "string") { - allowedBaseBranchesValue - .split(",") - .map(branch => branch.trim()) - .filter(Boolean) - .forEach(branch => set.add(branch)); - } - return set; -} - -/** - * Check if a base branch matches an allowed pattern. - * Supports exact matches and "*" glob patterns (e.g. "release/*"). - * @param {string} baseBranch - * @param {Set} allowedBaseBranches - * @returns {boolean} - */ -function isBaseBranchAllowed(baseBranch, allowedBaseBranches) { - if (allowedBaseBranches.has(baseBranch)) { - return true; - } - for (const pattern of allowedBaseBranches) { - if (pattern === "*") { - return true; - } - if (pattern.includes("*") && globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }).test(baseBranch)) { - return true; - } - } - return false; -} - /** * Merges the required fallback label with any workflow-configured labels, * deduplicating and filtering empty values. @@ -243,36 +200,6 @@ function enforcePullRequestLimits(patchContent) { } } -/** - * Generate a patch preview with max 500 lines and 2000 chars for issue body - * @param {string} patchContent - The full patch content - * @returns {string} Formatted patch preview - */ -function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - - // Apply line limit first - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - - // Apply character limit - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; -} - /** * Check whether the remote branch already exists and, if so, either fail loudly * (when preserve-branch-name is enabled) or rename the local branch by appending diff --git a/actions/setup/js/patch_preview.cjs b/actions/setup/js/patch_preview.cjs new file mode 100644 index 00000000000..ba1989b2024 --- /dev/null +++ b/actions/setup/js/patch_preview.cjs @@ -0,0 +1,34 @@ +// @ts-check + +/** + * Generate a patch preview with max 500 lines and 2000 chars for issue body + * @param {string} patchContent - The full patch content + * @returns {string} Formatted patch preview + */ +function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; + } + + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + + // Apply line limit first + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + + // Apply character limit + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + + const truncated = lineTruncated || charTruncated; + const shownLines = preview.length > 0 ? preview.split("\n").length : 0; + const summary = truncated ? `Show patch preview (${shownLines} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; + + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; +} + +module.exports = { generatePatchPreview }; diff --git a/actions/setup/js/patch_preview.test.cjs b/actions/setup/js/patch_preview.test.cjs new file mode 100644 index 00000000000..64ef9f90356 --- /dev/null +++ b/actions/setup/js/patch_preview.test.cjs @@ -0,0 +1,45 @@ +// @ts-check +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { generatePatchPreview } = require("./patch_preview.cjs"); + +describe("patch_preview", () => { + it("returns empty string for empty patch content", () => { + expect(generatePatchPreview("")).toBe(""); + }); + + it("formats non-truncated preview", () => { + const patch = ["diff --git a/a.txt b/a.txt", "--- a/a.txt", "+++ b/a.txt"].join("\n"); + const preview = generatePatchPreview(patch); + + expect(preview).toContain("Show patch (3 lines)"); + expect(preview).toContain("```diff"); + expect(preview).not.toContain("... (truncated)"); + }); + + it("truncates by line count", () => { + const patch = Array.from({ length: 600 }, () => "x").join("\n"); + const preview = generatePatchPreview(patch); + + expect(preview).toContain("Show patch preview (500 of 600 lines)"); + expect(preview).toContain("... (truncated)"); + }); + + it("truncates by character count", () => { + const patch = "x".repeat(2500); + const preview = generatePatchPreview(patch); + + expect(preview).toContain("Show patch preview (1 of 1 lines)"); + expect(preview).toContain("... (truncated)"); + }); + + it("reports shown line count accurately when truncated by characters", () => { + const patch = Array.from({ length: 800 }, (_, i) => `line-${i}`).join("\n"); + const preview = generatePatchPreview(patch); + + expect(preview).toMatch(/Show patch preview \(\d+ of 800 lines\)<\/summary>/); + expect(preview).not.toContain("Show patch preview (800 of 800 lines)"); + }); +}); diff --git a/actions/setup/js/pr_validation_helpers.cjs b/actions/setup/js/pr_validation_helpers.cjs new file mode 100644 index 00000000000..9610f2e9967 --- /dev/null +++ b/actions/setup/js/pr_validation_helpers.cjs @@ -0,0 +1,58 @@ +// @ts-check + +const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); + +/** + * Parse allowed base branch patterns from config value (array or comma-separated string) + * @param {string[]|string|undefined} allowedBaseBranchesValue + * @returns {Set} + */ +function parseAllowedBaseBranches(allowedBaseBranchesValue) { + const set = new Set(); + if (Array.isArray(allowedBaseBranchesValue)) { + allowedBaseBranchesValue + .map(branch => String(branch).trim()) + .filter(Boolean) + .forEach(branch => set.add(branch)); + } else if (typeof allowedBaseBranchesValue === "string") { + allowedBaseBranchesValue + .split(",") + .map(branch => branch.trim()) + .filter(Boolean) + .forEach(branch => set.add(branch)); + } + return set; +} + +/** + * Check if a base branch matches an allowed pattern. + * Supports exact matches and "*" glob patterns (e.g. "release/*"). + * @param {string} baseBranch + * @param {Set} allowedBaseBranches + * @returns {boolean} + */ +function isBaseBranchAllowed(baseBranch, allowedBaseBranches) { + if (allowedBaseBranches.has(baseBranch)) { + return true; + } + for (const pattern of allowedBaseBranches) { + if (pattern === "*") { + return true; + } + // Exact-match patterns were already handled above by Set lookup. + // Skip regex compilation unless the pattern is a glob. + if (!pattern.includes("*")) { + continue; + } + const regex = globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }); + if (regex.test(baseBranch)) { + return true; + } + } + return false; +} + +module.exports = { + parseAllowedBaseBranches, + isBaseBranchAllowed, +}; diff --git a/actions/setup/js/pr_validation_helpers.test.cjs b/actions/setup/js/pr_validation_helpers.test.cjs new file mode 100644 index 00000000000..604fee94a80 --- /dev/null +++ b/actions/setup/js/pr_validation_helpers.test.cjs @@ -0,0 +1,38 @@ +// @ts-check +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { parseAllowedBaseBranches, isBaseBranchAllowed } = require("./pr_validation_helpers.cjs"); + +describe("pr_validation_helpers", () => { + describe("parseAllowedBaseBranches", () => { + it("parses array values", () => { + const parsed = parseAllowedBaseBranches([" main ", "release/*", ""]); + expect([...parsed]).toEqual(["main", "release/*"]); + }); + + it("parses comma-separated values", () => { + const parsed = parseAllowedBaseBranches("main, release/*, "); + expect([...parsed]).toEqual(["main", "release/*"]); + }); + }); + + describe("isBaseBranchAllowed", () => { + it("allows exact branch matches", () => { + expect(isBaseBranchAllowed("main", new Set(["main"]))).toBe(true); + }); + + it("allows wildcard branch matches", () => { + expect(isBaseBranchAllowed("release/2026.04", new Set(["release/*"]))).toBe(true); + }); + + it("allows all branches for star pattern", () => { + expect(isBaseBranchAllowed("feature/x", new Set(["*"]))).toBe(true); + }); + + it("rejects non-matching branches", () => { + expect(isBaseBranchAllowed("feature/x", new Set(["main", "release/*"]))).toBe(false); + }); + }); +});