diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 43adbcfbe32..8f7b3991416 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -24,6 +24,7 @@ const { normalizeBranchName } = require("./normalize_branch_name.cjs"); const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"); const { createCheckoutManager } = require("./dynamic_checkout.cjs"); const { closeOlderPullRequests } = require("./close_older_pull_requests.cjs"); +const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); @@ -747,13 +748,20 @@ async function main(config = {}) { // Track how many items we've processed for max limit let processedCount = 0; - // Create checkout manager for multi-repo support + // Multi-repo support: checkout mapping from compile-time checkout: configs. + // When target-repo: "*" is configured and repos are checked out into subdirectories, + // the checkout_mapping tells us where each repo lives on disk. + const checkoutMapping = config.checkout_mapping || null; + + // Create checkout manager for multi-repo support (fallback when no checkout_mapping) // Token is available via GITHUB_TOKEN environment variable (set by the workflow job) const checkoutToken = process.env.GITHUB_TOKEN; const checkoutManager = checkoutToken ? createCheckoutManager(checkoutToken, { defaultBaseBranch: configBaseBranch }) : null; // Log multi-repo support status - if (allowedRepos.size > 0 && checkoutManager) { + if (checkoutMapping) { + core.info(`Multi-repo support enabled via checkout mapping: ${Object.keys(checkoutMapping).length} repo(s) mapped`); + } else if (allowedRepos.size > 0 && checkoutManager) { core.info(`Multi-repo support enabled: can switch between repos in allowed-repos list`); } else if (allowedRepos.size > 0 && !checkoutManager) { core.warning(`Multi-repo support disabled: GITHUB_TOKEN not available for dynamic checkout`); @@ -866,172 +874,306 @@ async function main(config = {}) { } } - // Multi-repo support: Switch checkout to target repo if different from current - // This enables creating PRs in multiple repos from a single workflow run - if (checkoutManager && itemRepo) { - const switchResult = await checkoutManager.switchTo(itemRepo, { baseBranch }); - if (!switchResult.success) { - core.warning(`Failed to switch to repository ${itemRepo}: ${switchResult.error}`); + // Multi-repo support: Switch to the correct working directory for the target repo. + // Priority: + // 1. checkout_mapping: repos checked out into subdirectories (wildcard target-repo) + // 2. findRepoCheckout: scan workspace for repo checkouts (subdirectory discovery) + // 3. createCheckoutManager: dynamic git remote switching (legacy allowed-repos) + let repoCwd = undefined; + const workflowRepo = process.env.GITHUB_REPOSITORY || ""; + const isTargetingDifferentRepo = itemRepo && itemRepo.toLowerCase() !== workflowRepo.toLowerCase(); + + if (isTargetingDifferentRepo && checkoutMapping) { + // Use checkout mapping to find the subdirectory for this repo + const targetLower = itemRepo.toLowerCase(); + const mappedPath = checkoutMapping[targetLower]; + if (mappedPath) { + const absolutePath = require("path").resolve(process.env.GITHUB_WORKSPACE || process.cwd(), mappedPath); + repoCwd = absolutePath; + core.info(`Using checkout mapping: ${itemRepo} -> ${mappedPath}`); + } else { + // Repo not in mapping; try scanning workspace + const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] }); + if (checkoutResult.success) { + repoCwd = checkoutResult.path; + core.info(`Found checkout for ${itemRepo} via workspace scan at: ${repoCwd}`); + } else { + core.warning(`Repository ${itemRepo} not found in checkout mapping or workspace`); + return { + success: false, + error: `Repository '${itemRepo}' not found in workspace. Configure it in checkout: with a path to enable multi-repo PR creation.`, + }; + } + } + } else if (isTargetingDifferentRepo && !checkoutMapping) { + // Legacy path: use findRepoCheckout first, fall back to dynamic checkout manager + const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] }); + if (checkoutResult.success) { + repoCwd = checkoutResult.path; + core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`); + } else if (checkoutManager) { + // Fall back to dynamic remote switching (changes git remote in workspace root) + const switchResult = await checkoutManager.switchTo(itemRepo, { baseBranch }); + if (!switchResult.success) { + core.warning(`Failed to switch to repository ${itemRepo}: ${switchResult.error}`); + return { + success: false, + error: `Failed to checkout repository ${itemRepo}: ${switchResult.error}`, + }; + } + if (switchResult.switched) { + core.info(`Switched checkout to repository: ${itemRepo} (dynamic remote switch)`); + } + } else { + // No checkout found and no checkout manager available. + // Proceed without repoCwd — if a patch/bundle is required it will fail at apply time; + // if allow_empty is set the PR can still be created via API alone. + core.warning(`Repository '${itemRepo}' not found in workspace and no checkout manager available; proceeding from workspace root`); + } + } + + // If we have a repoCwd (subdirectory checkout), change process cwd for git operations + const originalCwd = process.cwd(); + if (repoCwd) { + try { + process.chdir(repoCwd); + } catch (chdirError) { + return { success: false, error: `Failed to change working directory to '${repoCwd}': ${getErrorMessage(chdirError)}` }; + } + core.info(`Changed working directory to: ${repoCwd}`); + } + + try { + // SECURITY: Sanitize dynamically resolved base branch to prevent shell injection + const originalBaseBranch = baseBranch; + baseBranch = normalizeBranchName(baseBranch); + if (!baseBranch) { + return { + success: false, + error: `Invalid base branch: sanitization resulted in empty string (original: "${originalBaseBranch}")`, + }; + } + if (originalBaseBranch !== baseBranch) { return { success: false, - error: `Failed to checkout repository ${itemRepo}: ${switchResult.error}`, + error: `Invalid base branch: contains invalid characters (original: "${originalBaseBranch}", normalized: "${baseBranch}")`, }; } - if (switchResult.switched) { - core.info(`Switched checkout to repository: ${itemRepo}`); + core.info(`Base branch for ${itemRepo}: ${baseBranch}`); + + // Check if patch file exists and has valid content. + // Always require patch content for policy enforcement, even when bundle transport + // is used for apply-time commit transport. + const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath)); + const applyTransport = hasBundleFile ? "bundle" : "patch"; + core.info(`Apply transport mode: ${applyTransport} (bundle file present: ${hasBundleFile})`); + if (bundleFilePath && !hasBundleFile) { + core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`); } - } + const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath)); + if (!hasPatchFile) { + // If allow-empty is enabled, we can proceed without a patch file + if (allowEmpty) { + core.info("No patch file found, but allow-empty is enabled - will create empty PR"); + } else { + const message = "No patch file found - cannot create pull request without changes"; + + // If in staged mode, still show preview + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ⚠️ No patch file found\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + + // Write to step summary + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (no patch file)"); + return { success: true, staged: true }; + } - // SECURITY: Sanitize dynamically resolved base branch to prevent shell injection - const originalBaseBranch = baseBranch; - baseBranch = normalizeBranchName(baseBranch); - if (!baseBranch) { - return { - success: false, - error: `Invalid base branch: sanitization resulted in empty string (original: "${originalBaseBranch}")`, - }; - } - if (originalBaseBranch !== baseBranch) { - return { - success: false, - error: `Invalid base branch: contains invalid characters (original: "${originalBaseBranch}", normalized: "${baseBranch}")`, - }; - } - core.info(`Base branch for ${itemRepo}: ${baseBranch}`); - - // Check if patch file exists and has valid content. - // Always require patch content for policy enforcement, even when bundle transport - // is used for apply-time commit transport. - const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath)); - const applyTransport = hasBundleFile ? "bundle" : "patch"; - core.info(`Apply transport mode: ${applyTransport} (bundle file present: ${hasBundleFile})`); - if (bundleFilePath && !hasBundleFile) { - core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`); - } - const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath)); - if (!hasPatchFile) { - // If allow-empty is enabled, we can proceed without a patch file - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + return { success: false, error: message }; + + case "ignore": + // Silent success - no console output + return { success: false, skipped: true }; + + case "warn": + default: + core.warning(message); + return { success: false, error: message, skipped: true }; + } + } + } + + let patchContent = ""; + let isEmpty = true; + if (hasPatchFile) { + patchContent = fs.readFileSync(patchFilePath, "utf8"); + isEmpty = !patchContent || !patchContent.trim(); + } - // If in staged mode, still show preview + // Enforce max limits on patch before processing. + // Count files once here so the catch block can reuse the value without re-parsing. + const patchFileCount = countUniquePatchFiles(patchContent); + try { + enforcePullRequestLimits(patchContent, maxFiles); + } catch (error) { + const errorMessage = getErrorMessage(error); + core.warning(`Pull request limit exceeded: ${errorMessage}`); + + // In staged mode, show a preview instead of performing API side effects if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; + summaryContent += `**Status:** ⚠️ Patch file limit exceeded\n\n`; + summaryContent += `**Message:** ${errorMessage}\n\n`; - // Write to step summary await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); + core.info("📝 Pull request creation preview written to step summary (file limit exceeded)"); return { success: true, staged: true }; } - switch (ifNoChanges) { - case "error": - return { success: false, error: message }; + if (!fallbackAsIssue) { + return { success: false, error: errorMessage }; + } - case "ignore": - // Silent success - no console output - return { success: false, skipped: true }; + // Surface the limit error in a fallback issue so it appears in the agent failure + // issue/comment thread and the workflow operator knows exactly how to fix it. + const rawFallbackTitle = pullRequestItem.title?.trim() || "Agent Output"; + const fallbackTitle = applyTitlePrefix(sanitizeTitle(rawFallbackTitle, titlePrefix), titlePrefix); + const fallbackLabels = mergeFallbackIssueLabels(configFallbackLabels.length > 0 ? configFallbackLabels : envLabels); + const fallbackTemplatePath = getPromptPath("e003_file_limit_fallback.md"); + const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, { + error_message: errorMessage, + suggested_limit: patchFileCount, + }); - case "warn": - default: - core.warning(message); - return { success: false, error: message, skipped: true }; + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, fallbackTitle, fallbackBody, fallbackLabels, configAssignees); + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + return { + success: true, + fallback_used: true, + issue_number: issue.number, + issue_url: issue.html_url, + }; + } catch (issueError) { + const combinedError = `Pull request limit exceeded and failed to create fallback issue. Limit error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`; + core.error(combinedError); + return { success: false, error: combinedError }; } } - } - let patchContent = ""; - let isEmpty = true; - if (hasPatchFile) { - patchContent = fs.readFileSync(patchFilePath, "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + // If allow-empty is enabled, ignore patch errors and proceed + if (allowEmpty) { + core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); + patchContent = ""; + isEmpty = true; + } else { + const message = "Patch file contains error message - cannot create pull request without changes"; + + // If in staged mode, still show preview + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + + // Write to step summary + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (patch error)"); + return { success: true, staged: true }; + } - // Enforce max limits on patch before processing. - // Count files once here so the catch block can reuse the value without re-parsing. - const patchFileCount = countUniquePatchFiles(patchContent); - try { - enforcePullRequestLimits(patchContent, maxFiles); - } catch (error) { - const errorMessage = getErrorMessage(error); - core.warning(`Pull request limit exceeded: ${errorMessage}`); + switch (ifNoChanges) { + case "error": + return { success: false, error: message }; - // In staged mode, show a preview instead of performing API side effects - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file limit exceeded\n\n`; - summaryContent += `**Message:** ${errorMessage}\n\n`; + case "ignore": + // Silent success - no console output + return { success: false, skipped: true }; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (file limit exceeded)"); - return { success: true, staged: true }; + case "warn": + default: + core.warning(message); + return { success: false, error: message, skipped: true }; + } + } } - if (!fallbackAsIssue) { - return { success: false, error: errorMessage }; - } + // Validate patch size (unless empty) + if (!isEmpty) { + // maxSizeKb is already extracted from config at the top + const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); + const patchSizeKb = Math.ceil(patchSizeBytes / 1024); + + core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); + + if (patchSizeKb > maxSizeKb) { + const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; + + // If in staged mode, still show preview with error + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + + // Write to step summary + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (patch size error)"); + return { success: true, staged: true }; + } - // Surface the limit error in a fallback issue so it appears in the agent failure - // issue/comment thread and the workflow operator knows exactly how to fix it. - const rawFallbackTitle = pullRequestItem.title?.trim() || "Agent Output"; - const fallbackTitle = applyTitlePrefix(sanitizeTitle(rawFallbackTitle, titlePrefix), titlePrefix); - const fallbackLabels = mergeFallbackIssueLabels(configFallbackLabels.length > 0 ? configFallbackLabels : envLabels); - const fallbackTemplatePath = getPromptPath("e003_file_limit_fallback.md"); - const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, { - error_message: errorMessage, - suggested_limit: patchFileCount, - }); + return { success: false, error: message }; + } - try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, fallbackTitle, fallbackBody, fallbackLabels, configAssignees); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - return { - success: true, - fallback_used: true, - issue_number: issue.number, - issue_url: issue.html_url, - }; - } catch (issueError) { - const combinedError = `Pull request limit exceeded and failed to create fallback issue. Limit error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`; - core.error(combinedError); - return { success: false, error: combinedError }; + core.info("Patch size validation passed"); } - } - - // Check for actual error conditions (but allow empty patches as valid noop) - if (patchContent.includes("Failed to generate patch")) { - // If allow-empty is enabled, ignore patch errors and proceed - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - - // If in staged mode, still show preview - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return { success: true, staged: true }; + // Check file protection: allowlist (strict) or protected-files policy. + /** @type {string[] | null} Protected files that trigger fallback-to-issue handling */ + let manifestProtectionFallback = null; + /** @type {string[] | null} Protected files that trigger request-review handling */ + let manifestProtectionRequestReview = null; + /** @type {unknown} */ + let manifestProtectionPushFailedError = null; + if (!isEmpty) { + const protection = checkFileProtection(patchContent, { + ...config, + protected_files_policy: config.protected_files_policy ?? "request_review", + }); + if (protection.action === "deny") { + const filesStr = protection.files.join(", "); + const message = + protection.source === "allowlist" + ? `Cannot create pull request: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.` + : `Cannot create pull request: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`; + core.error(message); + return { success: false, error: message }; } + if (protection.action === "fallback") { + manifestProtectionFallback = protection.files; + core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`); + } + if (protection.action === "request_review") { + manifestProtectionRequestReview = protection.files; + core.warning(`Protected file protection triggered (request_review): ${protection.files.join(", ")}. Will create pull request with caution and request-changes review.`); + } + } + + if (isEmpty && !isStaged && !allowEmpty) { + const message = "Patch file is empty - no changes to apply (noop operation)"; switch (ifNoChanges) { case "error": - return { success: false, error: message }; + return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" }; case "ignore": // Silent success - no console output @@ -1043,485 +1185,405 @@ async function main(config = {}) { return { success: false, error: message, skipped: true }; } } - } - // Validate patch size (unless empty) - if (!isEmpty) { - // maxSizeKb is already extracted from config at the top - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); + if (!isEmpty) { + core.info("Patch content validation passed"); + } else if (allowEmpty) { + core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); + } else { + core.info("Patch file is empty - processing noop operation"); + } - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); + // If in staged mode, emit step summary instead of creating PR + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; + summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; + summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; + summaryContent += `**Base:** ${baseBranch}\n\n`; - // If in staged mode, still show preview with error - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; + if (pullRequestItem.body) { + summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; + } - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return { success: true, staged: true }; + if (patchFilePath && fs.existsSync(patchFilePath)) { + const patchStats = fs.readFileSync(patchFilePath, "utf8"); + if (patchStats.trim()) { + summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; + } else { + summaryContent += `**Changes:** No changes (empty patch)\n\n`; + } } - return { success: false, error: message }; + // Write to step summary + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary"); + return { success: true, staged: true }; } - core.info("Patch size validation passed"); - } - - // Check file protection: allowlist (strict) or protected-files policy. - /** @type {string[] | null} Protected files that trigger fallback-to-issue handling */ - let manifestProtectionFallback = null; - /** @type {string[] | null} Protected files that trigger request-review handling */ - let manifestProtectionRequestReview = null; - /** @type {unknown} */ - let manifestProtectionPushFailedError = null; - if (!isEmpty) { - const protection = checkFileProtection(patchContent, { - ...config, - protected_files_policy: config.protected_files_policy ?? "request_review", - }); - if (protection.action === "deny") { - const filesStr = protection.files.join(", "); - const message = - protection.source === "allowlist" - ? `Cannot create pull request: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.` - : `Cannot create pull request: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`; - core.error(message); - return { success: false, error: message }; + // Extract title, body, and branch from the JSON item + let title = pullRequestItem.title.trim(); + let processedBody = pullRequestItem.body; + + // Replace temporary ID references in the body with resolved issue/PR numbers + // This allows PRs to reference issues created earlier in the same workflow + // by using temporary IDs like #aw_123abc456def + if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) { + // Convert object to Map for compatibility with replaceTemporaryIdReferences + const tempIdMap = new Map(Object.entries(resolvedTemporaryIds)); + processedBody = replaceTemporaryIdReferences(processedBody, tempIdMap, itemRepo); + core.info(`Resolved ${tempIdMap.size} temporary ID references in PR body`); } - if (protection.action === "fallback") { - manifestProtectionFallback = protection.files; - core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`); - } - if (protection.action === "request_review") { - manifestProtectionRequestReview = protection.files; - core.warning(`Protected file protection triggered (request_review): ${protection.files.join(", ")}. Will create pull request with caution and request-changes review.`); - } - } - - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" }; + // Remove duplicate title from description if it starts with a header matching the title + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + + // Sanitize body content to neutralize @mentions, URLs, and other security risks + processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases }); + + // Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present. + // This ensures the triggering issue is auto-closed when the PR is merged. + // Agents are instructed to include this but don't reliably do so. + // This behavior can be disabled by setting auto-close-issue: false in the workflow config. + if (triggeringIssueNumber && autoCloseIssue) { + const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody); + if (!hasClosingKeyword) { + processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`; + core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`); + } + } else if (triggeringIssueNumber && !autoCloseIssue) { + core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`); + } - case "ignore": - // Silent success - no console output - return { success: false, skipped: true }; + let bodyLines = processedBody.split("\n"); + let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; + // Preserve the original agent branch name for bundle transport (the bundle was created + // using this branch name as the refs/heads ref inside the bundle file). + const originalAgentBranch = branchName; + const randomHex = crypto.randomBytes(8).toString("hex"); + + // SECURITY: Sanitize branch name to prevent shell injection (CWE-78) + // Branch names from user input must be normalized before use in git commands. + // When preserve-branch-name is disabled (default), a random salt suffix is + // appended to avoid collisions. + if (branchName) { + const originalBranchName = branchName; + branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex); + + // Validate it's not empty after normalization + if (!branchName) { + throw new Error(`Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")`); + } - case "warn": - default: - core.warning(message); - return { success: false, error: message, skipped: true }; + if (preserveBranchName) { + core.info(`Using branch name from JSONL without salt suffix (preserve-branch-name enabled): ${branchName}`); + } else { + core.info(`Using branch name from JSONL with added salt: ${branchName}`); + } + if (originalBranchName !== branchName) { + core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`); + } } - } - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } + // If no title was found, use a default + if (!title) { + title = "Agent Output"; + } - // If in staged mode, emit step summary instead of creating PR - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + // Sanitize title for Unicode security and remove any duplicate prefixes + title = sanitizeTitle(title, titlePrefix); + + // Apply title prefix (only if it doesn't already exist) + title = applyTitlePrefix(title, titlePrefix); + + // Add AI disclaimer with workflow name and run url + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; + const workflowId = process.env.GH_AW_WORKFLOW_ID || ""; + const runUrl = buildWorkflowRunUrl(context, context.repo); + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? ""; + const triggeringPRNumber = context.payload.pull_request?.number; + const triggeringDiscussionNumber = context.payload.discussion?.number; + + // Prepend threat detection caution alert at the very top of the PR body so it is + // immediately visible to reviewers. The caution is omitted from the footer to + // avoid duplication (skipDetectionCaution is passed to generateFooterWithMessages). + + // Inject body header before user content (unshifted first, so caution will appear before it) + const bodyHeader = getBodyHeader({ workflowName, runUrl }); + if (bodyHeader) { + bodyLines.unshift(...bodyHeader.split("\n"), ""); + } - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; + // Keep the protected-files notice directly under detection caution: + // this block runs first, then detectionCaution below unshifts to index 0. + if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { + const protectedFilesNoticeTemplatePath = getPromptPath("manifest_protection_request_review.md"); + const protectedFilesNotice = renderTemplateFromFile(protectedFilesNoticeTemplatePath, { + files: renderFilesList(manifestProtectionRequestReview.join(", ")), + }); + bodyLines.unshift(protectedFilesNotice, "", ""); + } + // Inject CAUTION at top of body (unshifted after header so it appears first in the final output) + const detectionCaution = assembleMarkdownBodyParts({ + includeFooter: false, + workflowName, + runUrl, + }).detectionCaution; + if (detectionCaution) { + // unshift(caution, "", "") places the caution alert at index 0 and two blank + // separator lines so the main body content follows after a full empty line. + bodyLines.unshift(detectionCaution, "", ""); + } - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; + // Add fingerprint comment if present + const trackerIDComment = getTrackerID("markdown"); + if (trackerIDComment) { + bodyLines.push(trackerIDComment); } - if (patchFilePath && fs.existsSync(patchFilePath)) { - const patchStats = fs.readFileSync(patchFilePath, "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + // Snapshot the body content (without footer) for use in protected-files fallback ordering. + // The protected-files section must appear before the footer (including guard notices such as + // the integrity-filtering note) so that the footer always comes last in the issue body. + const mainBodyContent = bodyLines.join("\n").trim(); + const issueSafeMainBodyContent = neutralizeClosingKeywordsForIssueBody(mainBodyContent); + + // Generate footer using messages template system (respects custom messages.footer config) + // When footer is disabled, only add XML markers (no visible footer content) + const footerParts = []; + if (includeFooter) { + const historyUrl = + generateHistoryUrl({ + owner: repoParts.owner, + repo: repoParts.repo, + itemType: "pull_request", + workflowId, + serverUrl: context.serverUrl, + }) ?? undefined; + // The footer builder skips detection caution so the caution already prepended at + // the top of the body is not duplicated in the footer. + let footer = assembleMarkdownBodyParts({ + includeFooter: true, + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber, + historyUrl, + }).footer; + footer = addExpirationToFooter(footer, expiresHours, "Pull Request"); + if (expiresHours > 0) { + footer += "\n\n"; } + bodyLines.push(``, ``, footer); + footerParts.push(footer); } - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return { success: true, staged: true }; - } - - // Extract title, body, and branch from the JSON item - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - - // Replace temporary ID references in the body with resolved issue/PR numbers - // This allows PRs to reference issues created earlier in the same workflow - // by using temporary IDs like #aw_123abc456def - if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) { - // Convert object to Map for compatibility with replaceTemporaryIdReferences - const tempIdMap = new Map(Object.entries(resolvedTemporaryIds)); - processedBody = replaceTemporaryIdReferences(processedBody, tempIdMap, itemRepo); - core.info(`Resolved ${tempIdMap.size} temporary ID references in PR body`); - } - - // Remove duplicate title from description if it starts with a header matching the title - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - - // Sanitize body content to neutralize @mentions, URLs, and other security risks - processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases }); - - // Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present. - // This ensures the triggering issue is auto-closed when the PR is merged. - // Agents are instructed to include this but don't reliably do so. - // This behavior can be disabled by setting auto-close-issue: false in the workflow config. - if (triggeringIssueNumber && autoCloseIssue) { - const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody); - if (!hasClosingKeyword) { - processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`; - core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`); + // Add standalone workflow-id marker for searchability (consistent with comments) + // Always add XML markers even when footer is disabled + if (workflowId) { + const workflowIdMarker = generateWorkflowIdMarker(workflowId); + // Add to bodyLines for the normal PR body path. + // Add to footerParts so the fallback issue body places it after the protected-files section. + bodyLines.push(``, workflowIdMarker); + footerParts.push(workflowIdMarker); } - } else if (triggeringIssueNumber && !autoCloseIssue) { - core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`); - } - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - // Preserve the original agent branch name for bundle transport (the bundle was created - // using this branch name as the refs/heads ref inside the bundle file). - const originalAgentBranch = branchName; - const randomHex = crypto.randomBytes(8).toString("hex"); - - // SECURITY: Sanitize branch name to prevent shell injection (CWE-78) - // Branch names from user input must be normalized before use in git commands. - // When preserve-branch-name is disabled (default), a random salt suffix is - // appended to avoid collisions. - if (branchName) { - const originalBranchName = branchName; - branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex); - - // Validate it's not empty after normalization - if (!branchName) { - throw new Error(`Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")`); + // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow + // do not close each other's PRs when close-older-pull-requests is enabled. + if (callerWorkflowId) { + bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId)); } - if (preserveBranchName) { - core.info(`Using branch name from JSONL without salt suffix (preserve-branch-name enabled): ${branchName}`); - } else { - core.info(`Using branch name from JSONL with added salt: ${branchName}`); + // Embed gh-aw-close-key marker when an explicit deduplication key is set. + if (closeOlderKey) { + bodyLines.push(generateCloseKeyMarker(closeOlderKey)); } - if (originalBranchName !== branchName) { - core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`); - } - } - // If no title was found, use a default - if (!title) { - title = "Agent Output"; - } + bodyLines.push(""); - // Sanitize title for Unicode security and remove any duplicate prefixes - title = sanitizeTitle(title, titlePrefix); - - // Apply title prefix (only if it doesn't already exist) - title = applyTitlePrefix(title, titlePrefix); - - // Add AI disclaimer with workflow name and run url - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowId = process.env.GH_AW_WORKFLOW_ID || ""; - const runUrl = buildWorkflowRunUrl(context, context.repo); - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? ""; - const triggeringPRNumber = context.payload.pull_request?.number; - const triggeringDiscussionNumber = context.payload.discussion?.number; - - // Prepend threat detection caution alert at the very top of the PR body so it is - // immediately visible to reviewers. The caution is omitted from the footer to - // avoid duplication (skipDetectionCaution is passed to generateFooterWithMessages). - - // Inject body header before user content (unshifted first, so caution will appear before it) - const bodyHeader = getBodyHeader({ workflowName, runUrl }); - if (bodyHeader) { - bodyLines.unshift(...bodyHeader.split("\n"), ""); - } + // Prepare the body content + const body = bodyLines.join("\n").trim(); + const issueSafeBody = neutralizeClosingKeywordsForIssueBody(body); + // Footer section (footer + workflow-id marker) used when ordering protected-files notices + const footerContent = footerParts.join("\n\n"); - // Keep the protected-files notice directly under detection caution: - // this block runs first, then detectionCaution below unshifts to index 0. - if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { - const protectedFilesNoticeTemplatePath = getPromptPath("manifest_protection_request_review.md"); - const protectedFilesNotice = renderTemplateFromFile(protectedFilesNoticeTemplatePath, { - files: renderFilesList(manifestProtectionRequestReview.join(", ")), - }); - bodyLines.unshift(protectedFilesNotice, "", ""); - } - // Inject CAUTION at top of body (unshifted after header so it appears first in the final output) - const detectionCaution = assembleMarkdownBodyParts({ - includeFooter: false, - workflowName, - runUrl, - }).detectionCaution; - if (detectionCaution) { - // unshift(caution, "", "") places the caution alert at index 0 and two blank - // separator lines so the main body content follows after a full empty line. - bodyLines.unshift(detectionCaution, "", ""); - } + // Build labels array - merge config labels with message labels + let labels = [...envLabels]; + if (pullRequestItem.labels && Array.isArray(pullRequestItem.labels)) { + labels = [...labels, ...pullRequestItem.labels]; + } + labels = labels + .filter(label => !!label) + .map(label => String(label).trim()) + .filter(label => label); + // Add agentic-threat-detected label when threat detection produced a warning + if (detectionCaution && !labels.includes("agentic-threat-detected")) { + labels.push("agentic-threat-detected"); + } + // Use explicitly configured fallback labels when present; otherwise preserve + // existing behavior by reusing pull request labels for fallback issues. + const effectiveFallbackLabels = configFallbackLabels.length > 0 ? configFallbackLabels : labels; + + // Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty) + const draft = draftDefault; + if (pullRequestItem.draft !== undefined && pullRequestItem.draft !== draftDefault) { + core.warning( + `Agent requested draft: ${pullRequestItem.draft}, but configuration enforces draft: ${draftDefault}. ` + + `Configuration takes precedence for security. To change this, update safe-outputs.create-pull-request.draft in the workflow file.` + ); + } - // Add fingerprint comment if present - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } + core.info(`Creating pull request with title: ${title}`); + core.info(`Labels: ${JSON.stringify(labels)}`); + core.info(`Draft: ${draft}`); + core.info(`Body length: ${body.length}`); - // Snapshot the body content (without footer) for use in protected-files fallback ordering. - // The protected-files section must appear before the footer (including guard notices such as - // the integrity-filtering note) so that the footer always comes last in the issue body. - const mainBodyContent = bodyLines.join("\n").trim(); - const issueSafeMainBodyContent = neutralizeClosingKeywordsForIssueBody(mainBodyContent); - - // Generate footer using messages template system (respects custom messages.footer config) - // When footer is disabled, only add XML markers (no visible footer content) - const footerParts = []; - if (includeFooter) { - const historyUrl = - generateHistoryUrl({ - owner: repoParts.owner, - repo: repoParts.repo, - itemType: "pull_request", - workflowId, - serverUrl: context.serverUrl, - }) ?? undefined; - // The footer builder skips detection caution so the caution already prepended at - // the top of the body is not duplicated in the footer. - let footer = assembleMarkdownBodyParts({ - includeFooter: true, - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber, - historyUrl, - }).footer; - footer = addExpirationToFooter(footer, expiresHours, "Pull Request"); - if (expiresHours > 0) { - footer += "\n\n"; + // When no branch name was provided by the agent, generate a unique one. + if (!branchName) { + core.info("No branch name provided in JSONL, generating unique branch name"); + branchName = `${workflowId}-${randomHex}`; } - bodyLines.push(``, ``, footer); - footerParts.push(footer); - } - // Add standalone workflow-id marker for searchability (consistent with comments) - // Always add XML markers even when footer is disabled - if (workflowId) { - const workflowIdMarker = generateWorkflowIdMarker(workflowId); - // Add to bodyLines for the normal PR body path. - // Add to footerParts so the fallback issue body places it after the protected-files section. - bodyLines.push(``, workflowIdMarker); - footerParts.push(workflowIdMarker); - } + // Apply the configured branch prefix (e.g. "signed/") if it hasn't already been applied. + if (branchPrefix && !branchName.startsWith(branchPrefix)) { + branchName = `${branchPrefix}${branchName}`; + core.info(`Applied branch prefix: ${branchName}`); + } - // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow - // do not close each other's PRs when close-older-pull-requests is enabled. - if (callerWorkflowId) { - bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId)); - } + core.info(`Generated branch name: ${branchName}`); + core.info(`Base branch: ${baseBranch}`); - // Embed gh-aw-close-key marker when an explicit deduplication key is set. - if (closeOlderKey) { - bodyLines.push(generateCloseKeyMarker(closeOlderKey)); - } + // Create a new branch using git CLI, ensuring it's based on the correct base branch - bodyLines.push(""); + // First, fetch the base branch specifically (since we use shallow checkout) + core.info(`Fetching base branch: ${baseBranch}`); - // Prepare the body content - const body = bodyLines.join("\n").trim(); - const issueSafeBody = neutralizeClosingKeywordsForIssueBody(body); - // Footer section (footer + workflow-id marker) used when ordering protected-files notices - const footerContent = footerParts.join("\n\n"); + // Fetch without creating/updating local branch to avoid conflicts with current branch + // This works even when we're already on the base branch + await exec.exec(`git fetch origin ${baseBranch}`); - // Build labels array - merge config labels with message labels - let labels = [...envLabels]; - if (pullRequestItem.labels && Array.isArray(pullRequestItem.labels)) { - labels = [...labels, ...pullRequestItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label); - // Add agentic-threat-detected label when threat detection produced a warning - if (detectionCaution && !labels.includes("agentic-threat-detected")) { - labels.push("agentic-threat-detected"); - } - // Use explicitly configured fallback labels when present; otherwise preserve - // existing behavior by reusing pull request labels for fallback issues. - const effectiveFallbackLabels = configFallbackLabels.length > 0 ? configFallbackLabels : labels; - - // Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty) - const draft = draftDefault; - if (pullRequestItem.draft !== undefined && pullRequestItem.draft !== draftDefault) { - core.warning( - `Agent requested draft: ${pullRequestItem.draft}, but configuration enforces draft: ${draftDefault}. ` + - `Configuration takes precedence for security. To change this, update safe-outputs.create-pull-request.draft in the workflow file.` - ); - } + // Apply the patch/bundle using git CLI (skip if empty) + // Track number of new commits pushed so we can restrict the extra empty commit + // to branches with exactly one new commit (security: prevents use of CI trigger + // token on multi-commit branches where workflow files may have been modified). + let newCommitCount = 0; + if (hasBundleFile) { + // Bundle transport: fetch commits directly from the bundle file. + // This preserves merge commit topology and per-commit metadata (messages, authorship) + // unlike git format-patch which flattens history and drops merge resolution content. + core.info(`Applying changes from bundle: ${bundleFilePath}`); + try { + await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec); + } catch (bundleError) { + core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`); + return { success: false, error: "Failed to apply bundle" }; + } - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); + // Push the commits from the bundle to the remote branch + // Note: when manifestProtectionFallback is set we still push the branch so the + // fallback issue can include a compare URL. Genuine push failures are handled in + // the catch block below. + { + try { + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); - // When no branch name was provided by the agent, generate a unique one. - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } + await pushSignedCommits({ + githubClient, + owner: repoParts.owner, + repo: repoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: process.cwd(), + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + }); + core.info("Changes pushed to branch (from bundle)"); - // Apply the configured branch prefix (e.g. "signed/") if it hasn't already been applied. - if (branchPrefix && !branchName.startsWith(branchPrefix)) { - branchName = `${branchPrefix}${branchName}`; - core.info(`Applied branch prefix: ${branchName}`); - } + // Count new commits on PR branch relative to base + try { + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); + newCommitCount = parseInt(countStr.trim(), 10); + core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); + } catch { + core.info("Could not count new commits - extra empty commit will be skipped"); + } + } catch (initialPushError) { + /** @type {unknown} */ + let pushError = initialPushError; + let pushRecovered = false; + const pushErrorMessage = pushError instanceof Error ? pushError.message : String(pushError); + const isSignedMergeReplayRefusal = signedCommits && /pushSignedCommits: refusing unsigned push/.test(pushErrorMessage) && /merge commit/i.test(pushErrorMessage); + + if (isSignedMergeReplayRefusal) { + core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); + try { + await rewriteBundleBranchAsSingleCommit(baseBranch, exec); + await pushSignedCommits({ + githubClient, + owner: repoParts.owner, + repo: repoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: process.cwd(), + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + }); + core.info("Changes pushed to branch after bundle rewrite retry"); - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); + try { + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); + newCommitCount = parseInt(countStr.trim(), 10); + core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); + } catch { + core.info("Could not count new commits - extra empty commit will be skipped"); + } + pushRecovered = true; + } catch (retryPushError) { + pushError = retryPushError; + } + } - // Create a new branch using git CLI, ensuring it's based on the correct base branch + if (!pushRecovered) { + core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); + + if (manifestProtectionFallback) { + // Push failed specifically for a protected-file modification. Don't create + // a generic push-failed issue — fall through to the manifestProtectionFallback + // block below, which will create the proper protected-file review issue with + // patch artifact download instructions (since the branch was not pushed). + core.warning("Git push failed for protected-file modification - deferring to protected-file review issue"); + manifestProtectionPushFailedError = pushError; + } else if (!fallbackAsIssue) { + const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; + return { success: false, error, error_type: "push_failed" }; + } else { + core.warning("Git push operation failed - creating fallback issue instead of pull request"); - // First, fetch the base branch specifically (since we use shallow checkout) - core.info(`Fetching base branch: ${baseBranch}`); + const runUrl = buildWorkflowRunUrl(context, context.repo); + const runId = context.runId; - // Fetch without creating/updating local branch to avoid conflicts with current branch - // This works even when we're already on the base branch - await exec.exec(`git fetch origin ${baseBranch}`); + const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle"; + const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`; + const fallbackBundleTempRef = createBundleTempRef(branchName); + const fallbackBody = `${issueSafeBody} - // Apply the patch/bundle using git CLI (skip if empty) - // Track number of new commits pushed so we can restrict the extra empty commit - // to branches with exactly one new commit (security: prevents use of CI trigger - // token on multi-commit branches where workflow files may have been modified). - let newCommitCount = 0; - if (hasBundleFile) { - // Bundle transport: fetch commits directly from the bundle file. - // This preserves merge commit topology and per-commit metadata (messages, authorship) - // unlike git format-patch which flattens history and drops merge resolution content. - core.info(`Applying changes from bundle: ${bundleFilePath}`); - try { - await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec); - } catch (bundleError) { - core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`); - return { success: false, error: "Failed to apply bundle" }; - } +--- - // Push the commits from the bundle to the remote branch - // Note: when manifestProtectionFallback is set we still push the branch so the - // fallback issue can include a compare URL. Genuine push failures are handled in - // the catch block below. - { - try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); +> [!NOTE] +> This was originally intended as a pull request, but the git push operation failed. +> +> **Workflow Run:** [View run details and download bundle artifact](${runUrl}) +> +> The bundle file is available in the \`agent\` artifact in the workflow run linked above. - await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - }); - core.info("Changes pushed to branch (from bundle)"); - - // Count new commits on PR branch relative to base - try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); - newCommitCount = parseInt(countStr.trim(), 10); - core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); - } catch { - core.info("Could not count new commits - extra empty commit will be skipped"); - } - } catch (initialPushError) { - /** @type {unknown} */ - let pushError = initialPushError; - let pushRecovered = false; - const pushErrorMessage = pushError instanceof Error ? pushError.message : String(pushError); - const isSignedMergeReplayRefusal = signedCommits && /pushSignedCommits: refusing unsigned push/.test(pushErrorMessage) && /merge commit/i.test(pushErrorMessage); - - if (isSignedMergeReplayRefusal) { - core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); - try { - await rewriteBundleBranchAsSingleCommit(baseBranch, exec); - await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - }); - core.info("Changes pushed to branch after bundle rewrite retry"); - - try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); - newCommitCount = parseInt(countStr.trim(), 10); - core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); - } catch { - core.info("Could not count new commits - extra empty commit will be skipped"); - } - pushRecovered = true; - } catch (retryPushError) { - pushError = retryPushError; - } - } - - if (!pushRecovered) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - - if (manifestProtectionFallback) { - // Push failed specifically for a protected-file modification. Don't create - // a generic push-failed issue — fall through to the manifestProtectionFallback - // block below, which will create the proper protected-file review issue with - // patch artifact download instructions (since the branch was not pushed). - core.warning("Git push failed for protected-file modification - deferring to protected-file review issue"); - manifestProtectionPushFailedError = pushError; - } else if (!fallbackAsIssue) { - const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; - return { success: false, error, error_type: "push_failed" }; - } else { - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - - const runUrl = buildWorkflowRunUrl(context, context.repo); - const runId = context.runId; - - const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle"; - const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`; - const fallbackBundleTempRef = createBundleTempRef(branchName); - const fallbackBody = `${issueSafeBody} - ---- - -> [!NOTE] -> This was originally intended as a pull request, but the git push operation failed. -> -> **Workflow Run:** [View run details and download bundle artifact](${runUrl}) -> -> The bundle file is available in the \`agent\` artifact in the workflow run linked above. - -To create a pull request with the changes: +To create a pull request with the changes: \`\`\`sh # Download the artifact from the workflow run @@ -1543,269 +1605,269 @@ git push origin ${branchName} gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} \`\`\``; - try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); - - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - - return { - success: true, - fallback_used: true, - issue_number: issue.number, - issue_url: issue.html_url, - }; - } catch (issueError) { - const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; - return { success: false, error }; + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); + + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + + return { + success: true, + fallback_used: true, + issue_number: issue.number, + issue_url: issue.html_url, + }; + } catch (issueError) { + const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + return { success: false, error }; + } } } } } - } - } else { - // Checkout the base branch (using origin/${baseBranch} if local doesn't exist) - try { - await exec.exec(`git checkout ${baseBranch}`); - } catch (checkoutError) { - // If local branch doesn't exist, create it from origin - core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`); - await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`); - } - - // Handle branch creation/checkout - let branchBaseRef = baseBranch; - const recordedBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit); - if (recordedBaseCommit) { - core.info(`Patch route base_commit resolved: ${recordedBaseCommit}`); - core.info(`Using base_commit from safe output entry for patch apply: ${recordedBaseCommit}`); + } else { + // Checkout the base branch (using origin/${baseBranch} if local doesn't exist) try { - try { - await exec.exec("git", ["fetch", "origin", recordedBaseCommit, "--depth=1"]); - } catch (fetchError) { - core.info(`Note: could not fetch base commit ${recordedBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`); - } - await exec.exec("git", ["cat-file", "-e", recordedBaseCommit]); - const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, `origin/${baseBranch}`], { ignoreReturnCode: true }); - if (ancestryCheck.exitCode !== 0) { - throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of origin/${baseBranch}; falling back to ${baseBranch}`); - } - branchBaseRef = recordedBaseCommit; - } catch (baseCommitError) { - core.warning(`Recorded base_commit ${recordedBaseCommit} is not available in this checkout (${baseCommitError instanceof Error ? baseCommitError.message : String(baseCommitError)}); falling back to ${baseBranch}`); + await exec.exec(`git checkout ${baseBranch}`); + } catch (checkoutError) { + // If local branch doesn't exist, create it from origin + core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`); + await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`); } - } else if (String(pullRequestItem.base_commit ?? "").trim()) { - core.warning(`Ignoring invalid base_commit value for patch apply: ${String(pullRequestItem.base_commit).trim()}`); - } - core.info(`Branch should not exist locally, creating new branch from base: ${branchName} (${branchBaseRef})`); - await exec.exec("git", ["checkout", "-b", branchName, branchBaseRef]); - core.info(`Created new branch from base: ${branchName} (${branchBaseRef})`); - // Apply the patch using git CLI (skip if empty) - if (!isEmpty) { - // Resolve temporary ID references in patch content before applying - // This handles references like #aw_XXX in committed source code - if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) { - const tempIdMap = new Map(Object.entries(resolvedTemporaryIds)); - const originalPatchContent = patchContent; - patchContent = replaceTemporaryIdReferencesInPatch(patchContent, tempIdMap, itemRepo); - if (patchContent !== originalPatchContent) { - core.info("Resolved temporary ID references in patch content"); - fs.writeFileSync(patchFilePath, patchContent, "utf8"); + // Handle branch creation/checkout + let branchBaseRef = baseBranch; + const recordedBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit); + if (recordedBaseCommit) { + core.info(`Patch route base_commit resolved: ${recordedBaseCommit}`); + core.info(`Using base_commit from safe output entry for patch apply: ${recordedBaseCommit}`); + try { + try { + await exec.exec("git", ["fetch", "origin", recordedBaseCommit, "--depth=1"]); + } catch (fetchError) { + core.info(`Note: could not fetch base commit ${recordedBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`); + } + await exec.exec("git", ["cat-file", "-e", recordedBaseCommit]); + const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, `origin/${baseBranch}`], { ignoreReturnCode: true }); + if (ancestryCheck.exitCode !== 0) { + throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of origin/${baseBranch}; falling back to ${baseBranch}`); + } + branchBaseRef = recordedBaseCommit; + } catch (baseCommitError) { + core.warning(`Recorded base_commit ${recordedBaseCommit} is not available in this checkout (${baseCommitError instanceof Error ? baseCommitError.message : String(baseCommitError)}); falling back to ${baseBranch}`); } + } else if (String(pullRequestItem.base_commit ?? "").trim()) { + core.warning(`Ignoring invalid base_commit value for patch apply: ${String(pullRequestItem.base_commit).trim()}`); } + core.info(`Branch should not exist locally, creating new branch from base: ${branchName} (${branchBaseRef})`); + await exec.exec("git", ["checkout", "-b", branchName, branchBaseRef]); + core.info(`Created new branch from base: ${branchName} (${branchBaseRef})`); + + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + // Resolve temporary ID references in patch content before applying + // This handles references like #aw_XXX in committed source code + if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) { + const tempIdMap = new Map(Object.entries(resolvedTemporaryIds)); + const originalPatchContent = patchContent; + patchContent = replaceTemporaryIdReferencesInPatch(patchContent, tempIdMap, itemRepo); + if (patchContent !== originalPatchContent) { + core.info("Resolved temporary ID references in patch content"); + fs.writeFileSync(patchFilePath, patchContent, "utf8"); + } + } - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } + core.info("Applying patch..."); + const patchLines = patchContent.split("\n"); + const previewLineCount = Math.min(500, patchLines.length); + core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); + for (let i = 0; i < previewLineCount; i++) { + core.info(patchLines[i]); + } - // Patches are created with git format-patch, so use git am to apply them - // Use --3way to handle cross-repo patches where the patch base may differ from target repo - // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source - let patchApplied = false; - try { - await exec.exec("git", ["am", "--3way", patchFilePath]); - core.info("Patch applied successfully"); - patchApplied = true; - } catch (patchError) { - core.error(`Failed to apply patch with --3way: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - - const recoveredFromAddAddConflict = await tryRecoverGitAmAddAddConflict(exec); - if (recoveredFromAddAddConflict.recovered) { + // Patches are created with git format-patch, so use git am to apply them + // Use --3way to handle cross-repo patches where the patch base may differ from target repo + // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source + let patchApplied = false; + try { + await exec.exec("git", ["am", "--3way", patchFilePath]); + core.info("Patch applied successfully"); patchApplied = true; - } else { - if (recoveredFromAddAddConflict.errorMessage) { - core.warning(`Automatic add/add conflict recovery attempt failed: ${recoveredFromAddAddConflict.errorMessage}`); - } - // Investigate why the patch failed by logging git status and the failed patch - try { - core.info("Investigating patch failure..."); - - // Log git status to see the current state - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - - // Log the failed patch diff - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } + } catch (patchError) { + core.error(`Failed to apply patch with --3way: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - // Abort the failed git am before attempting any fallback - try { - await exec.exec("git am --abort"); - core.info("Aborted failed git am"); - } catch (abortError) { - core.warning(`Failed to abort git am: ${abortError instanceof Error ? abortError.message : String(abortError)}`); - } - - // Fallback (Option 1): create the PR branch at the original base commit so the PR - // can still be created. GitHub will show the merge conflicts, allowing manual resolution. - // This handles the case where the target branch received intervening commits after - // the patch was generated, making --3way unable to resolve the conflicts automatically. - core.info("Attempting fallback: create PR branch at original base commit..."); - try { - // Use the base commit recorded at patch generation time. - // The From header in format-patch output contains the agent's new commit SHA - // which does not exist in this checkout, so we cannot derive the base from it. - const originalBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit); - if (!originalBaseCommit) { - core.warning("No base_commit recorded in safe output entry - fallback not possible"); - } else { - core.info(`Original base commit from patch generation: ${originalBaseCommit}`); + const recoveredFromAddAddConflict = await tryRecoverGitAmAddAddConflict(exec); + if (recoveredFromAddAddConflict.recovered) { + patchApplied = true; + } else { + if (recoveredFromAddAddConflict.errorMessage) { + core.warning(`Automatic add/add conflict recovery attempt failed: ${recoveredFromAddAddConflict.errorMessage}`); + } + // Investigate why the patch failed by logging git status and the failed patch + try { + core.info("Investigating patch failure..."); + + // Log git status to see the current state + const statusResult = await exec.getExecOutput("git", ["status"]); + core.info("Git status output:"); + core.info(statusResult.stdout); + + // Log the failed patch diff + const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); + core.info("Failed patch content:"); + core.info(patchResult.stdout); + } catch (investigateError) { + core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); + } - // In shallow clones (fetch-depth: 1) the base commit may not be locally available. - // Attempt to fetch it explicitly before checking whether it exists. - try { - await exec.exec("git", ["fetch", "origin", originalBaseCommit, "--depth=1"]); - } catch (fetchError) { - // Non-fatal: the commit may already be available, or the server may not support - // fetching individual SHAs (e.g. some GHE configurations). Log for troubleshooting. - core.info(`Note: could not fetch base commit ${originalBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`); - } + // Abort the failed git am before attempting any fallback + try { + await exec.exec("git am --abort"); + core.info("Aborted failed git am"); + } catch (abortError) { + core.warning(`Failed to abort git am: ${abortError instanceof Error ? abortError.message : String(abortError)}`); + } - // Verify the base commit is available in this repo (may not exist cross-repo) - await exec.exec("git", ["cat-file", "-e", originalBaseCommit]); - core.info("Original base commit exists locally - proceeding with fallback"); + // Fallback (Option 1): create the PR branch at the original base commit so the PR + // can still be created. GitHub will show the merge conflicts, allowing manual resolution. + // This handles the case where the target branch received intervening commits after + // the patch was generated, making --3way unable to resolve the conflicts automatically. + core.info("Attempting fallback: create PR branch at original base commit..."); + try { + // Use the base commit recorded at patch generation time. + // The From header in format-patch output contains the agent's new commit SHA + // which does not exist in this checkout, so we cannot derive the base from it. + const originalBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit); + if (!originalBaseCommit) { + core.warning("No base_commit recorded in safe output entry - fallback not possible"); + } else { + core.info(`Original base commit from patch generation: ${originalBaseCommit}`); + + // In shallow clones (fetch-depth: 1) the base commit may not be locally available. + // Attempt to fetch it explicitly before checking whether it exists. + try { + await exec.exec("git", ["fetch", "origin", originalBaseCommit, "--depth=1"]); + } catch (fetchError) { + // Non-fatal: the commit may already be available, or the server may not support + // fetching individual SHAs (e.g. some GHE configurations). Log for troubleshooting. + core.info(`Note: could not fetch base commit ${originalBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`); + } - // Re-create the PR branch at the original base commit - await exec.exec(`git checkout ${baseBranch}`); - try { - await exec.exec(`git branch -D ${branchName}`); - } catch { - // Branch may not exist yet, ignore - } - await exec.exec(`git checkout -b ${branchName} ${originalBaseCommit}`); - core.info(`Created branch ${branchName} at original base commit ${originalBaseCommit}`); + // Verify the base commit is available in this repo (may not exist cross-repo) + await exec.exec("git", ["cat-file", "-e", originalBaseCommit]); + core.info("Original base commit exists locally - proceeding with fallback"); - // Try --3way first to maximize repair opportunities even on fallback branches. - // If that still fails with add/add conflicts, recover and continue git am. - try { - await exec.exec("git", ["am", "--3way", patchFilePath]); - } catch (fallbackPatchError) { - core.warning(`Fallback git am --3way failed: ${getErrorMessage(fallbackPatchError)}`); - const recoveredFallback = await tryRecoverGitAmAddAddConflict(exec); - if (!recoveredFallback.recovered) { - if (recoveredFallback.errorMessage) { - core.warning(`Automatic add/add conflict recovery attempt failed during fallback: ${recoveredFallback.errorMessage}`); - } - try { - await exec.exec("git am --abort"); - } catch (abortFallbackError) { - core.warning(`Failed to abort fallback git am: ${getErrorMessage(abortFallbackError)}`); + // Re-create the PR branch at the original base commit + await exec.exec(`git checkout ${baseBranch}`); + try { + await exec.exec(`git branch -D ${branchName}`); + } catch { + // Branch may not exist yet, ignore + } + await exec.exec(`git checkout -b ${branchName} ${originalBaseCommit}`); + core.info(`Created branch ${branchName} at original base commit ${originalBaseCommit}`); + + // Try --3way first to maximize repair opportunities even on fallback branches. + // If that still fails with add/add conflicts, recover and continue git am. + try { + await exec.exec("git", ["am", "--3way", patchFilePath]); + } catch (fallbackPatchError) { + core.warning(`Fallback git am --3way failed: ${getErrorMessage(fallbackPatchError)}`); + const recoveredFallback = await tryRecoverGitAmAddAddConflict(exec); + if (!recoveredFallback.recovered) { + if (recoveredFallback.errorMessage) { + core.warning(`Automatic add/add conflict recovery attempt failed during fallback: ${recoveredFallback.errorMessage}`); + } + try { + await exec.exec("git am --abort"); + } catch (abortFallbackError) { + core.warning(`Failed to abort fallback git am: ${getErrorMessage(abortFallbackError)}`); + } + throw fallbackPatchError; } - throw fallbackPatchError; } - } - core.info("Patch applied successfully at original base commit"); - core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. The pull request will show merge conflicts that require manual resolution.`); - patchApplied = true; + core.info("Patch applied successfully at original base commit"); + core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. The pull request will show merge conflicts that require manual resolution.`); + patchApplied = true; + } + } catch (fallbackError) { + core.warning(`Fallback to original base commit failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`); } - } catch (fallbackError) { - core.warning(`Fallback to original base commit failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`); } - } - if (!patchApplied) { - return { success: false, error: "Failed to apply patch" }; + if (!patchApplied) { + return { success: false, error: "Failed to apply patch" }; + } } - } - // Push the applied commits to the branch (with fallback to issue creation on failure) - // Note: when manifestProtectionFallback is set we still push the branch so the - // fallback issue can include a compare URL. Genuine push failures are handled in - // the catch block below. - { - try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); - - await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - }); - core.info("Changes pushed to branch"); - - // Count new commits on PR branch relative to base, used to restrict - // the extra empty CI-trigger commit to exactly 1 new commit. + // Push the applied commits to the branch (with fallback to issue creation on failure) + // Note: when manifestProtectionFallback is set we still push the branch so the + // fallback issue can include a compare URL. Genuine push failures are handled in + // the catch block below. + { try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); - newCommitCount = parseInt(countStr.trim(), 10); - core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); - } catch { - // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped - core.info("Could not count new commits - extra empty commit will be skipped"); - } - } catch (pushError) { - // Push failed - create fallback issue instead of PR (if fallback is enabled) - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - - if (manifestProtectionFallback) { - // Push failed specifically for a protected-file modification. Don't create - // a generic push-failed issue — fall through to the manifestProtectionFallback - // block below, which will create the proper protected-file review issue with - // patch artifact download instructions (since the branch was not pushed). - core.warning("Git push failed for protected-file modification - deferring to protected-file review issue"); - manifestProtectionPushFailedError = pushError; - } else if (!fallbackAsIssue) { - // Fallback is disabled - return error without creating issue - core.error("fallback-as-issue is disabled - not creating fallback issue"); - const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; - return { - success: false, - error, - error_type: "push_failed", - }; - } else { - core.warning("Git push operation failed - creating fallback issue instead of pull request"); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); - const runUrl = buildWorkflowRunUrl(context, context.repo); - const runId = context.runId; + await pushSignedCommits({ + githubClient, + owner: repoParts.owner, + repo: repoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: process.cwd(), + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + }); + core.info("Changes pushed to branch"); - // Read patch content for preview - let patchPreview = ""; - if (patchFilePath && fs.existsSync(patchFilePath)) { - const patchContent = fs.readFileSync(patchFilePath, "utf8"); - patchPreview = generatePatchPreview(patchContent); + // Count new commits on PR branch relative to base, used to restrict + // the extra empty CI-trigger commit to exactly 1 new commit. + try { + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); + newCommitCount = parseInt(countStr.trim(), 10); + core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); + } catch { + // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped + core.info("Could not count new commits - extra empty commit will be skipped"); } + } catch (pushError) { + // Push failed - create fallback issue instead of PR (if fallback is enabled) + core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); + + if (manifestProtectionFallback) { + // Push failed specifically for a protected-file modification. Don't create + // a generic push-failed issue — fall through to the manifestProtectionFallback + // block below, which will create the proper protected-file review issue with + // patch artifact download instructions (since the branch was not pushed). + core.warning("Git push failed for protected-file modification - deferring to protected-file review issue"); + manifestProtectionPushFailedError = pushError; + } else if (!fallbackAsIssue) { + // Fallback is disabled - return error without creating issue + core.error("fallback-as-issue is disabled - not creating fallback issue"); + const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; + return { + success: false, + error, + error_type: "push_failed", + }; + } else { + core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch"; - const fallbackBody = `${issueSafeBody} + const runUrl = buildWorkflowRunUrl(context, context.repo); + const runId = context.runId; + + // Read patch content for preview + let patchPreview = ""; + if (patchFilePath && fs.existsSync(patchFilePath)) { + const patchContent = fs.readFileSync(patchFilePath, "utf8"); + patchPreview = generatePatchPreview(patchContent); + } + + const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch"; + const fallbackBody = `${issueSafeBody} --- @@ -1836,22 +1898,22 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo \`\`\` ${patchPreview}`; - try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - // Update the activation comment with issue link (if a comment was created) - // - // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created - // in the same repo as the activation, so the global client has the correct context for updating the comment. - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + // Update the activation comment with issue link (if a comment was created) + // + // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created + // in the same repo as the activation, so the global client has the correct context for updating the comment. + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` + // Write summary to GitHub Actions summary + await core.summary + .addRaw( + ` ## Push Failure Fallback - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} @@ -1859,421 +1921,480 @@ ${patchPreview}`; - **Patch Artifact:** Available in workflow run artifacts - **Note:** Push failed, created issue as fallback ` - ) - .write(); + ) + .write(); + + return { + success: true, + fallback_used: true, + push_failed: true, + issue_number: issue.number, + issue_url: issue.html_url, + branch_name: branchName, + repo: itemRepo, + }; + } catch (issueError) { + const error = `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + core.error(error); + return { + success: false, + error, + }; + } + } // end else (generic push-failed fallback) + } + } + } else { + core.info("Skipping patch application (empty patch)"); - return { - success: true, - fallback_used: true, - push_failed: true, - issue_number: issue.number, - issue_url: issue.html_url, - branch_name: branchName, - repo: itemRepo, - }; - } catch (issueError) { - const error = `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; - core.error(error); - return { - success: false, - error, - }; + // For empty patches with allow-empty, we still need to push the branch + if (allowEmpty) { + core.info("allow-empty is enabled - will create branch and push with empty commit"); + // Push the branch with an empty commit to allow PR creation + try { + // Create an empty commit to ensure there's a commit difference + await exec.exec(`git commit --allow-empty -m "Initialize"`); + core.info("Created empty commit"); + + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + + await pushSignedCommits({ + githubClient, + owner: repoParts.owner, + repo: repoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: process.cwd(), + signedCommits, + resolvedTemporaryIds, + currentRepo: itemRepo, + }); + core.info("Empty branch pushed successfully"); + + // Count new commits (will be 1 from the Initialize commit) + try { + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); + newCommitCount = parseInt(countStr.trim(), 10); + core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); + } catch { + // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped + core.info("Could not count new commits - extra empty commit will be skipped"); } - } // end else (generic push-failed fallback) + } catch (pushError) { + const error = `Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`; + core.error(error); + return { + success: false, + error, + error_type: "push_failed", + }; + } + } else { + // For empty patches without allow-empty, handle if-no-changes configuration + const message = "No changes to apply - noop operation completed successfully"; + + switch (ifNoChanges) { + case "error": + return { success: false, error: "No changes to apply - failing as configured by if-no-changes: error" }; + + case "ignore": + // Silent success - no console output + return { success: false, skipped: true }; + + case "warn": + default: + core.warning(message); + return { success: false, error: message, skipped: true }; + } } + } // end if (!isEmpty) / else patch application block + } // end else (!hasBundleFile - patch path) + + // Protected file protection – fallback-to-issue path: + // The patch has been applied (and pushed, unless manifestProtectionPushFailedError is set). + // Instead of creating a pull request, we create a review issue so a human can carefully + // inspect the protected file changes before merging. + // - Normal case (push succeeded): provides a GitHub compare URL to click and create the PR. + // - Push-failed case: push was rejected (e.g. missing `workflows` permission); provides + // patch artifact download instructions instead of the compare URL. + if (manifestProtectionFallback) { + const allFound = manifestProtectionFallback; + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + // Use head branch (branchName) for links when push succeeded; fall back to baseBranch + // for the push-failed case where the head branch is not yet on the remote. + const branchForLinks = manifestProtectionPushFailedError ? baseBranch : branchName; + const fileList = buildProtectedFileList(allFound, githubServer, repoParts.owner, repoParts.repo, branchForLinks); + + let fallbackBody; + if (manifestProtectionPushFailedError) { + // Push failed — branch not on remote, so compare URL is unavailable. + // Use the push-failed template with artifact download instructions. + const runId = context.runId; + const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch"; + const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md"); + fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, { + main_body: issueSafeMainBodyContent, + footer: footerContent, + files: fileList, + run_id: String(runId), + branch_name: branchName, + base_branch: baseBranch, + patch_file: patchFileName, + title, + repo: `${repoParts.owner}/${repoParts.repo}`, + }); + } else { + // Normal case — push succeeded, provide compare URL. + const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title); + fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); } - } else { - core.info("Skipping patch application (empty patch)"); - // For empty patches with allow-empty, we still need to push the branch - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - // Push the branch with an empty commit to allow PR creation - try { - // Create an empty commit to ensure there's a commit difference - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); - await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, - branch: branchName, - baseRef: `origin/${baseBranch}`, - cwd: process.cwd(), - signedCommits, - resolvedTemporaryIds, - currentRepo: itemRepo, - }); - core.info("Empty branch pushed successfully"); + core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`); - // Count new commits (will be 1 from the Initialize commit) + if (!manifestProtectionPushFailedError) { try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); - newCommitCount = parseInt(countStr.trim(), 10); - core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); - } catch { - // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped - core.info("Could not count new commits - extra empty commit will be skipped"); + const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number); + const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); + + await withRetry( + () => + githubClient.rest.issues.update({ + owner: repoParts.owner, + repo: repoParts.repo, + issue_number: issue.number, + body: fallbackBodyWithCloseKeyword, + }), + RATE_LIMIT_RETRY_CONFIG, + `update protected-file-protection fallback issue #${issue.number} with auto-close link` + ); + } catch (updateIssueBodyError) { + core.warning(`Failed to update protected-file-protection fallback issue #${issue.number} with auto-close link: ${updateIssueBodyError instanceof Error ? updateIssueBodyError.message : String(updateIssueBodyError)}`); } - } catch (pushError) { - const error = `Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`; - core.error(error); - return { - success: false, - error, - error_type: "push_failed", - }; } - } else { - // For empty patches without allow-empty, handle if-no-changes configuration - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - return { success: false, error: "No changes to apply - failing as configured by if-no-changes: error" }; + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - case "ignore": - // Silent success - no console output - return { success: false, skipped: true }; + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - case "warn": - default: - core.warning(message); - return { success: false, error: message, skipped: true }; - } + return { + success: true, + fallback_used: true, + issue_number: issue.number, + issue_url: issue.html_url, + branch_name: branchName, + repo: itemRepo, + }; + } catch (issueError) { + const error = `Protected file protection: failed to create review issue. Error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + core.error(error); + return { success: false, error }; } - } // end if (!isEmpty) / else patch application block - } // end else (!hasBundleFile - patch path) - - // Protected file protection – fallback-to-issue path: - // The patch has been applied (and pushed, unless manifestProtectionPushFailedError is set). - // Instead of creating a pull request, we create a review issue so a human can carefully - // inspect the protected file changes before merging. - // - Normal case (push succeeded): provides a GitHub compare URL to click and create the PR. - // - Push-failed case: push was rejected (e.g. missing `workflows` permission); provides - // patch artifact download instructions instead of the compare URL. - if (manifestProtectionFallback) { - const allFound = manifestProtectionFallback; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - // Use head branch (branchName) for links when push succeeded; fall back to baseBranch - // for the push-failed case where the head branch is not yet on the remote. - const branchForLinks = manifestProtectionPushFailedError ? baseBranch : branchName; - const fileList = buildProtectedFileList(allFound, githubServer, repoParts.owner, repoParts.repo, branchForLinks); - - let fallbackBody; - if (manifestProtectionPushFailedError) { - // Push failed — branch not on remote, so compare URL is unavailable. - // Use the push-failed template with artifact download instructions. - const runId = context.runId; - const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch"; - const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md"); - fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, { - main_body: issueSafeMainBodyContent, - footer: footerContent, - files: fileList, - run_id: String(runId), - branch_name: branchName, - base_branch: baseBranch, - patch_file: patchFileName, - title, - repo: `${repoParts.owner}/${repoParts.repo}`, - }); - } else { - // Normal case — push succeeded, provide compare URL. - const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title); - fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); } + // Try to create the pull request, with fallback to issue creation try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); + const { data: pullRequest } = await withRetry( + () => + githubClient.rest.pulls.create({ + owner: repoParts.owner, + repo: repoParts.repo, + title: title, + body: body, + head: branchName, + base: baseBranch, + draft: draft, + }), + RATE_LIMIT_RETRY_CONFIG, + `create pull request in ${repoParts.owner}/${repoParts.repo}` + ); - core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`); + core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (!manifestProtectionPushFailedError) { + // Add labels if specified + if (labels.length > 0) { try { - const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number); - const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); - await withRetry( () => - githubClient.rest.issues.update({ + githubClient.rest.issues.addLabels({ owner: repoParts.owner, repo: repoParts.repo, - issue_number: issue.number, - body: fallbackBodyWithCloseKeyword, + issue_number: pullRequest.number, + labels: labels, }), - RATE_LIMIT_RETRY_CONFIG, - `update protected-file-protection fallback issue #${issue.number} with auto-close link` + { + maxRetries: LABEL_MAX_RETRIES, + initialDelayMs: LABEL_INITIAL_DELAY_MS, + maxDelayMs: LABEL_MAX_DELAY_MS, + backoffMultiplier: 2, + shouldRetry: isLabelTransientError, + }, + `add labels to PR #${pullRequest.number}` ); - } catch (updateIssueBodyError) { - core.warning(`Failed to update protected-file-protection fallback issue #${issue.number} with auto-close link: ${updateIssueBodyError instanceof Error ? updateIssueBodyError.message : String(updateIssueBodyError)}`); + core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); + } catch (labelError) { + // Label addition is non-critical - warn but don't fail the PR creation. + // GitHub's API may transiently fail to resolve the PR node ID immediately + // after creation, which causes label operations to fail with an unprocessable error. + // If this warning appears, repository checks that require labels on the opened event + // may fail transiently; consider triggering required-label checks on the labeled event instead. + core.warning(`Failed to add labels to PR #${pullRequest.number}: ${labelError instanceof Error ? labelError.message : String(labelError)}`); } } - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + // Add configured reviewers if specified + if (configReviewers.length > 0 || configTeamReviewers.length > 0) { + const hasCopilot = configReviewers.includes("copilot"); + const otherReviewers = configReviewers.filter(r => r !== "copilot"); - return { - success: true, - fallback_used: true, - issue_number: issue.number, - issue_url: issue.html_url, - branch_name: branchName, - repo: itemRepo, - }; - } catch (issueError) { - const error = `Protected file protection: failed to create review issue. Error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; - core.error(error); - return { success: false, error }; - } - } - - // Try to create the pull request, with fallback to issue creation - try { - const { data: pullRequest } = await withRetry( - () => - githubClient.rest.pulls.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }), - RATE_LIMIT_RETRY_CONFIG, - `create pull request in ${repoParts.owner}/${repoParts.repo}` - ); - - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - - // Add labels if specified - if (labels.length > 0) { - try { - await withRetry( - () => - githubClient.rest.issues.addLabels({ + if (otherReviewers.length > 0 || configTeamReviewers.length > 0) { + core.info(`Requesting reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`); + try { + /** @type {{ owner: string, repo: string, pull_number: number, reviewers: string[], team_reviewers?: string[] }} */ + const reviewerRequest = { owner: repoParts.owner, repo: repoParts.repo, - issue_number: pullRequest.number, - labels: labels, - }), - { - maxRetries: LABEL_MAX_RETRIES, - initialDelayMs: LABEL_INITIAL_DELAY_MS, - maxDelayMs: LABEL_MAX_DELAY_MS, - backoffMultiplier: 2, - shouldRetry: isLabelTransientError, - }, - `add labels to PR #${pullRequest.number}` - ); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } catch (labelError) { - // Label addition is non-critical - warn but don't fail the PR creation. - // GitHub's API may transiently fail to resolve the PR node ID immediately - // after creation, which causes label operations to fail with an unprocessable error. - // If this warning appears, repository checks that require labels on the opened event - // may fail transiently; consider triggering required-label checks on the labeled event instead. - core.warning(`Failed to add labels to PR #${pullRequest.number}: ${labelError instanceof Error ? labelError.message : String(labelError)}`); - } - } - - // Add configured reviewers if specified - if (configReviewers.length > 0 || configTeamReviewers.length > 0) { - const hasCopilot = configReviewers.includes("copilot"); - const otherReviewers = configReviewers.filter(r => r !== "copilot"); - - if (otherReviewers.length > 0 || configTeamReviewers.length > 0) { - core.info(`Requesting reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`); - try { - /** @type {{ owner: string, repo: string, pull_number: number, reviewers: string[], team_reviewers?: string[] }} */ - const reviewerRequest = { - owner: repoParts.owner, - repo: repoParts.repo, - pull_number: pullRequest.number, - reviewers: otherReviewers, - }; - if (configTeamReviewers.length > 0) { - reviewerRequest.team_reviewers = configTeamReviewers; + pull_number: pullRequest.number, + reviewers: otherReviewers, + }; + if (configTeamReviewers.length > 0) { + reviewerRequest.team_reviewers = configTeamReviewers; + } + await githubClient.rest.pulls.requestReviewers(reviewerRequest); + core.info(`Requested reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`); + } catch (reviewerError) { + core.warning(`Failed to request reviewers for PR #${pullRequest.number}: ${reviewerError instanceof Error ? reviewerError.message : String(reviewerError)}`); } - await githubClient.rest.pulls.requestReviewers(reviewerRequest); - core.info(`Requested reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`); - } catch (reviewerError) { - core.warning(`Failed to request reviewers for PR #${pullRequest.number}: ${reviewerError instanceof Error ? reviewerError.message : String(reviewerError)}`); } - } - if (hasCopilot) { - core.info(`Requesting copilot as reviewer for pull request #${pullRequest.number}`); - try { - await githubClient.rest.pulls.requestReviewers({ - owner: repoParts.owner, - repo: repoParts.repo, - pull_number: pullRequest.number, - reviewers: [COPILOT_REVIEWER_BOT], - }); - core.info(`Requested copilot as reviewer for pull request #${pullRequest.number}`); - } catch (copilotError) { - core.warning(`Failed to request copilot as reviewer for PR #${pullRequest.number}: ${copilotError instanceof Error ? copilotError.message : String(copilotError)}`); + if (hasCopilot) { + core.info(`Requesting copilot as reviewer for pull request #${pullRequest.number}`); + try { + await githubClient.rest.pulls.requestReviewers({ + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: pullRequest.number, + reviewers: [COPILOT_REVIEWER_BOT], + }); + core.info(`Requested copilot as reviewer for pull request #${pullRequest.number}`); + } catch (copilotError) { + core.warning(`Failed to request copilot as reviewer for PR #${pullRequest.number}: ${copilotError instanceof Error ? copilotError.message : String(copilotError)}`); + } } } - } - const requestChangesSections = []; - if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { - const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md"); - requestChangesSections.push( - renderTemplateFromFile(protectedFilesReviewTemplatePath, { - files: renderFilesList(manifestProtectionRequestReview), - }) - ); - } - if (detectionCaution) { - const detectionReason = process.env.GH_AW_DETECTION_REASON || "unknown"; - const detectionWarningReviewTemplatePath = getPromptPath("threat_warning_request_changes_review.md"); - requestChangesSections.push( - renderTemplateFromFile(detectionWarningReviewTemplatePath, { - detectionReason, - runUrl, - }) - ); - } - if (requestChangesSections.length > 0) { - const requestChangesBody = requestChangesSections.join("\n\n---\n\n"); - /** @type {{ owner: string, repo: string, pull_number: number, event: "REQUEST_CHANGES" | "COMMENT", body: string, commit_id?: string }} */ - const requestChangesParams = { - owner: repoParts.owner, - repo: repoParts.repo, - pull_number: pullRequest.number, - event: "REQUEST_CHANGES", - body: requestChangesBody, - }; - if (pullRequest.head && pullRequest.head.sha) { - requestChangesParams.commit_id = pullRequest.head.sha; + const requestChangesSections = []; + if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { + const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md"); + requestChangesSections.push( + renderTemplateFromFile(protectedFilesReviewTemplatePath, { + files: renderFilesList(manifestProtectionRequestReview), + }) + ); } - core.info(`Creating REQUEST_CHANGES review for PR #${pullRequest.number} due to protected files`); - try { - await withRetry(() => githubClient.rest.pulls.createReview(requestChangesParams), RATE_LIMIT_RETRY_CONFIG, `create REQUEST_CHANGES review for PR #${pullRequest.number}`); - core.info(`Created REQUEST_CHANGES review for PR #${pullRequest.number}`); - } catch (requestChangesError) { - const requestChangesErrorMessage = getErrorMessage(requestChangesError); - const ownPrMessages = ["Can not request changes on your own pull request"]; - if (ownPrMessages.some(msg => requestChangesErrorMessage.includes(msg))) { - core.warning(`Cannot submit REQUEST_CHANGES on own PR #${pullRequest.number}. Retrying with COMMENT.`); - try { - const commentReviewParams = { ...requestChangesParams, event: "COMMENT" }; - await withRetry(() => githubClient.rest.pulls.createReview(commentReviewParams), RATE_LIMIT_RETRY_CONFIG, `create COMMENT review fallback for PR #${pullRequest.number}`); - core.info(`Created COMMENT review fallback for PR #${pullRequest.number}`); - } catch (commentReviewError) { - core.warning(`Failed to create COMMENT review fallback for PR #${pullRequest.number}: ${commentReviewError instanceof Error ? commentReviewError.message : String(commentReviewError)}`); + if (detectionCaution) { + const detectionReason = process.env.GH_AW_DETECTION_REASON || "unknown"; + const detectionWarningReviewTemplatePath = getPromptPath("threat_warning_request_changes_review.md"); + requestChangesSections.push( + renderTemplateFromFile(detectionWarningReviewTemplatePath, { + detectionReason, + runUrl, + }) + ); + } + if (requestChangesSections.length > 0) { + const requestChangesBody = requestChangesSections.join("\n\n---\n\n"); + /** @type {{ owner: string, repo: string, pull_number: number, event: "REQUEST_CHANGES" | "COMMENT", body: string, commit_id?: string }} */ + const requestChangesParams = { + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: pullRequest.number, + event: "REQUEST_CHANGES", + body: requestChangesBody, + }; + if (pullRequest.head && pullRequest.head.sha) { + requestChangesParams.commit_id = pullRequest.head.sha; + } + core.info(`Creating REQUEST_CHANGES review for PR #${pullRequest.number} due to protected files`); + try { + await withRetry(() => githubClient.rest.pulls.createReview(requestChangesParams), RATE_LIMIT_RETRY_CONFIG, `create REQUEST_CHANGES review for PR #${pullRequest.number}`); + core.info(`Created REQUEST_CHANGES review for PR #${pullRequest.number}`); + } catch (requestChangesError) { + const requestChangesErrorMessage = getErrorMessage(requestChangesError); + const ownPrMessages = ["Can not request changes on your own pull request"]; + if (ownPrMessages.some(msg => requestChangesErrorMessage.includes(msg))) { + core.warning(`Cannot submit REQUEST_CHANGES on own PR #${pullRequest.number}. Retrying with COMMENT.`); + try { + const commentReviewParams = { ...requestChangesParams, event: "COMMENT" }; + await withRetry(() => githubClient.rest.pulls.createReview(commentReviewParams), RATE_LIMIT_RETRY_CONFIG, `create COMMENT review fallback for PR #${pullRequest.number}`); + core.info(`Created COMMENT review fallback for PR #${pullRequest.number}`); + } catch (commentReviewError) { + core.warning(`Failed to create COMMENT review fallback for PR #${pullRequest.number}: ${commentReviewError instanceof Error ? commentReviewError.message : String(commentReviewError)}`); + } + } else { + core.warning(`Failed to create REQUEST_CHANGES review for PR #${pullRequest.number}: ${requestChangesErrorMessage}`); } - } else { - core.warning(`Failed to create REQUEST_CHANGES review for PR #${pullRequest.number}: ${requestChangesErrorMessage}`); } } - } - // Enable auto-merge if configured - if (autoMerge) { - try { - await githubClient.graphql( - `mutation($prId: ID!) { + // Enable auto-merge if configured + if (autoMerge) { + try { + await githubClient.graphql( + `mutation($prId: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $prId}) { pullRequest { id } } }`, - { - prId: pullRequest.node_id, - } - ); - core.info(`Enabled auto-merge for pull request #${pullRequest.number}`); - } catch (autoMergeError) { - core.warning(`Failed to enable auto-merge for PR #${pullRequest.number}: ${autoMergeError instanceof Error ? autoMergeError.message : String(autoMergeError)}`); + { + prId: pullRequest.node_id, + } + ); + core.info(`Enabled auto-merge for pull request #${pullRequest.number}`); + } catch (autoMergeError) { + core.warning(`Failed to enable auto-merge for PR #${pullRequest.number}: ${autoMergeError instanceof Error ? autoMergeError.message : String(autoMergeError)}`); + } } - } - // Update the activation comment with PR link (if a comment was created) - // - // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created - // in the same repo as the activation, so the global client has the correct context for updating the comment. - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - - // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow) - if (closeOlderPullRequestsEnabled) { - if (workflowId || closeOlderKey) { - const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; - core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`); - try { - const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey); - if (closedPRs.length > 0) { - core.info(`Closed ${closedPRs.length} older pull request(s)`); + // Update the activation comment with PR link (if a comment was created) + // + // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created + // in the same repo as the activation, so the global client has the correct context for updating the comment. + await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); + + // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow) + if (closeOlderPullRequestsEnabled) { + if (workflowId || closeOlderKey) { + const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; + core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`); + try { + const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey); + if (closedPRs.length > 0) { + core.info(`Closed ${closedPRs.length} older pull request(s)`); + } + } catch (error) { + // Log error but don't fail the workflow + core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`); } - } catch (error) { - // Log error but don't fail the workflow - core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`); + } else { + core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping"); } - } else { - core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping"); } - } - // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` + // Write summary to GitHub Actions summary + await core.summary + .addRaw( + ` ## Pull Request - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - **Branch**: \`${branchName}\` - **Base Branch**: \`${baseBranch}\` ` - ) - .write(); - - // Push an extra empty commit if a token is configured and exactly 1 new commit was pushed. - // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events. - // Restricting to exactly 1 new commit prevents the CI trigger token being used on - // multi-commit branches where workflow files may have been iteratively modified. - const ciTriggerResult = await pushExtraEmptyCommit({ - branchName, - repoOwner: repoParts.owner, - repoName: repoParts.repo, - newCommitCount, - }); - if (ciTriggerResult.success && !ciTriggerResult.skipped) { - core.info("Extra empty commit pushed - CI checks should start shortly"); - } + ) + .write(); + + // Push an extra empty commit if a token is configured and exactly 1 new commit was pushed. + // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events. + // Restricting to exactly 1 new commit prevents the CI trigger token being used on + // multi-commit branches where workflow files may have been iteratively modified. + const ciTriggerResult = await pushExtraEmptyCommit({ + branchName, + repoOwner: repoParts.owner, + repoName: repoParts.repo, + newCommitCount, + }); + if (ciTriggerResult.success && !ciTriggerResult.skipped) { + core.info("Extra empty commit pushed - CI checks should start shortly"); + } - // Return success with PR details - return { - success: true, - number: pullRequest.number, - url: pullRequest.html_url, - managedBody: body, - branch_name: branchName, - temporaryId: temporaryId, - repo: itemRepo, - }; - } catch (prError) { - const errorMessage = prError instanceof Error ? prError.message : String(prError); - core.warning(`Failed to create pull request: ${errorMessage}`); + // Return success with PR details + return { + success: true, + number: pullRequest.number, + url: pullRequest.html_url, + managedBody: body, + branch_name: branchName, + temporaryId: temporaryId, + repo: itemRepo, + }; + } catch (prError) { + const errorMessage = prError instanceof Error ? prError.message : String(prError); + core.warning(`Failed to create pull request: ${errorMessage}`); + + // Check if the error is the specific "GitHub actions is not permitted to create or approve pull requests" error + if (errorMessage.includes("GitHub Actions is not permitted to create or approve pull requests")) { + core.error(`Permission error: GitHub Actions is not permitted to create or approve pull requests. See FAQ: ${FAQ_CREATE_PR_PERMISSIONS_URL}`); + + // Branch has already been pushed - create a fallback issue with a link to create the PR via GitHub UI + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + // Encode branch name path segments individually to preserve '/' while encoding other special characters + const encodedBase = baseBranch.split("/").map(encodeURIComponent).join("/"); + const encodedHead = branchName.split("/").map(encodeURIComponent).join("/"); + const createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; + + // Read patch content for preview + let patchPreview = ""; + if (patchFilePath && fs.existsSync(patchFilePath)) { + const patchContent = fs.readFileSync(patchFilePath, "utf8"); + patchPreview = generatePatchPreview(patchContent); + } + + const fallbackTemplatePath = getPromptPath("pr_permission_denied_fallback.md"); + const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, { + body: issueSafeBody, + branch_name: branchName, + create_pr_url: createPrUrl, + faq_url: FAQ_CREATE_PR_PERMISSIONS_URL, + patch_preview: patchPreview, + }); + + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); + + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + + return { + success: true, + fallback_used: true, + issue_number: issue.number, + issue_url: issue.html_url, + branch_name: branchName, + repo: itemRepo, + }; + } catch (issueError) { + const error = `Failed to create pull request (permission denied) and failed to create fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + core.error(error); + return { + success: false, + error, + error_type: "permission_denied", + }; + } + } - // Check if the error is the specific "GitHub actions is not permitted to create or approve pull requests" error - if (errorMessage.includes("GitHub Actions is not permitted to create or approve pull requests")) { - core.error(`Permission error: GitHub Actions is not permitted to create or approve pull requests. See FAQ: ${FAQ_CREATE_PR_PERMISSIONS_URL}`); + if (!fallbackAsIssue) { + // Fallback is disabled - return error without creating issue + core.error("fallback-as-issue is disabled - not creating fallback issue"); + return { + success: false, + error: errorMessage, + error_type: "pr_creation_failed", + }; + } + + core.info("Falling back to creating an issue instead"); - // Branch has already been pushed - create a fallback issue with a link to create the PR via GitHub UI + // Create issue as fallback with enhanced body content const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - // Encode branch name path segments individually to preserve '/' while encoding other special characters - const encodedBase = baseBranch.split("/").map(encodeURIComponent).join("/"); - const encodedHead = branchName.split("/").map(encodeURIComponent).join("/"); - const createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; + const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${repoParts.owner}/${repoParts.repo}/tree/${branchName}`; // Read patch content for preview let patchPreview = ""; @@ -2282,14 +2403,21 @@ ${patchPreview}`; patchPreview = generatePatchPreview(patchContent); } - const fallbackTemplatePath = getPromptPath("pr_permission_denied_fallback.md"); - const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, { - body: issueSafeBody, - branch_name: branchName, - create_pr_url: createPrUrl, - faq_url: FAQ_CREATE_PR_PERMISSIONS_URL, - patch_preview: patchPreview, - }); + const fallbackBody = `${issueSafeBody} + +--- + +> [!NOTE] +> This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). +> +> **Original error:** ${errorMessage} + +To create the pull request manually: + +\`\`\`sh +gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} +\`\`\` +${patchPreview}`; try { const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); @@ -2297,8 +2425,12 @@ ${patchPreview}`; core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + // Update the activation comment with issue link (if a comment was created) + // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created + // in the same repo as the activation, so the global client has the correct context for updating the comment. await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + // Return success with fallback flag return { success: true, fallback_used: true, @@ -2308,82 +2440,18 @@ ${patchPreview}`; repo: itemRepo, }; } catch (issueError) { - const error = `Failed to create pull request (permission denied) and failed to create fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + const error = `Failed to create both pull request and fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; core.error(error); return { success: false, error, - error_type: "permission_denied", }; } } - - if (!fallbackAsIssue) { - // Fallback is disabled - return error without creating issue - core.error("fallback-as-issue is disabled - not creating fallback issue"); - return { - success: false, - error: errorMessage, - error_type: "pr_creation_failed", - }; - } - - core.info("Falling back to creating an issue instead"); - - // Create issue as fallback with enhanced body content - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${repoParts.owner}/${repoParts.repo}/tree/${branchName}`; - - // Read patch content for preview - let patchPreview = ""; - if (patchFilePath && fs.existsSync(patchFilePath)) { - const patchContent = fs.readFileSync(patchFilePath, "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - - const fallbackBody = `${issueSafeBody} - ---- - -> [!NOTE] -> This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). -> -> **Original error:** ${errorMessage} - -To create the pull request manually: - -\`\`\`sh -gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} -\`\`\` -${patchPreview}`; - - try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); - - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - - // Update the activation comment with issue link (if a comment was created) - // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created - // in the same repo as the activation, so the global client has the correct context for updating the comment. - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - - // Return success with fallback flag - return { - success: true, - fallback_used: true, - issue_number: issue.number, - issue_url: issue.html_url, - branch_name: branchName, - repo: itemRepo, - }; - } catch (issueError) { - const error = `Failed to create both pull request and fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; - core.error(error); - return { - success: false, - error, - }; + } finally { + // Restore original working directory after multi-repo subdirectory operations + if (repoCwd) { + process.chdir(originalCwd); } } }; // End of handleCreatePullRequest diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index b99e1d28e79..15f247ef5bb 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -428,14 +428,28 @@ async function main(config = {}) { const workflowRepo = process.env.GITHUB_REPOSITORY || ""; if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) { core.info(`Cross-repo push: looking for checkout of ${itemRepo}`); - const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] }); - if (!checkoutResult.success) { - return { - success: false, - error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, - }; + // First try the checkout mapping (faster than scanning the workspace) + const checkoutMappingConfig = config.checkout_mapping || null; + if (checkoutMappingConfig) { + const targetLower = itemRepo.toLowerCase(); + const mappedPath = checkoutMappingConfig[targetLower]; + if (mappedPath) { + const path = require("path"); + repoCwd = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), mappedPath); + core.info(`Using checkout mapping: ${itemRepo} -> ${mappedPath}`); + } + } + // Fall back to workspace scan if not found in mapping + if (!repoCwd) { + const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] }); + if (!checkoutResult.success) { + return { + success: false, + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, + }; + } + repoCwd = checkoutResult.path; } - repoCwd = checkoutResult.path; core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`); } diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index fa9fb0a38c9..4c1b95782a8 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -96,8 +96,8 @@ If the base branch advances between agent start and `safe_outputs` apply, the PR An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution. -:::note[Single cross-repo target] -`safe_outputs` supports exactly **one** cross-repo target per run — the repository named in `target-repo`. Workflows that need to commit to multiple repositories in a single run are not currently supported. +:::note[Cross-repo targets] +When `target-repo` names a specific repository, `safe_outputs` checks out and applies changes to that single repository. When `target-repo: "*"` is used, the agent chooses the target repository at runtime and the `safe_outputs` job checks out **all** repositories listed in `checkout:` frontmatter into subdirectories (mirroring the agent job layout), enabling pull requests to multiple repositories in a single run. ::: ## Pull Request Updates (`update-pull-request:`) @@ -281,7 +281,7 @@ By default, pushes are replayed through GitHub's signed commit API because `sign ### Cross-repo usage -`push-to-pull-request-branch` supports pushing to pull requests in a different repository via `target-repo` (and optionally `allowed-repos`). When `target-repo` is set, **the target repository must be checked out into the workflow workspace** using the `checkout:` frontmatter field with a `path:` specified. +`push-to-pull-request-branch` supports pushing to pull requests in a different repository via `target-repo` (and optionally `allowed-repos`). When `target-repo` is set, **the target repository must be checked out into the workflow workspace** using the `checkout:` frontmatter field with a `path:` specified. Use `target-repo: "*"` to let the agent choose the target repository at runtime (the safe_outputs job will check out all `checkout:` repositories into subdirectories automatically). ```yaml wrap checkout: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5b2f2354a37..8855cc52f53 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6254,7 +6254,7 @@ }, "target-repo": { "type": "string", - "description": "Target repository in format 'owner/repo' for cross-repository pull request creation. Takes precedence over trial target repo settings." + "description": "Target repository in format 'owner/repo' for cross-repository pull request creation, or '*' to let the agent choose the target repository at runtime (requires checkout: configs with path: for each possible target). Takes precedence over trial target repo settings." }, "allowed-repos": { "description": "List of additional repositories in format 'owner/repo' that pull requests can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the pull request in. The target repository (current or target-repo) is always implicitly allowed. Accepts an array or a GitHub Actions expression resolving to a comma-separated list (e.g. '${{ inputs[\\'allowed-repos\\'] }}').", @@ -8040,7 +8040,7 @@ }, "target-repo": { "type": "string", - "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch. Takes precedence over trial target repo settings." + "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch, or '*' to let the agent choose the target repository at runtime (requires checkout: configs with path: for each possible target). Takes precedence over trial target repo settings." }, "allowed-repos": { "description": "List of additional repositories in format 'owner/repo' that push to pull request branch can target. When specified, the agent can use a 'repo' field in the output to specify which repository to push to. The target repository (current or target-repo) is always implicitly allowed. Accepts an array or a GitHub Actions expression resolving to a comma-separated list (e.g. '${{ inputs[\\'allowed-repos\\'] }}').", diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index b9c413bf5e2..0ac4f6ac765 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -2778,6 +2778,59 @@ func TestProtectTopLevelDotFolders(t *testing.T) { } } +func TestInjectCheckoutMappingForWildcardTargetRepo(t *testing.T) { + t.Run("injects mapping when target-repo is wildcard", func(t *testing.T) { + data := &WorkflowData{ + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "octocat/Hello-World", Path: "./hello-world"}, + {Repository: "octocat/Spoon-Knife", Path: "./spoon-knife"}, + }, + } + handlerCfg := map[string]any{"target-repo": "*"} + injectCheckoutMapping("create_pull_request", handlerCfg, data) + mapping, ok := handlerCfg["checkout_mapping"].(map[string]string) + require.True(t, ok, "checkout_mapping should be a map[string]string") + assert.Equal(t, "hello-world", mapping["octocat/hello-world"]) + assert.Equal(t, "spoon-knife", mapping["octocat/spoon-knife"]) + }) + + t.Run("skips when target-repo is not wildcard", func(t *testing.T) { + data := &WorkflowData{ + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "octocat/Hello-World", Path: "./hello-world"}, + }, + } + handlerCfg := map[string]any{"target-repo": "octocat/Hello-World"} + injectCheckoutMapping("create_pull_request", handlerCfg, data) + _, ok := handlerCfg["checkout_mapping"] + assert.False(t, ok, "checkout_mapping should not be injected for non-wildcard") + }) + + t.Run("skips wiki checkouts", func(t *testing.T) { + data := &WorkflowData{ + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "octocat/Hello-World", Path: "./hello-world", Wiki: true}, + }, + } + handlerCfg := map[string]any{"target-repo": "*"} + injectCheckoutMapping("create_pull_request", handlerCfg, data) + _, ok := handlerCfg["checkout_mapping"] + assert.False(t, ok, "checkout_mapping should not include wiki checkouts") + }) + + t.Run("skips unrelated handlers", func(t *testing.T) { + data := &WorkflowData{ + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "octocat/Hello-World", Path: "./hello-world"}, + }, + } + handlerCfg := map[string]any{"target-repo": "*"} + injectCheckoutMapping("create_issue", handlerCfg, data) + _, ok := handlerCfg["checkout_mapping"] + assert.False(t, ok, "checkout_mapping should not be injected for unrelated handlers") + }) +} + func TestHandlerConfigInjectsCurrentCheckoutPatchWorkspacePath(t *testing.T) { compiler := NewCompiler() workflowData := &WorkflowData{ diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 76fff0c7ae3..f8e1746e812 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -116,6 +116,16 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { consolidatedSafeOutputsStepsLog.Printf("Using trialLogicalRepoSlug: %s", targetRepoSlug) } + // Wildcard target-repo: the agent chooses the target repo at runtime. + // Instead of a single cross-repo checkout, emit checkout steps for ALL repos + // declared in checkout: configs (mirroring the agent job), so that any of them + // can be targeted by the agent's safe output messages at runtime. + // The JS handler uses findRepoCheckout() to locate the correct directory. + if targetRepoSlug == "*" { + consolidatedSafeOutputsStepsLog.Print("Wildcard target-repo: generating multi-repo checkout steps for safe_outputs") + return c.buildMultiRepoCheckoutSteps(data, checkoutMgr, checkoutToken, gitRemoteToken, condition) + } + // For cross-repo targets, override fetch-depth and sparse-checkout patterns // from the checkout: config entry that targets the same repository. The agent // job already uses these values; the safe_outputs job must mirror them so that @@ -265,6 +275,170 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { return steps } +// buildMultiRepoCheckoutSteps generates checkout steps for ALL repositories declared +// in the checkout: config, mirroring what the agent job does. This is used when +// target-repo is "*" (wildcard), meaning the agent decides at runtime which repository +// to target. Each repository is checked out to its configured path (or workspace root +// for the default checkout), so the JS handler can locate it via findRepoCheckout(). +// +// The git credential configuration step sets up authentication for ALL checked-out +// repositories, enabling push operations to any of them at runtime. +func (c *Compiler) buildMultiRepoCheckoutSteps(data *WorkflowData, checkoutMgr *CheckoutManager, checkoutToken, gitRemoteToken string, condition ConditionNode) []string { + var steps []string + conditionStr := RenderCondition(condition) + + // Step 1: Checkout the default (workspace root) repository. + // This mirrors the single-repo path but without a cross-repo repository: parameter. + defaultCheckout := checkoutMgr.GetDefaultCheckoutOverride() + defaultFetchDepth := 1 + var defaultSparsePatterns []string + if defaultCheckout != nil { + if defaultCheckout.fetchDepth != nil { + defaultFetchDepth = *defaultCheckout.fetchDepth + } + if len(defaultCheckout.sparsePatterns) > 0 { + defaultSparsePatterns = defaultCheckout.sparsePatterns + } + } + + // Checkout ref: use extracted base branch from agent output with event-context fallbacks. + // For comment-triggered privileged events, force checkout to trusted default branch. + const baseBranchFallbackExpr = "${{ (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && github.event.repository.default_branch || steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}" + steps = append(steps, " - name: Checkout repository\n") + steps = append(steps, fmt.Sprintf(" if: %s\n", conditionStr)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/checkout"))) + steps = append(steps, " with:\n") + steps = append(steps, fmt.Sprintf(" ref: %s\n", baseBranchFallbackExpr)) + steps = append(steps, fmt.Sprintf(" token: %s\n", checkoutToken)) + steps = append(steps, " persist-credentials: false\n") + steps = append(steps, fmt.Sprintf(" fetch-depth: %d\n", defaultFetchDepth)) + steps = appendSparseCheckoutLines(steps, defaultSparsePatterns) + + // Step 2: Checkout additional repositories from checkout: configs into their paths. + // Only include entries that have a non-empty repository and path (cross-repo checkouts). + for _, cfg := range data.CheckoutConfigs { + if cfg == nil || cfg.Repository == "" || cfg.Path == "" { + continue + } + if cfg.Wiki { + // Wiki checkouts are not relevant for PR/push operations. + continue + } + + entryFetchDepth := 1 + if cfg.FetchDepth != nil { + entryFetchDepth = *cfg.FetchDepth + } + var entrySparsePatterns []string + if cfg.SparseCheckout != "" { + entrySparsePatterns = strings.Split(cfg.SparseCheckout, "\n") + } + + // Use the safe-outputs token for authentication (consistent with single-repo path) + entryToken := checkoutToken + if cfg.GitHubToken != "" { + entryToken = cfg.GitHubToken + } + + steps = append(steps, fmt.Sprintf(" - name: Checkout %s into %s\n", cfg.Repository, cfg.Path)) + steps = append(steps, fmt.Sprintf(" if: %s\n", conditionStr)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/checkout"))) + steps = append(steps, " with:\n") + steps = append(steps, fmt.Sprintf(" repository: %s\n", cfg.Repository)) + steps = append(steps, fmt.Sprintf(" path: %s\n", cfg.Path)) + steps = append(steps, fmt.Sprintf(" token: %s\n", entryToken)) + steps = append(steps, " persist-credentials: false\n") + steps = append(steps, fmt.Sprintf(" fetch-depth: %d\n", entryFetchDepth)) + steps = appendSparseCheckoutLines(steps, entrySparsePatterns) + + consolidatedSafeOutputsStepsLog.Printf("Added multi-repo checkout: %s -> %s", cfg.Repository, cfg.Path) + } + + // Step 3: Configure Git credentials for ALL repositories. + // Set up authentication for the workspace root and each subdirectory checkout. + gitConfigSteps := []string{ + " - name: Configure Git credentials\n", + fmt.Sprintf(" if: %s\n", conditionStr), + " env:\n", + " REPO_NAME: ${{ github.repository }}\n", + " SERVER_URL: ${{ github.server_url }}\n", + fmt.Sprintf(" GIT_TOKEN: %s\n", gitRemoteToken), + " run: |\n", + " git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n", + " git config --global user.name \"github-actions[bot]\"\n", + " git config --global am.keepcr true\n", + " # Re-authenticate git with GitHub token for workspace root\n", + " SERVER_URL_STRIPPED=\"${SERVER_URL#https://}\"\n", + " git remote set-url origin \"https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git\"\n", + } + + // Also configure credentials for each subdirectory checkout + for _, cfg := range data.CheckoutConfigs { + if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki { + continue + } + gitConfigSteps = append(gitConfigSteps, + fmt.Sprintf(" # Re-authenticate git for %s\n", cfg.Repository), + fmt.Sprintf(" git -C \"%s\" remote set-url origin \"https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/%s.git\"\n", cfg.Path, cfg.Repository), + ) + } + + gitConfigSteps = append(gitConfigSteps, + " echo \"Git configured with standard GitHub Actions identity\"\n", + ) + steps = append(steps, gitConfigSteps...) + + // Step 4: Fetch additional refs for each repository that declares them. + for _, cfg := range data.CheckoutConfigs { + if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki { + continue + } + if entry := checkoutMgr.GetCheckoutForRepository(cfg.Repository); entry != nil && len(entry.fetchRefs) > 0 { + consolidatedSafeOutputsStepsLog.Printf("Adding fetch refs step for multi-repo target %s (%d refs)", cfg.Repository, len(entry.fetchRefs)) + if fetchStep := buildSafeOutputsMultiRepoFetchRefsStep(cfg.Repository, cfg.Path, checkoutToken, entry.fetchRefs, entry.fetchDepth, conditionStr); fetchStep != "" { + steps = append(steps, fetchStep) + } + } + } + + consolidatedSafeOutputsStepsLog.Printf("Added multi-repo checkout steps with condition: %s", condition.Render()) + return steps +} + +// buildSafeOutputsMultiRepoFetchRefsStep generates a conditional "Fetch additional refs" +// step for a repository checked out into a subdirectory (multi-repo wildcard scenario). +// Unlike buildSafeOutputsFetchRefsStep, this step targets a specific subdirectory via -C. +func buildSafeOutputsMultiRepoFetchRefsStep(repoSlug, path, token string, fetchRefs []string, fetchDepth *int, condition string) string { + if len(fetchRefs) == 0 { + return "" + } + refspecs := make([]string, 0, len(fetchRefs)) + for _, ref := range fetchRefs { + refspecs = append(refspecs, fmt.Sprintf("'%s'", fetchRefToRefspec(ref))) + } + + depthFlag := "" + effectiveDepth := 1 + if fetchDepth != nil { + effectiveDepth = *fetchDepth + } + if effectiveDepth > 0 { + depthFlag = fmt.Sprintf(" --depth=%d", effectiveDepth) + } + + var sb strings.Builder + fmt.Fprintf(&sb, " - name: Fetch additional refs for %s\n", repoSlug) + if condition != "" { + fmt.Fprintf(&sb, " if: %s\n", condition) + } + sb.WriteString(" env:\n") + fmt.Fprintf(&sb, " GH_AW_FETCH_TOKEN: %s\n", token) + sb.WriteString(" run: |\n") + sb.WriteString(" header=$(printf \"x-access-token:%s\" \"${GH_AW_FETCH_TOKEN}\" | base64 -w 0)\n") + fmt.Fprintf(&sb, " git -C \"%s\" -c \"http.extraheader=Authorization: Basic ${header}\" fetch origin%s %s\n", path, depthFlag, strings.Join(refspecs, " ")) + return sb.String() +} + // buildSafeOutputsFetchRefsStep generates a conditional "Fetch additional refs" step // for the safe_outputs job's cross-repo checkout. // diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 61988e50d66..eff15dda04a 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -1744,6 +1744,7 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow // 2. For auto-enabled handlers, include even with empty config if handlerConfig != nil { injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerConfig, data) + injectCheckoutMapping(handlerName, handlerConfig, data) // Augment protected-files protection with engine-specific files for handlers that use it. if _, hasProtected := handlerConfig["protected_files"]; hasProtected { // Extract per-handler exclusions set by the handler builder (sentinel key). diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 3d1427395c9..fb559eed388 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -40,6 +40,7 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { for handlerName, builder := range handlerRegistry { if handlerCfg := builder(data.SafeOutputs); handlerCfg != nil { injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerCfg, data) + injectCheckoutMapping(handlerName, handlerCfg, data) excludeFiles := ParseStringArrayFromConfig(handlerCfg, "_protected_files_exclude", nil) // Strip the internal sentinel key used by the handler manager for compile-time // exclusion processing — it must not be forwarded to the runtime config.json. diff --git a/pkg/workflow/safe_outputs_patch_workspace.go b/pkg/workflow/safe_outputs_patch_workspace.go index f431a43153b..e9a48687621 100644 --- a/pkg/workflow/safe_outputs_patch_workspace.go +++ b/pkg/workflow/safe_outputs_patch_workspace.go @@ -67,3 +67,51 @@ func normalizeCurrentCheckoutPatchPath(path string) string { } return filepath.ToSlash(path) } + +// injectCheckoutMapping adds a checkout_mapping to handler config for create_pull_request +// and push_to_pull_request_branch when target-repo is "*" (wildcard). +// The mapping tells the JS handler where each repository is checked out on disk, +// enabling it to operate on multiple repositories without dynamic git remote switching. +// +// The mapping is keyed by lowercase repo slug and values are relative paths within +// GITHUB_WORKSPACE for cross-repo checkouts (entries with repository + path). +func injectCheckoutMapping(handlerName string, handlerCfg map[string]any, data *WorkflowData) { + if handlerCfg == nil || data == nil { + return + } + if handlerName != "create_pull_request" && handlerName != "push_to_pull_request_branch" { + return + } + + // Only inject when target-repo is wildcard + targetRepo := "" + if value, ok := handlerCfg["target-repo"].(string); ok { + targetRepo = strings.TrimSpace(value) + } + if targetRepo != "*" { + return + } + + // Build the checkout mapping from checkout: configs + mapping := make(map[string]string) + for _, cfg := range data.CheckoutConfigs { + if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki { + continue + } + // Normalize repo slug to lowercase for consistent lookup + repoKey := strings.ToLower(strings.TrimSpace(cfg.Repository)) + normalizedPath := normalizeCurrentCheckoutPatchPath(cfg.Path) + if normalizedPath != "" { + mapping[repoKey] = normalizedPath + } + } + + // Only inject if there are actual cross-repo checkouts configured + if len(mapping) == 0 { + patchWorkspaceLog.Printf("No checkout mapping entries for handler=%s (wildcard target-repo but no cross-repo checkout: configs)", handlerName) + return + } + + handlerCfg["checkout_mapping"] = mapping + patchWorkspaceLog.Printf("Injected checkout_mapping for handler=%s: %d entries", handlerName, len(mapping)) +}