Skip to content
Closed
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
77 changes: 2 additions & 75 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string>}
*/
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<string>} 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.
Expand Down Expand Up @@ -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<details><summary>${summary}</summary>\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>`;
}

/**
* 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
Expand Down
34 changes: 34 additions & 0 deletions actions/setup/js/patch_preview.cjs
Original file line number Diff line number Diff line change
@@ -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<details><summary>${summary}</summary>\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>`;
}

module.exports = { generatePatchPreview };
45 changes: 45 additions & 0 deletions actions/setup/js/patch_preview.test.cjs
Original file line number Diff line number Diff line change
@@ -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("<summary>Show patch (3 lines)</summary>");
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("<summary>Show patch preview (500 of 600 lines)</summary>");
expect(preview).toContain("... (truncated)");
});

it("truncates by character count", () => {
const patch = "x".repeat(2500);
const preview = generatePatchPreview(patch);

expect(preview).toContain("<summary>Show patch preview (1 of 1 lines)</summary>");
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(/<summary>Show patch preview \(\d+ of 800 lines\)<\/summary>/);
expect(preview).not.toContain("<summary>Show patch preview (800 of 800 lines)</summary>");
});
});
58 changes: 58 additions & 0 deletions actions/setup/js/pr_validation_helpers.cjs
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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<string>} 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,
};
38 changes: 38 additions & 0 deletions actions/setup/js/pr_validation_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
});