diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 3031f792dde..1479004658e 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -27,16 +27,32 @@ const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { checkFileProtection } = require("./manifest_file_helpers.cjs"); -const { renderTemplateFromFile, buildProtectedFileList, encodePathSegments, getPromptPath } = require("./messages_core.cjs"); -const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL, MAX_ASSIGNEES } = require("./constants.cjs"); +const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); +const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); -const { withRetry, isTransientError, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); -const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); +const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits } = require("./git_helpers.cjs"); const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs"); +const { + MANAGED_FALLBACK_ISSUE_LABEL, + LABEL_MAX_RETRIES, + LABEL_INITIAL_DELAY_MS, + LABEL_MAX_DELAY_MS, + summarizeListForLog, + createBundleTempRef, + isLabelTransientError, + parseAllowedBaseBranches, + isBaseBranchAllowed, + parseStringListConfig, + mergeFallbackIssueLabels, + sanitizeFallbackAssignees, + neutralizeClosingKeywordsForIssueBody, + generatePatchPreview, + buildManifestProtectionCreatePrUrl, + renderManifestProtectionFallbackBody, +} = require("./create_pull_request_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -68,35 +84,8 @@ async function createCopilotAssignmentClient(config) { /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "create_pull_request"; -/** @type {string} Label always added to fallback issues so the triage system can find them */ -const MANAGED_FALLBACK_ISSUE_LABEL = "agentic-workflows"; - -/** - * Creates a temporary refs/bundles ref for applying create_pull_request bundles. - * Branch names are sanitized for ref compatibility, and a short crypto-random - * suffix avoids collisions between branches that sanitize to the same value. - * - * @param {string} branchName - Target branch name - * @returns {string} Temporary bundle ref name - */ -function createBundleTempRef(branchName) { - const suffix = crypto.randomBytes(4).toString("hex"); - return `refs/bundles/create-pr-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}-${suffix}`; -} - -/** - * Summarize a list for log output to avoid excessively long lines. - * @param {string[]} values - * @param {number} limit - * @returns {string} - */ -function summarizeListForLog(values, limit = 10) { - if (!Array.isArray(values) || values.length === 0) { - return "(none)"; - } - const preview = values.slice(0, limit).join(", "); - return values.length > limit ? `${preview} ... and ${values.length - limit} more` : preview; -} +// NOTE: MANAGED_FALLBACK_ISSUE_LABEL, createBundleTempRef, and summarizeListForLog +// are imported from create_pull_request_helpers.cjs above. /** * Attempt automatic recovery for git am add/add conflicts by preferring the patch version. @@ -319,131 +308,11 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi) { } } -/** - * Determines if a label API error is transient and worth retrying. - * Returns true for: - * - The GitHub race condition where a newly-created PR's node ID is not immediately - * resolvable via the REST/GraphQL bridge (unprocessable validation error). - * - Any standard transient error matched by {@link isTransientError} (network issues, - * rate limits, 5xx gateway errors, etc.). - * @param {any} error - The error to check - * @returns {boolean} True if the error is transient and should be retried - */ -function isLabelTransientError(error) { - const msg = getErrorMessage(error); - if (msg.includes("Could not resolve to a node with the global id")) { - return true; - } - return isTransientError(error); -} - -/** @type {number} Number of retry attempts for label operations */ -const LABEL_MAX_RETRIES = 5; -/** @type {number} Base delay in ms used to calculate label retry backoff (3 seconds) */ -const LABEL_INITIAL_DELAY_MS = 3000; -/** @type {number} Maximum delay in ms between label retries (30 seconds) */ -const LABEL_MAX_DELAY_MS = 30000; - -/** - * 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; -} - -/** - * Parse config values that may be arrays or comma-separated strings. - * @param {string[]|string|undefined} value - * @returns {string[]} - */ -function parseStringListConfig(value) { - if (!value) { - return []; - } - const raw = Array.isArray(value) ? value : String(value).split(","); - return raw.map(item => String(item).trim()).filter(Boolean); -} - -/** - * Merges the required fallback label with any workflow-configured labels, - * deduplicating and filtering empty values. - * @param {string[]} [labels] - * @returns {string[]} - */ -function mergeFallbackIssueLabels(labels = []) { - const normalizedLabels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label); - return [...new Set([MANAGED_FALLBACK_ISSUE_LABEL, ...normalizedLabels])]; -} - -/** - * Sanitizes configured assignees for fallback issue creation. - * Filters invalid values, removes the special "copilot" username (not a valid GitHub user - * for issue assignment), and enforces the MAX_ASSIGNEES limit. - * Returns null (no assignees field) if the sanitized list is empty. - * @param {string[]} assignees - Raw assignees from config - * @returns {string[] | null} Sanitized assignees or null if none remain - */ -function sanitizeFallbackAssignees(assignees) { - if (!assignees || assignees.length === 0) { - return null; - } - const sanitized = assignees - .filter(a => typeof a === "string") - .map(a => a.trim()) - .filter(a => a.length > 0 && a.toLowerCase() !== "copilot"); - - if (sanitized.length === 0) { - return null; - } - - const limitResult = tryEnforceArrayLimit(sanitized, MAX_ASSIGNEES, "assignees"); - if (!limitResult.success) { - core.warning(`Assignees limit exceeded for fallback issue: ${limitResult.error}. Using first ${MAX_ASSIGNEES}.`); - return sanitized.slice(0, MAX_ASSIGNEES); - } - - return sanitized; -} +// NOTE: isLabelTransientError, LABEL_MAX_RETRIES, LABEL_INITIAL_DELAY_MS, LABEL_MAX_DELAY_MS, +// parseAllowedBaseBranches, isBaseBranchAllowed, parseStringListConfig, mergeFallbackIssueLabels, +// sanitizeFallbackAssignees, neutralizeClosingKeywordsForIssueBody, generatePatchPreview, +// buildManifestProtectionCreatePrUrl, and renderManifestProtectionFallbackBody +// are imported from create_pull_request_helpers.cjs above. /** * Creates a fallback GitHub issue, retrying on rate-limit and other transient errors @@ -494,61 +363,6 @@ async function createFallbackIssue(githubClient, repoParts, title, body, labels, ); } -/** - * Builds a compare URL used in protected-files fallback issue bodies. - * Optionally appends a prefilled PR body that closes the fallback issue. - * @param {string} githubServer - * @param {{owner: string, repo: string}} repoParts - * @param {string} baseBranch - * @param {string} branchName - * @param {string} title - * @param {number} [fallbackIssueNumber] - * @returns {string} - */ -function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, fallbackIssueNumber) { - const encodedBase = encodePathSegments(baseBranch); - const encodedHead = encodePathSegments(branchName); - let createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; - if (typeof fallbackIssueNumber === "number") { - createPrUrl += `&body=${encodeURIComponent(`Closes #${fallbackIssueNumber}`)}`; - } - return createPrUrl; -} - -/** - * Renders protected-files fallback issue body with a prefilled compare URL. - * @param {string} mainBodyContent - * @param {string} footerContent - * @param {string} fileList - * @param {string} createPrUrl - * @returns {string} - */ -function renderManifestProtectionFallbackBody(mainBodyContent, footerContent, fileList, createPrUrl) { - const templatePath = getPromptPath("manifest_protection_create_pr_fallback.md"); - return renderTemplateFromFile(templatePath, { - main_body: mainBodyContent, - footer: footerContent, - files: fileList, - create_pr_url: createPrUrl, - }); -} - -/** - * Neutralizes issue-closing keywords in body text to avoid unintended cross-issue closure - * when PR content is reused in fallback issue bodies. - * - * Example: "Closes #123" -> "Closes \\#123" - * - * @param {string} content - * @returns {string} - */ -function neutralizeClosingKeywordsForIssueBody(content) { - if (!content) { - return content; - } - return String(content).replace(/\b(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+((?:[a-z0-9_.-]+\/[a-z0-9_.-]+)?#\d+)\b/gi, (_match, keyword, issueRef) => `${keyword} ${String(issueRef).replace("#", "\\#")}`); -} - /** * Maximum limits for pull request parameters to prevent resource exhaustion. * These limits align with GitHub's API constraints and security best practices. @@ -632,35 +446,7 @@ function enforcePullRequestLimits(patchContent, maxFiles = MAX_FILES) { } } -/** - * 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
`; -} +// NOTE: generatePatchPreview is imported from create_pull_request_helpers.cjs above. /** * Check whether the remote branch already exists and, if so, either reuse it diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs new file mode 100644 index 00000000000..a634f146634 --- /dev/null +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -0,0 +1,273 @@ +// @ts-check +/// + +/** @type {typeof import("crypto")} */ +const crypto = require("crypto"); +const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); +const { getErrorMessage } = require("./error_helpers.cjs"); +const { isTransientError } = require("./error_recovery.cjs"); +const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); +const { MAX_ASSIGNEES } = require("./constants.cjs"); +const { encodePathSegments, renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs"); + +/** @type {string} Label always added to fallback issues so the triage system can find them */ +const MANAGED_FALLBACK_ISSUE_LABEL = "agentic-workflows"; + +/** @type {number} Number of retry attempts for label operations */ +const LABEL_MAX_RETRIES = 5; +/** @type {number} Base delay in ms used to calculate label retry backoff (3 seconds) */ +const LABEL_INITIAL_DELAY_MS = 3000; +/** @type {number} Maximum delay in ms between label retries (30 seconds) */ +const LABEL_MAX_DELAY_MS = 30000; + +/** + * Summarize a list for log output to avoid excessively long lines. + * @param {string[]} values + * @param {number} limit + * @returns {string} + */ +function summarizeListForLog(values, limit = 10) { + if (!Array.isArray(values) || values.length === 0) { + return "(none)"; + } + const preview = values.slice(0, limit).join(", "); + return values.length > limit ? `${preview} ... and ${values.length - limit} more` : preview; +} + +/** + * Creates a temporary refs/bundles ref for applying create_pull_request bundles. + * Branch names are sanitized for ref compatibility, and a short crypto-random + * suffix avoids collisions between branches that sanitize to the same value. + * + * @param {string} branchName - Target branch name + * @returns {string} Temporary bundle ref name + */ +function createBundleTempRef(branchName) { + const suffix = crypto.randomBytes(4).toString("hex"); + return `refs/bundles/create-pr-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}-${suffix}`; +} + +/** + * Determines if a label API error is transient and worth retrying. + * Returns true for: + * - The GitHub race condition where a newly-created PR's node ID is not immediately + * resolvable via the REST/GraphQL bridge (unprocessable validation error). + * - Any standard transient error matched by {@link isTransientError} (network issues, + * rate limits, 5xx gateway errors, etc.). + * @param {any} error - The error to check + * @returns {boolean} True if the error is transient and should be retried + */ +function isLabelTransientError(error) { + const msg = getErrorMessage(error); + if (msg.includes("Could not resolve to a node with the global id")) { + return true; + } + return isTransientError(error); +} + +/** + * 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; +} + +/** + * Parse config values that may be arrays or comma-separated strings. + * @param {string[]|string|undefined} value + * @returns {string[]} + */ +function parseStringListConfig(value) { + if (!value) { + return []; + } + const raw = Array.isArray(value) ? value : String(value).split(","); + return raw.map(item => String(item).trim()).filter(Boolean); +} + +/** + * Merges the required fallback label with any workflow-configured labels, + * deduplicating and filtering empty values. + * @param {string[]} [labels] + * @returns {string[]} + */ +function mergeFallbackIssueLabels(labels = []) { + const normalizedLabels = labels + .filter(label => !!label) + .map(label => String(label).trim()) + .filter(label => label); + return [...new Set([MANAGED_FALLBACK_ISSUE_LABEL, ...normalizedLabels])]; +} + +/** + * Sanitizes configured assignees for fallback issue creation. + * Filters invalid values, removes the special "copilot" username (not a valid GitHub user + * for issue assignment), and enforces the MAX_ASSIGNEES limit. + * Returns null (no assignees field) if the sanitized list is empty. + * @param {string[]} assignees - Raw assignees from config + * @returns {string[] | null} Sanitized assignees or null if none remain + */ +function sanitizeFallbackAssignees(assignees) { + if (!assignees || assignees.length === 0) { + return null; + } + const sanitized = assignees + .filter(a => typeof a === "string") + .map(a => a.trim()) + .filter(a => a.length > 0 && a.toLowerCase() !== "copilot"); + + if (sanitized.length === 0) { + return null; + } + + const limitResult = tryEnforceArrayLimit(sanitized, MAX_ASSIGNEES, "assignees"); + if (!limitResult.success) { + core.warning(`Assignees limit exceeded for fallback issue: ${limitResult.error}. Using first ${MAX_ASSIGNEES}.`); + return sanitized.slice(0, MAX_ASSIGNEES); + } + + return sanitized; +} + +/** + * Neutralizes issue-closing keywords in body text to avoid unintended cross-issue closure + * when PR content is reused in fallback issue bodies. + * + * Example: "Closes #123" -> "Closes \\#123" + * + * @param {string} content + * @returns {string} + */ +function neutralizeClosingKeywordsForIssueBody(content) { + if (!content) { + return content; + } + const closingKeywordPattern = /\b(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+((?:[a-z0-9_.-]+\/[a-z0-9_.-]+)?#\d+)\b/gi; + const escapeIssueRef = (_match, keyword, issueRef) => `${keyword} ${String(issueRef).replace("#", "\\#")}`; + return String(content).replace(closingKeywordPattern, escapeIssueRef); +} + +/** + * 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
`; +} + +/** + * Builds a compare URL used in protected-files fallback issue bodies. + * Optionally appends a prefilled PR body that closes the fallback issue. + * @param {string} githubServer + * @param {{owner: string, repo: string}} repoParts + * @param {string} baseBranch + * @param {string} branchName + * @param {string} title + * @param {number} [fallbackIssueNumber] + * @returns {string} + */ +function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, fallbackIssueNumber) { + const encodedBase = encodePathSegments(baseBranch); + const encodedHead = encodePathSegments(branchName); + let createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; + if (typeof fallbackIssueNumber === "number") { + createPrUrl += `&body=${encodeURIComponent(`Closes #${fallbackIssueNumber}`)}`; + } + return createPrUrl; +} + +/** + * Renders protected-files fallback issue body with a prefilled compare URL. + * @param {string} mainBodyContent + * @param {string} footerContent + * @param {string} fileList + * @param {string} createPrUrl + * @returns {string} + */ +function renderManifestProtectionFallbackBody(mainBodyContent, footerContent, fileList, createPrUrl) { + const templatePath = getPromptPath("manifest_protection_create_pr_fallback.md"); + return renderTemplateFromFile(templatePath, { + main_body: mainBodyContent, + footer: footerContent, + files: fileList, + create_pr_url: createPrUrl, + }); +} + +module.exports = { + MANAGED_FALLBACK_ISSUE_LABEL, + LABEL_MAX_RETRIES, + LABEL_INITIAL_DELAY_MS, + LABEL_MAX_DELAY_MS, + summarizeListForLog, + createBundleTempRef, + isLabelTransientError, + parseAllowedBaseBranches, + isBaseBranchAllowed, + parseStringListConfig, + mergeFallbackIssueLabels, + sanitizeFallbackAssignees, + neutralizeClosingKeywordsForIssueBody, + generatePatchPreview, + buildManifestProtectionCreatePrUrl, + renderManifestProtectionFallbackBody, +}; diff --git a/actions/setup/js/create_pull_request_helpers.test.cjs b/actions/setup/js/create_pull_request_helpers.test.cjs new file mode 100644 index 00000000000..3bfff75f5ae --- /dev/null +++ b/actions/setup/js/create_pull_request_helpers.test.cjs @@ -0,0 +1,482 @@ +// @ts-check +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; +import crypto from "crypto"; + +const require = createRequire(import.meta.url); + +// Set up globals required by modules that reference `core` at load time. +global.core = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), +}; + +const { + MANAGED_FALLBACK_ISSUE_LABEL, + LABEL_MAX_RETRIES, + LABEL_INITIAL_DELAY_MS, + LABEL_MAX_DELAY_MS, + summarizeListForLog, + createBundleTempRef, + isLabelTransientError, + parseAllowedBaseBranches, + isBaseBranchAllowed, + parseStringListConfig, + mergeFallbackIssueLabels, + sanitizeFallbackAssignees, + neutralizeClosingKeywordsForIssueBody, + generatePatchPreview, + buildManifestProtectionCreatePrUrl, +} = require("./create_pull_request_helpers.cjs"); + +describe("create_pull_request_helpers - constants", () => { + it("MANAGED_FALLBACK_ISSUE_LABEL is the correct triage label", () => { + expect(MANAGED_FALLBACK_ISSUE_LABEL).toBe("agentic-workflows"); + }); + + it("label retry constants have sensible values", () => { + expect(LABEL_MAX_RETRIES).toBeGreaterThan(0); + expect(LABEL_INITIAL_DELAY_MS).toBeGreaterThan(0); + expect(LABEL_MAX_DELAY_MS).toBeGreaterThan(LABEL_INITIAL_DELAY_MS); + }); +}); + +// --------------------------------------------------------------------------- +// summarizeListForLog +// --------------------------------------------------------------------------- +describe("summarizeListForLog", () => { + it("returns (none) for empty array", () => { + expect(summarizeListForLog([])).toBe("(none)"); + }); + + it("returns (none) for non-array input", () => { + // @ts-ignore – deliberate bad input test + expect(summarizeListForLog(null)).toBe("(none)"); + // @ts-ignore + expect(summarizeListForLog(undefined)).toBe("(none)"); + // @ts-ignore + expect(summarizeListForLog("string")).toBe("(none)"); + }); + + it("returns all items joined when count is within limit", () => { + expect(summarizeListForLog(["a", "b", "c"])).toBe("a, b, c"); + }); + + it("truncates with overflow count when exceeding default limit of 10", () => { + const items = Array.from({ length: 15 }, (_, i) => `item${i}`); + const result = summarizeListForLog(items); + expect(result).toContain("... and 5 more"); + expect(result).toContain("item0"); + expect(result).not.toContain("item10"); + }); + + it("respects a custom limit", () => { + const result = summarizeListForLog(["a", "b", "c", "item-d", "item-e"], 3); + expect(result).toContain("... and 2 more"); + expect(result).toContain("a, b, c"); + expect(result).not.toContain("item-d"); + }); + + it("does not truncate when count exactly equals limit", () => { + const items = ["x", "y", "z"]; + const result = summarizeListForLog(items, 3); + expect(result).toBe("x, y, z"); + expect(result).not.toContain("more"); + }); + + it("single item returns that item with no trailing comma", () => { + expect(summarizeListForLog(["only"])).toBe("only"); + }); +}); + +// --------------------------------------------------------------------------- +// createBundleTempRef +// --------------------------------------------------------------------------- +describe("createBundleTempRef", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("produces a ref under refs/bundles/", () => { + expect(createBundleTempRef("feature/my-branch")).toMatch(/^refs\/bundles\//); + }); + + it("replaces non-alphanumeric/hyphen characters with hyphens", () => { + const ref = createBundleTempRef("feature/my_branch.with.dots"); + // slashes and dots should become hyphens + expect(ref).toMatch(/^refs\/bundles\/create-pr-feature-my-branch-with-dots-[a-f0-9]{8}$/); + }); + + it("appends an 8-char hex suffix for collision avoidance", () => { + const ref = createBundleTempRef("main"); + expect(ref).toMatch(/^refs\/bundles\/create-pr-main-[a-f0-9]{8}$/); + }); + + it("produces different refs when crypto returns different bytes", () => { + vi.spyOn(crypto, "randomBytes").mockReturnValueOnce(Buffer.from("aabbccdd", "hex")).mockReturnValueOnce(Buffer.from("11223344", "hex")); + + const ref1 = createBundleTempRef("same-branch"); + const ref2 = createBundleTempRef("same-branch"); + + expect(ref1).toBe("refs/bundles/create-pr-same-branch-aabbccdd"); + expect(ref2).toBe("refs/bundles/create-pr-same-branch-11223344"); + }); +}); + +// --------------------------------------------------------------------------- +// isLabelTransientError +// --------------------------------------------------------------------------- +describe("isLabelTransientError", () => { + it("returns true for GitHub node-ID race-condition message", () => { + const err = new Error("Could not resolve to a node with the global id 'PRI_xxx'"); + expect(isLabelTransientError(err)).toBe(true); + }); + + it("returns false for a plain non-transient error", () => { + const err = new Error("Not found"); + expect(isLabelTransientError(err)).toBe(false); + }); + + it("returns true for a 429 rate-limit error (isTransientError path)", () => { + const err = Object.assign(new Error("API rate limit exceeded"), { status: 429 }); + expect(isLabelTransientError(err)).toBe(true); + }); + + it("returns true for a 503 service unavailable error (isTransientError path)", () => { + // isTransientError checks both message content AND status codes; this exercises the message path + const err = new Error("503 service unavailable"); + expect(isLabelTransientError(err)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseAllowedBaseBranches +// --------------------------------------------------------------------------- +describe("parseAllowedBaseBranches", () => { + it("returns empty Set for undefined input", () => { + expect(parseAllowedBaseBranches(undefined).size).toBe(0); + }); + + it("parses an array of branch names", () => { + const result = parseAllowedBaseBranches(["main", "develop"]); + expect(result).toEqual(new Set(["main", "develop"])); + }); + + it("trims whitespace from array entries", () => { + const result = parseAllowedBaseBranches([" main ", " develop "]); + expect(result).toEqual(new Set(["main", "develop"])); + }); + + it("filters out empty array entries", () => { + const result = parseAllowedBaseBranches(["main", "", " "]); + expect(result).toEqual(new Set(["main"])); + }); + + it("parses a comma-separated string", () => { + const result = parseAllowedBaseBranches("main,develop,release/1.0"); + expect(result).toEqual(new Set(["main", "develop", "release/1.0"])); + }); + + it("trims whitespace from comma-separated string entries", () => { + const result = parseAllowedBaseBranches("main , develop , release/1.0"); + expect(result).toEqual(new Set(["main", "develop", "release/1.0"])); + }); + + it("filters empty entries from comma-separated string", () => { + const result = parseAllowedBaseBranches("main,,develop"); + expect(result).toEqual(new Set(["main", "develop"])); + }); +}); + +// --------------------------------------------------------------------------- +// isBaseBranchAllowed +// --------------------------------------------------------------------------- +describe("isBaseBranchAllowed", () => { + it("returns true for exact match", () => { + expect(isBaseBranchAllowed("main", new Set(["main", "develop"]))).toBe(true); + }); + + it("returns false when not in allowed set", () => { + expect(isBaseBranchAllowed("feature/x", new Set(["main", "develop"]))).toBe(false); + }); + + it("returns true when '*' is in the allowed set (allow all)", () => { + expect(isBaseBranchAllowed("any-branch", new Set(["*"]))).toBe(true); + }); + + it("returns true when branch matches a glob pattern like 'release/*'", () => { + expect(isBaseBranchAllowed("release/1.0", new Set(["release/*"]))).toBe(true); + }); + + it("returns false when branch does not match the glob pattern", () => { + expect(isBaseBranchAllowed("feature/1.0", new Set(["release/*"]))).toBe(false); + }); + + it("matches nested branches against multi-level glob", () => { + expect(isBaseBranchAllowed("release/v2/hotfix", new Set(["release/**"]))).toBe(true); + }); + + it("returns false for empty allowed set", () => { + expect(isBaseBranchAllowed("main", new Set())).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseStringListConfig +// --------------------------------------------------------------------------- +describe("parseStringListConfig", () => { + it("returns empty array for falsy input", () => { + expect(parseStringListConfig(undefined)).toEqual([]); + expect(parseStringListConfig("")).toEqual([]); + // @ts-ignore + expect(parseStringListConfig(null)).toEqual([]); + }); + + it("returns the same array after trimming and filtering", () => { + expect(parseStringListConfig(["bug", " enhancement ", ""])).toEqual(["bug", "enhancement"]); + }); + + it("splits a comma-separated string", () => { + expect(parseStringListConfig("bug,enhancement,question")).toEqual(["bug", "enhancement", "question"]); + }); + + it("trims whitespace from comma-separated entries", () => { + expect(parseStringListConfig(" bug , enhancement , question ")).toEqual(["bug", "enhancement", "question"]); + }); + + it("filters blank entries from comma-separated string", () => { + expect(parseStringListConfig("bug,,enhancement")).toEqual(["bug", "enhancement"]); + }); + + it("coerces non-string array items to strings", () => { + // @ts-ignore – deliberate mixed-type test + expect(parseStringListConfig([1, true, "label"])).toEqual(["1", "true", "label"]); + }); +}); + +// --------------------------------------------------------------------------- +// mergeFallbackIssueLabels +// --------------------------------------------------------------------------- +describe("mergeFallbackIssueLabels", () => { + it("always includes MANAGED_FALLBACK_ISSUE_LABEL as the first entry", () => { + const result = mergeFallbackIssueLabels(["custom-label"]); + expect(result[0]).toBe(MANAGED_FALLBACK_ISSUE_LABEL); + }); + + it("returns only MANAGED_FALLBACK_ISSUE_LABEL when called with no args", () => { + expect(mergeFallbackIssueLabels()).toEqual([MANAGED_FALLBACK_ISSUE_LABEL]); + }); + + it("deduplicates when MANAGED_FALLBACK_ISSUE_LABEL is passed explicitly", () => { + const result = mergeFallbackIssueLabels([MANAGED_FALLBACK_ISSUE_LABEL, "other"]); + expect(result.filter(l => l === MANAGED_FALLBACK_ISSUE_LABEL)).toHaveLength(1); + }); + + it("includes additional labels after the managed label", () => { + const result = mergeFallbackIssueLabels(["alpha", "beta"]); + expect(result).toEqual([MANAGED_FALLBACK_ISSUE_LABEL, "alpha", "beta"]); + }); + + it("filters out empty and whitespace-only labels", () => { + const result = mergeFallbackIssueLabels(["valid", "", " "]); + expect(result).toEqual([MANAGED_FALLBACK_ISSUE_LABEL, "valid"]); + }); + + it("trims whitespace from label values", () => { + const result = mergeFallbackIssueLabels([" trimmed "]); + expect(result).toContain("trimmed"); + expect(result).not.toContain(" trimmed "); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeFallbackAssignees +// --------------------------------------------------------------------------- +describe("sanitizeFallbackAssignees", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null for empty input", () => { + expect(sanitizeFallbackAssignees([])).toBeNull(); + // @ts-ignore + expect(sanitizeFallbackAssignees(null)).toBeNull(); + // @ts-ignore + expect(sanitizeFallbackAssignees(undefined)).toBeNull(); + }); + + it("returns null when all assignees are filtered out", () => { + expect(sanitizeFallbackAssignees(["copilot", "COPILOT", " "])).toBeNull(); + }); + + it("removes 'copilot' (case-insensitive) from the list", () => { + const result = sanitizeFallbackAssignees(["alice", "Copilot", "bob"]); + expect(result).toEqual(["alice", "bob"]); + expect(result).not.toContain("Copilot"); + }); + + it("trims whitespace from assignee names", () => { + const result = sanitizeFallbackAssignees([" alice ", " bob "]); + expect(result).toEqual(["alice", "bob"]); + }); + + it("truncates to MAX_ASSIGNEES (5) and calls core.warning", () => { + const warnSpy = vi.spyOn(global.core, "warning"); + const assignees = ["a1", "a2", "a3", "a4", "a5", "a6"]; + const result = sanitizeFallbackAssignees(assignees); + expect(result).toHaveLength(5); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toMatch(/assignees limit exceeded/i); + }); + + it("filters out non-string entries", () => { + // @ts-ignore + const result = sanitizeFallbackAssignees([42, "alice", null, "bob"]); + expect(result).toEqual(["alice", "bob"]); + }); + + it("returns valid assignees unchanged", () => { + expect(sanitizeFallbackAssignees(["alice", "bob"])).toEqual(["alice", "bob"]); + }); +}); + +// --------------------------------------------------------------------------- +// neutralizeClosingKeywordsForIssueBody +// --------------------------------------------------------------------------- +describe("neutralizeClosingKeywordsForIssueBody", () => { + it("escapes 'Closes #N' patterns", () => { + expect(neutralizeClosingKeywordsForIssueBody("Closes #42")).toBe("Closes \\#42"); + }); + + it("escapes all supported keywords case-insensitively", () => { + const keywords = ["fix", "fixes", "fixed", "close", "closes", "closed", "resolve", "resolves", "resolved"]; + for (const kw of keywords) { + const result = neutralizeClosingKeywordsForIssueBody(`${kw} #1`); + expect(result).toBe(`${kw} \\#1`); + } + }); + + it("escapes cross-repo references like 'owner/repo#N'", () => { + const result = neutralizeClosingKeywordsForIssueBody("Resolves test-owner/test-repo#58"); + expect(result).toBe("Resolves test-owner/test-repo\\#58"); + }); + + it("does not alter text without closing keywords", () => { + const text = "This PR adds a new feature and updates docs."; + expect(neutralizeClosingKeywordsForIssueBody(text)).toBe(text); + }); + + it("does not escape #N references that are not preceded by a closing keyword", () => { + const text = "See issue #42 for context"; + expect(neutralizeClosingKeywordsForIssueBody(text)).toBe(text); + }); + + it("handles multiple closing keywords in the same body", () => { + const text = "Closes #1\nFixes #2\nResolves owner/repo#3"; + const result = neutralizeClosingKeywordsForIssueBody(text); + expect(result).toBe("Closes \\#1\nFixes \\#2\nResolves owner/repo\\#3"); + }); + + it("returns empty string/falsy values unchanged", () => { + expect(neutralizeClosingKeywordsForIssueBody("")).toBe(""); + // @ts-ignore + expect(neutralizeClosingKeywordsForIssueBody(null)).toBe(null); + // @ts-ignore + expect(neutralizeClosingKeywordsForIssueBody(undefined)).toBe(undefined); + }); +}); + +// --------------------------------------------------------------------------- +// generatePatchPreview +// --------------------------------------------------------------------------- +describe("generatePatchPreview", () => { + it("returns empty string for empty/whitespace-only input", () => { + expect(generatePatchPreview("")).toBe(""); + expect(generatePatchPreview(" ")).toBe(""); + // @ts-ignore + expect(generatePatchPreview(null)).toBe(""); + }); + + it("wraps content in a
block with a diff code fence", () => { + const result = generatePatchPreview("diff --git a/file b/file\n+changed"); + expect(result).toContain("
"); + expect(result).toContain("
"); + expect(result).toContain("```diff"); + expect(result).toContain("```"); + }); + + it("shows total line count in summary when under both limits", () => { + const patch = Array.from({ length: 5 }, (_, i) => `line${i}`).join("\n"); + const result = generatePatchPreview(patch); + expect(result).toContain("Show patch (5 lines)"); + expect(result).not.toContain("truncated"); + }); + + it("truncates and indicates truncation when over 500 lines", () => { + const patch = Array.from({ length: 600 }, (_, i) => `line${i}`).join("\n"); + const result = generatePatchPreview(patch); + expect(result).toContain("Show patch preview (500 of 600 lines)"); + expect(result).toContain("... (truncated)"); + // Content from line 500+ must not appear + expect(result).not.toContain("line500"); + }); + + it("truncates and indicates truncation when over 2000 characters", () => { + // 3 lines totaling well over 2000 chars + const longLine = "x".repeat(1000); + const patch = `${longLine}\n${longLine}\n${longLine}`; + const result = generatePatchPreview(patch); + expect(result).toContain("... (truncated)"); + }); + + it("includes patch content in the output when within limits", () => { + const patch = "diff --git a/foo b/foo\n+hello"; + const result = generatePatchPreview(patch); + expect(result).toContain("+hello"); + }); +}); + +// --------------------------------------------------------------------------- +// buildManifestProtectionCreatePrUrl +// --------------------------------------------------------------------------- +describe("buildManifestProtectionCreatePrUrl", () => { + const repoParts = { owner: "my-org", repo: "my-repo" }; + + it("builds a compare URL with title", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.com", repoParts, "main", "feature/x", "My PR Title"); + expect(url).toContain("https://github.com/my-org/my-repo/compare/main...feature/x"); + expect(url).toContain("expand=1"); + expect(url).toContain(`title=${encodeURIComponent("My PR Title")}`); + }); + + it("appends a Closes body param when fallbackIssueNumber is provided", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.com", repoParts, "main", "feature/x", "Title", 42); + expect(url).toContain(`body=${encodeURIComponent("Closes #42")}`); + }); + + it("does not append a body param when fallbackIssueNumber is not provided", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.com", repoParts, "main", "feature/x", "Title"); + expect(url).not.toContain("body="); + }); + + it("URL-encodes branch names with special characters via encodePathSegments", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.com", repoParts, "main", "feature/my branch#1", "Title"); + // encodePathSegments encodes each slash-delimited segment but preserves '/'; + // space → %20, # → %23 + expect(url).toContain("/compare/main...feature/my%20branch%231"); + }); + + it("URL-encodes the PR title", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.com", repoParts, "main", "feat", "Title with spaces & symbols"); + expect(url).toContain(encodeURIComponent("Title with spaces & symbols")); + }); + + it("uses the provided github server URL", () => { + const url = buildManifestProtectionCreatePrUrl("https://github.example.com", repoParts, "main", "feat", "T"); + expect(url.startsWith("https://github.example.com/")).toBe(true); + }); +});