// @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, isWorkflowsScopeTimeoutError, withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { MAX_ASSIGNEES } = require("./constants.cjs"); const { encodePathSegments, renderTemplateFromFile } = require("./messages_core.cjs"); const { parseReportMarkdown } = require("./report_markdown.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}`; } /** * Quote a value as a single POSIX shell argument. * @param {string|number} value * @returns {string} */ function shellQuote(value) { const s = String(value); if (s.length === 0) return "''"; return `'${s.replace(/'/g, `'\\''`)}'`; } /** * 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); } /** * Runs an initial branch-push operation, retrying with exponential backoff when it fails * with a transient workflows-scope timeout. Non-transient * errors, and the error from the final exhausted attempt, are rethrown completely unmodified * so existing push-failure handling (message-based classification, fallback issue body, etc.) * behaves exactly as it did before retries were introduced. * @template T * @param {() => Promise} operation - The push operation to run * @returns {Promise} */ async function withTransientPushRetry(operation) { try { return await withRetry( operation, { ...RATE_LIMIT_RETRY_CONFIG, shouldRetry: isWorkflowsScopeTimeoutError, }, "push branch" ); } catch (error) { throw error?.originalError ?? 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); } /** * Build a patch preview with max 500 lines and 2000 chars for an issue body. * @param {string} patchContent * @returns {import("./report_ast.cjs").ReportBlock[]} */ function buildPatchPreviewBlocks(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.split("\n").length; const summary = truncated ? `Show patch preview (${shownLines} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; return [{ type: "details", summary, agentTainted: false, children: [{ type: "code_block", language: "diff", value: preview + (truncated ? "\n... (truncated)" : ""), agentTainted: true }] }]; } /** * Import a configured fallback template before substituting scalar data. * Markdown fields must be passed as typed blocks, not rendered fragments. * @param {string} templatePath * @param {import("./report_markdown.cjs").ReportContext} values * @returns {import("./report_ast.cjs").ReportBlock[]} */ function buildFallbackIssueBlocks(templatePath, values) { return parseReportMarkdown(renderTemplateFromFile(templatePath, {}), { context: values, minimumHeadingLevel: 2, allowMetadata: true, agentTainted: false }); } /** * Build typed push-error content, expanding unsupported signed-commit shapes * into cause-specific remediation. Other errors use the sanitized message. * @param {string} rawErrorMessage * @param {string} sanitizedErrorMessage * @returns {import("./report_ast.cjs").ReportBlock[]} */ function buildPushErrorBlocks(rawErrorMessage, sanitizedErrorMessage) { if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage) || !/createCommitOnBranch GraphQL mutation cannot represent/.test(rawErrorMessage)) { return [ { type: "paragraph", agentTainted: false, children: [ { type: "strong", children: [{ type: "text", value: "Original error:", agentTainted: false }], agentTainted: false }, { type: "text", value: ` ${sanitizedErrorMessage}`, agentTainted: true }, ], }, ]; } const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '.*?': ([^.]+?)(?=\. GitHub's createCommitOnBranch)/); const cause = causeMatch ? causeMatch[1].trim() : "unsupported commit shape"; return [ { type: "paragraph", agentTainted: false, children: [ { type: "strong", children: [{ type: "text", value: "Error:", agentTainted: false }], agentTainted: false }, { type: "text", value: ` Signed commit push refused — ${cause}`, agentTainted: true }, ], }, ...parseReportMarkdown("GitHub's `createCommitOnBranch` GraphQL API cannot represent:\n\n- Merge commits\n- Symlinks (mode `120000`)\n- Submodule entries (mode `160000`)\n- Executable files (mode `100755`)", { agentTainted: false }), ...parseReportMarkdown( _remediationForCause(cause) .map(line => line.replace(/^> ?/, "")) .join("\n"), { agentTainted: false } ), ]; } /** * @param {{mainBody: import("./report_ast.cjs").ReportBlock[], error: import("./report_ast.cjs").ReportBlock[], runUrl: string, artifact: "bundle" | "patch", commands: string, patchPreview?: import("./report_ast.cjs").ReportBlock[]}} options * @returns {import("./report_ast.cjs").ReportBlock[]} */ function buildPushFailureIssueBlocks({ mainBody, error, runUrl, artifact, commands, patchPreview = [] }) { return [ ...mainBody, { type: "separator", agentTainted: false }, { type: "alert", kind: "NOTE", agentTainted: false, children: [ { type: "paragraph", agentTainted: false, children: [{ type: "text", value: "This was originally intended as a pull request, but the git push operation failed.", agentTainted: false }] }, ...error, { type: "paragraph", agentTainted: false, children: [ { type: "strong", children: [{ type: "text", value: "Workflow Run:", agentTainted: false }], agentTainted: false }, { type: "text", value: " ", agentTainted: false }, { type: "link", url: runUrl, children: [{ type: "text", value: `View run details and download ${artifact} artifact`, agentTainted: false }], agentTainted: false }, ], }, { type: "paragraph", agentTainted: false, children: [ { type: "text", value: `The ${artifact} file is available in the `, agentTainted: false }, { type: "code", value: "agent", agentTainted: false }, { type: "text", value: " artifact in the workflow run linked above.", agentTainted: false }, ], }, ], }, { type: "details", summary: "Create the pull request manually", agentTainted: false, children: [{ type: "code_block", language: "sh", value: commands, agentTainted: true }] }, ...patchPreview, ]; } /** * 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] * @param {string} [headRef] * @returns {string} */ function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, fallbackIssueNumber, headRef) { const encodedBase = encodePathSegments(baseBranch); const encodedHead = encodePathSegments(headRef || 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; } /** * 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.`]; } /** * Build the shell instructions for manually recreating a branch (and later opening a PR) * from a workflow-run artifact, matching whichever transport (bundle or format-patch) * was actually used to encode the changes. Used when the automated push failed and a * human needs to recreate the branch locally before pushing and opening the PR themselves. * * The returned block intentionally stops right before the final `git push` / `gh pr create` * steps, which are appended separately by the caller since they are identical for both * transports. * * @param {object} params * @param {boolean} params.hasBundleFile - true when the artifact is a git bundle, false for a format-patch file * @param {string|number} params.runId - workflow run id, used to build the `gh run download` command * @param {string} params.artifactFileName - bundle or patch file name (relative to the artifact root) * @param {string} params.branchName - branch to create/checkout locally * @param {string} [params.baseBranch] - base branch to create the new branch from (patch transport only) * @param {string} [params.tempRef] - temporary ref used while transplanting the bundle (bundle transport only) * @returns {string} Shell instructions (no leading/trailing blank lines, no code fence) */ function buildManualBranchRecoveryCommands({ hasBundleFile, runId, artifactFileName, branchName, baseBranch, tempRef }) { if (hasBundleFile) { if (!tempRef) { throw new Error("tempRef is required for bundle manual branch recovery commands"); } const bundlePath = `/tmp/agent-${runId}/${artifactFileName}`; const targetRef = `refs/heads/${branchName}`; return [ `# Download the artifact from the workflow run`, `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Resolve the bundle source ref, fetch it into a temporary ref, then create the local branch`, `bundle_path=${shellQuote(bundlePath)}`, `temp_ref=${shellQuote(tempRef)}`, `target_ref=${shellQuote(targetRef)}`, `bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 ~ /^refs\\/heads\\// { print $2 }')`, `if [ -z "$bundle_source_ref" ]; then`, ` bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 == "HEAD" { print $2 }')`, `fi`, `if [ "$(printf '%s\\n' "$bundle_source_ref" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then`, ` echo "Expected exactly one bundle source ref, found: $bundle_source_ref" >&2`, ` exit 1`, `fi`, `git fetch "$bundle_path" "\${bundle_source_ref}:\${temp_ref}"`, `git update-ref "$target_ref" "$temp_ref"`, `git checkout ${shellQuote(branchName)}`, `# Ensure the working tree matches the updated branch`, `git reset --hard`, `# Remove the temporary bundle ref`, `git update-ref -d "$temp_ref"`, ].join("\n"); } if (!baseBranch) { throw new Error("baseBranch is required for patch manual branch recovery commands"); } return [ `# Download the artifact from the workflow run`, `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Create a new branch`, `git checkout -b ${shellQuote(branchName)} ${shellQuote(baseBranch)}`, ``, `# Apply the patch (--3way handles cross-repo patches where files may already exist)`, `git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`, ].join("\n"); } /** * Build the shell instructions for manually applying a workflow-run artifact to an * *existing* remote branch (e.g. an already-open pull request branch), matching whichever * transport (bundle or format-patch) was actually used to encode the changes. * * Unlike {@link buildManualBranchRecoveryCommands}, the returned block is self-contained: * it includes the final `git push`, since there is no separate PR-creation step for this flow. * * @param {object} params * @param {boolean} params.hasBundleFile - true when the artifact is a git bundle, false for a format-patch file * @param {string|number} params.runId - workflow run id, used to build the `gh run download` command * @param {string} params.artifactFileName - bundle or patch file name (relative to the artifact root) * @param {string} params.branchName - existing remote branch to update * @param {string} [params.branchRemote] - remote name or URL containing the existing branch * @returns {string} Shell instructions (no leading/trailing blank lines, no code fence) */ function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName, branchName, branchRemote = "origin" }) { const bundlePath = `/tmp/agent-${runId}/${artifactFileName}`; const pushRef = `HEAD:refs/heads/${branchName}`; if (hasBundleFile) { return [ `# Download the artifact from the workflow run`, `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Fetch the bundle into a temporary ref, then fast-forward the branch`, `bundle_path=${shellQuote(bundlePath)}`, `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, `git checkout -B ${shellQuote(branchName)} FETCH_HEAD`, `bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 ~ /^refs\\/heads\\// { print $2 }')`, `if [ -z "$bundle_source_ref" ]; then`, ` bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 == "HEAD" { print $2 }')`, `fi`, `if [ "$(printf '%s\\n' "$bundle_source_ref" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then`, ` echo "Expected exactly one bundle source ref, found: $bundle_source_ref" >&2`, ` exit 1`, `fi`, `git fetch "$bundle_path" "\${bundle_source_ref}:refs/bundles/manual-apply"`, `git reset --hard refs/bundles/manual-apply`, `git update-ref -d refs/bundles/manual-apply`, `git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`, ].join("\n"); } return [ `# Download the artifact from the workflow run`, `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Apply the patch to the pull request branch`, `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, `git checkout -B ${shellQuote(branchName)} FETCH_HEAD`, `git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`, `git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`, ].join("\n"); } module.exports = { MANAGED_FALLBACK_ISSUE_LABEL, LABEL_MAX_RETRIES, LABEL_INITIAL_DELAY_MS, LABEL_MAX_DELAY_MS, summarizeListForLog, createBundleTempRef, shellQuote, isLabelTransientError, withTransientPushRetry, parseAllowedBaseBranches, isBaseBranchAllowed, parseStringListConfig, mergeFallbackIssueLabels, sanitizeFallbackAssignees, neutralizeClosingKeywordsForIssueBody, buildPatchPreviewBlocks, buildFallbackIssueBlocks, buildPushErrorBlocks, buildPushFailureIssueBlocks, buildManifestProtectionCreatePrUrl, buildManualBranchRecoveryCommands, buildManualBranchApplyCommands, };