From 50050452117e3b961d97774433f5496fe4ede8d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:15:57 +0000 Subject: [PATCH 1/3] Improve push signed commits merge error rendering in fallback issue bodies Add `buildPushErrorSection` helper that detects signed-commits refusal errors and expands them into structured markdown with cause, a list of unsupported commit shapes, and remediation steps (git rebase / signed- commits: false), instead of rendering as a wall of text. Use it in both the bundle-path and patch-path fallback issue bodies in create_pull_request.cjs, and add 8 unit tests in the helpers test file. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 7 +- .../setup/js/create_pull_request_helpers.cjs | 49 +++++++++++++ .../js/create_pull_request_helpers.test.cjs | 68 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index e51f74db950..2ba30002298 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -57,6 +57,7 @@ const { generatePatchPreview, buildManifestProtectionCreatePrUrl, renderManifestProtectionFallbackBody, + buildPushErrorSection, } = require("./create_pull_request_helpers.cjs"); /** @@ -1736,6 +1737,7 @@ async function main(config = {}) { const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases }) .replace(/\s+/g, " ") .trim(); + const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage); const fallbackBody = `${issueSafeBody} --- @@ -1743,7 +1745,7 @@ async function main(config = {}) { > [!NOTE] > This was originally intended as a pull request, but the git push operation failed. > -> **Original error:** ${pushFailureMessage} +${pushErrorSection} > > **Workflow Run:** [View run details and download bundle artifact](${runUrl}) > @@ -2101,6 +2103,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases }) .replace(/\s+/g, " ") .trim(); + const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage); const fallbackBody = `${issueSafeBody} --- @@ -2108,7 +2111,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead > [!NOTE] > This was originally intended as a pull request, but the git push operation failed. > -> **Original error:** ${pushFailureMessage} +${pushErrorSection} > > **Workflow Run:** [View run details and download patch artifact](${runUrl}) > diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index a00b00e3700..d6b36949c5e 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -236,6 +236,54 @@ function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, return createPrUrl; } +/** + * Build a formatted markdown error section for a push-failure note block. + * + * When the raw error message matches the `pushSignedCommits: refusing unsigned push` + * pattern the section is expanded into a structured block that names the cause and + * lists remediation steps. For all other errors it degrades gracefully to a single + * `**Original error:**` line using the sanitised, whitespace-collapsed form. + * + * The returned string contains one or more blockquote lines (lines starting with `>`) + * with no leading or trailing blank lines. It is designed to be embedded directly + * inside a `> [!NOTE]` block in a GitHub issue body, for example: + * + * ``` + * > [!NOTE] + * > Intro sentence. + * > + * ${buildPushErrorSection(rawMsg, sanitizedMsg)} + * > + * > **Workflow Run:** [details](url) + * ``` + * + * @param {string} rawErrorMessage - Unprocessed error message; used for pattern detection and cause extraction. + * @param {string} sanitizedErrorMessage - Sanitised, whitespace-collapsed message; used as the fallback line. + * @returns {string} One or more blockquote lines for the error section (no leading/trailing blank lines). + */ +function buildPushErrorSection(rawErrorMessage, sanitizedErrorMessage) { + if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage)) { + return `> **Original error:** ${sanitizedErrorMessage}`; + } + + // Extract the specific cause (e.g. "merge commit detected") embedded in the raw message. + const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '[^']*': ([^.]+)/); + const cause = causeMatch ? causeMatch[1].trim() : "unsupported commit shape"; + + return [ + `> **Error:** Signed commit push refused — ${cause}`, + `>`, + `> GitHub's \`createCommitOnBranch\` GraphQL API cannot represent:`, + `> - Merge commits`, + `> - Symlinks (mode \`120000\`)`, + `> - Submodule entries (mode \`160000\`)`, + `> - Executable files (mode \`100755\`)`, + `>`, + `> **To fix:** Use \`git rebase\` instead of \`git merge\` to incorporate upstream changes,`, + `> or set \`signed-commits: false\` in your workflow step if signed commits are not required.`, + ].join("\n"); +} + /** * Renders protected-files fallback issue body with a prefilled compare URL. * @param {string} mainBodyContent @@ -271,4 +319,5 @@ module.exports = { generatePatchPreview, buildManifestProtectionCreatePrUrl, renderManifestProtectionFallbackBody, + buildPushErrorSection, }; diff --git a/actions/setup/js/create_pull_request_helpers.test.cjs b/actions/setup/js/create_pull_request_helpers.test.cjs index 3bfff75f5ae..d438cc8d0ce 100644 --- a/actions/setup/js/create_pull_request_helpers.test.cjs +++ b/actions/setup/js/create_pull_request_helpers.test.cjs @@ -31,6 +31,7 @@ const { neutralizeClosingKeywordsForIssueBody, generatePatchPreview, buildManifestProtectionCreatePrUrl, + buildPushErrorSection, } = require("./create_pull_request_helpers.cjs"); describe("create_pull_request_helpers - constants", () => { @@ -480,3 +481,70 @@ describe("buildManifestProtectionCreatePrUrl", () => { expect(url.startsWith("https://github.example.com/")).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// buildPushErrorSection +// --------------------------------------------------------------------------- +describe("buildPushErrorSection", () => { + const SIGNED_COMMITS_RAW = + "pushSignedCommits: refusing unsigned push for branch 'my-branch': merge commit detected. " + + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " + + "submodule entries (mode 160000), or executable bits (mode 100755). " + + "Rewrite the commits to use only regular files (mode 100644) with no merge commits, " + + "or set signed-commits: false if the repository does not require signed commits."; + const SIGNED_COMMITS_SANITIZED = SIGNED_COMMITS_RAW.replace(/\s+/g, " ").trim(); + + it("returns a generic fallback line for non-signed-commits errors", () => { + const raw = "Some other push error occurred"; + const sanitized = "Some other push error occurred"; + const result = buildPushErrorSection(raw, sanitized); + expect(result).toBe(`> **Original error:** ${sanitized}`); + }); + + it("returns structured markdown for a signed-commits refusal", () => { + const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); + expect(result).toContain("> **Error:** Signed commit push refused"); + expect(result).not.toContain("Original error:"); + }); + + it("extracts the cause from the error message", () => { + const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); + expect(result).toContain("merge commit detected"); + }); + + it("lists all unsupported commit shapes", () => { + const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); + expect(result).toContain("Merge commits"); + expect(result).toContain("Symlinks"); + expect(result).toContain("Submodule entries"); + expect(result).toContain("Executable files"); + }); + + it("includes remediation steps mentioning git rebase and signed-commits: false", () => { + const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); + expect(result).toContain("git rebase"); + expect(result).toContain("signed-commits: false"); + }); + + it("produces only blockquote lines (each line starts with '>')", () => { + const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); + const lines = result.split("\n"); + for (const line of lines) { + expect(line.startsWith(">")).toBe(true); + } + }); + + it("uses a generic cause when the branch name pattern does not match", () => { + const malformed = "pushSignedCommits: refusing unsigned push: no colon before cause"; + const result = buildPushErrorSection(malformed, malformed); + expect(result).toContain("unsupported commit shape"); + }); + + it("handles submodule change detected cause", () => { + const raw = + "pushSignedCommits: refusing unsigned push for branch 'feat': submodule change detected. " + + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, ..."; + const result = buildPushErrorSection(raw, raw); + expect(result).toContain("submodule change detected"); + }); +}); From dfb9a95b8f0ad5d87f5a320b89d5fbe43db2f94c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:07:27 +0000 Subject: [PATCH 2/3] fix(buildPushErrorSection): restrict detection to unsupported-shape boilerplate, fix apostrophe regex, add cause-specific remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Issue 1: tighten detection to require 'createCommitOnBranch GraphQL mutation cannot represent' boilerplate so PushSignedCommitsPolicyViolation errors (max-patch, file-protection) still use the sanitized original-error fallback - Issue 2: change '[^']*' to lazy '.*?' and anchor cause extraction to the boilerplate with (?=\. GitHub's createCommitOnBranch) so branch names containing apostrophes are matched correctly - Issue 3: extract _remediationForCause() with cause-specific guidance: merge commit → git rebase, submodule → remove submodule entry, symlink → remove symlink, default → shape-neutral rewrite - Tests: add policy-violation regression test, apostrophe-branch test, symlink remediation test; update submodule test to assert cause-specific text and absence of git rebase; update malformed test to use boilerplate-present-but-no-branch-pattern input Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/create_pull_request_helpers.cjs | 36 ++++++++++++-- .../js/create_pull_request_helpers.test.cjs | 48 +++++++++++++++++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index d6b36949c5e..666320b3677 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -262,14 +262,21 @@ function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, * @returns {string} One or more blockquote lines for the error section (no leading/trailing blank lines). */ function buildPushErrorSection(rawErrorMessage, sanitizedErrorMessage) { - if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage)) { + // Only render the structured block for PushSignedCommitsUnsupportedShape errors, + // identified by the unique "cannot represent" boilerplate text. PushSignedCommitsPolicyViolation + // errors also start with "refusing unsigned push" but lack this boilerplate, so they fall + // through to the sanitised original-error fallback. + if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage) || !/createCommitOnBranch GraphQL mutation cannot represent/.test(rawErrorMessage)) { return `> **Original error:** ${sanitizedErrorMessage}`; } - // Extract the specific cause (e.g. "merge commit detected") embedded in the raw message. - const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '[^']*': ([^.]+)/); + // Extract the specific cause (e.g. "merge commit detected") anchored to the boilerplate. + // Using '.*?' (lazy) instead of '[^']*' (restricted) handles apostrophes in branch names. + const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '.*?': ([^.]+?)(?=\. GitHub's createCommitOnBranch)/); const cause = causeMatch ? causeMatch[1].trim() : "unsupported commit shape"; + const remediationLines = _remediationForCause(cause); + return [ `> **Error:** Signed commit push refused — ${cause}`, `>`, @@ -279,11 +286,30 @@ function buildPushErrorSection(rawErrorMessage, sanitizedErrorMessage) { `> - Submodule entries (mode \`160000\`)`, `> - Executable files (mode \`100755\`)`, `>`, - `> **To fix:** Use \`git rebase\` instead of \`git merge\` to incorporate upstream changes,`, - `> or set \`signed-commits: false\` in your workflow step if signed commits are not required.`, + ...remediationLines, ].join("\n"); } +/** + * Returns cause-specific remediation lines for a signed-commits push refusal. + * @param {string} cause - The extracted cause string from the error message. + * @returns {string[]} Two blockquote lines: the fix instruction and the unsigned-push alternative. + */ +function _remediationForCause(cause) { + const c = cause.toLowerCase(); + let rewriteInstruction; + if (c.includes("merge commit")) { + rewriteInstruction = "Use `git rebase` instead of `git merge` to rewrite the commit history without merge commits"; + } else if (c.includes("submodule")) { + rewriteInstruction = "Remove the submodule entry from the commit history"; + } else if (c.includes("symlink")) { + rewriteInstruction = "Remove the symlink from the commit history"; + } else { + rewriteInstruction = "Rewrite the commits to use only regular files (mode `100644`) with no merge commits or special entries"; + } + return [`> **To fix:** ${rewriteInstruction},`, `> or set \`signed-commits: false\` in your workflow step if signed commits are not required.`]; +} + /** * Renders protected-files fallback issue body with a prefilled compare URL. * @param {string} mainBodyContent diff --git a/actions/setup/js/create_pull_request_helpers.test.cjs b/actions/setup/js/create_pull_request_helpers.test.cjs index d438cc8d0ce..02ea0fd96ef 100644 --- a/actions/setup/js/create_pull_request_helpers.test.cjs +++ b/actions/setup/js/create_pull_request_helpers.test.cjs @@ -520,7 +520,7 @@ describe("buildPushErrorSection", () => { expect(result).toContain("Executable files"); }); - it("includes remediation steps mentioning git rebase and signed-commits: false", () => { + it("includes merge-commit-specific remediation (git rebase) and signed-commits: false", () => { const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED); expect(result).toContain("git rebase"); expect(result).toContain("signed-commits: false"); @@ -534,17 +534,55 @@ describe("buildPushErrorSection", () => { } }); - it("uses a generic cause when the branch name pattern does not match", () => { - const malformed = "pushSignedCommits: refusing unsigned push: no colon before cause"; + it("falls back to generic error line for PushSignedCommitsPolicyViolation (no cannot-represent boilerplate)", () => { + const raw = "pushSignedCommits: refusing unsigned push for branch 'my-branch': " + "E003: Signed-commit payload exceeds max-patch-files (10). Synthesized payload touches 15 file(s): a.txt, b.txt."; + const sanitized = raw.replace(/\s+/g, " ").trim(); + const result = buildPushErrorSection(raw, sanitized); + expect(result).toBe(`> **Original error:** ${sanitized}`); + }); + + it("uses a generic cause when the cause pattern does not match", () => { + // Has the boilerplate but lacks the standard 'for branch ...' extraction pattern. + const malformed = "pushSignedCommits: refusing unsigned push without branch info. " + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits."; const result = buildPushErrorSection(malformed, malformed); expect(result).toContain("unsupported commit shape"); }); - it("handles submodule change detected cause", () => { + it("extracts cause correctly from branch name containing an apostrophe", () => { + const raw = + "pushSignedCommits: refusing unsigned push for branch 'it's-great': merge commit detected. " + + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " + + "submodule entries (mode 160000), or executable bits (mode 100755). " + + "Rewrite the commits to use only regular files (mode 100644) with no merge commits, " + + "or set signed-commits: false if the repository does not require signed commits."; + const result = buildPushErrorSection(raw, raw); + expect(result).toContain("merge commit detected"); + expect(result).not.toContain("unsupported commit shape"); + }); + + it("handles submodule change detected cause with submodule-specific remediation", () => { const raw = "pushSignedCommits: refusing unsigned push for branch 'feat': submodule change detected. " + - "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, ..."; + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " + + "submodule entries (mode 160000), or executable bits (mode 100755). " + + "Rewrite the commits to use only regular files (mode 100644) with no merge commits, " + + "or set signed-commits: false if the repository does not require signed commits."; const result = buildPushErrorSection(raw, raw); expect(result).toContain("submodule change detected"); + expect(result).toContain("Remove the submodule entry"); + expect(result).not.toContain("git rebase"); + }); + + it("handles symlink cause with symlink-specific remediation", () => { + const raw = + "pushSignedCommits: refusing unsigned push for branch 'feat': symlink file mode requires git push fallback. " + + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " + + "submodule entries (mode 160000), or executable bits (mode 100755). " + + "Rewrite the commits to use only regular files (mode 100644) with no merge commits, " + + "or set signed-commits: false if the repository does not require signed commits."; + const result = buildPushErrorSection(raw, raw); + expect(result).toContain("symlink file mode requires git push fallback"); + expect(result).toContain("Remove the symlink"); + expect(result).not.toContain("git rebase"); }); }); From 3add5d736316dbfd8ac9e146a90a5900dc6ec4a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:08:23 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20correct=20spelling=20of=20sanitised?= =?UTF-8?q?=20=E2=86=92=20sanitized=20in=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request_helpers.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index 666320b3677..1257abcaa8c 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -265,7 +265,7 @@ function buildPushErrorSection(rawErrorMessage, sanitizedErrorMessage) { // Only render the structured block for PushSignedCommitsUnsupportedShape errors, // identified by the unique "cannot represent" boilerplate text. PushSignedCommitsPolicyViolation // errors also start with "refusing unsigned push" but lack this boilerplate, so they fall - // through to the sanitised original-error fallback. + // through to the sanitized original-error fallback. if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage) || !/createCommitOnBranch GraphQL mutation cannot represent/.test(rawErrorMessage)) { return `> **Original error:** ${sanitizedErrorMessage}`; }