diff --git a/.changeset/patch-refactor-pr-description-update.md b/.changeset/patch-refactor-pr-description-update.md new file mode 100644 index 00000000000..ac90440694f --- /dev/null +++ b/.changeset/patch-refactor-pr-description-update.md @@ -0,0 +1,13 @@ +--- +"gh-aw": patch +--- + +Refactor PR description updates: extract helper module, add `replace-island` mode for +idempotent PR description sections, make footer messages customizable via workflow +frontmatter, and add tests. + +This change introduces a new helper `update_pr_description_helpers.cjs`, a +`replace-island` operation mode that updates workflow-run-scoped islands in PR +descriptions, and customizable footer messages via `messages.footer` in the +workflow frontmatter. Tests were added for the helper and integration scenarios. + diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index a5f7ae9e010..375bb8f21f8 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -6926,271 +6926,8 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const fs = require("fs"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`πŸ“ ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - if (updateTarget === "*") { - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - updateData.body = item.body; - hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - return { hasUpdates, updateData, logMessages }; - } - async function runUpdateWorkflow(config) { - const { - itemType, - displayName, - displayNamePlural, - numberField, - outputNumberKey, - outputUrlKey, - isValidContext, - getContextNumber, - supportsStatus, - supportsOperation, - renderStagedItem, - executeUpdate, - getSummaryLine, - } = config; - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const updateItems = result.items.filter( item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - const updatedItems = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - for (const msg of logMessages) { - core.info(msg); - } - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - try { - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`βœ— Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - return function renderStagedItem(item, index) { - let content = `### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - function createGetSummaryLine(config) { - const { entityPrefix } = config; - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } + const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); + const { updatePRBody } = require("./update_pr_description_helpers.cjs"); function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || @@ -7220,7 +6957,7 @@ jobs: const operation = updateData._operation || "replace"; const rawBody = updateData._rawBody; const { _operation, _rawBody, ...apiData } = updateData; - if (rawBody !== undefined && (operation === "append" || operation === "prepend")) { + if (rawBody !== undefined && operation !== "replace") { const { data: currentPR } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, @@ -7229,16 +6966,14 @@ jobs: const currentBody = currentPR.body || ""; const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const aiFooter = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (operation === "prepend") { - const prependSection = `${rawBody}${aiFooter}\n\n---\n\n`; - apiData.body = prependSection + currentBody; - core.info("Operation: prepend (add to start with separator)"); - } else { - const appendSection = `\n\n---\n\n${rawBody}${aiFooter}`; - apiData.body = currentBody + appendSection; - core.info("Operation: append (add to end with separator)"); - } + apiData.body = updatePRBody({ + currentBody, + newContent: rawBody, + operation, + workflowName, + runUrl, + runId: context.runId, + }); core.info(`Will update body (length: ${apiData.body.length})`); } else if (rawBody !== undefined) { core.info("Operation: replace (full body replacement)"); diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index 686de2151b7..de40aa05766 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -7789,271 +7789,8 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`πŸ“ ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - if (updateTarget === "*") { - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - updateData.body = item.body; - hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - return { hasUpdates, updateData, logMessages }; - } - async function runUpdateWorkflow(config) { - const { - itemType, - displayName, - displayNamePlural, - numberField, - outputNumberKey, - outputUrlKey, - isValidContext, - getContextNumber, - supportsStatus, - supportsOperation, - renderStagedItem, - executeUpdate, - getSummaryLine, - } = config; - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const updateItems = result.items.filter( item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - const updatedItems = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - for (const msg of logMessages) { - core.info(msg); - } - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - try { - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`βœ— Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - return function renderStagedItem(item, index) { - let content = `### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - function createGetSummaryLine(config) { - const { entityPrefix } = config; - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } + const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); + const { updatePRBody } = require("./update_pr_description_helpers.cjs"); function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || @@ -8083,7 +7820,7 @@ jobs: const operation = updateData._operation || "replace"; const rawBody = updateData._rawBody; const { _operation, _rawBody, ...apiData } = updateData; - if (rawBody !== undefined && (operation === "append" || operation === "prepend")) { + if (rawBody !== undefined && operation !== "replace") { const { data: currentPR } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, @@ -8092,16 +7829,14 @@ jobs: const currentBody = currentPR.body || ""; const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const aiFooter = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (operation === "prepend") { - const prependSection = `${rawBody}${aiFooter}\n\n---\n\n`; - apiData.body = prependSection + currentBody; - core.info("Operation: prepend (add to start with separator)"); - } else { - const appendSection = `\n\n---\n\n${rawBody}${aiFooter}`; - apiData.body = currentBody + appendSection; - core.info("Operation: append (add to end with separator)"); - } + apiData.body = updatePRBody({ + currentBody, + newContent: rawBody, + operation, + workflowName, + runUrl, + runId: context.runId, + }); core.info(`Will update body (length: ${apiData.body.length})`); } else if (rawBody !== undefined) { core.info("Operation: replace (full body replacement)"); diff --git a/pkg/workflow/js/update_pr_description_helpers.cjs b/pkg/workflow/js/update_pr_description_helpers.cjs new file mode 100644 index 00000000000..67bb92f9971 --- /dev/null +++ b/pkg/workflow/js/update_pr_description_helpers.cjs @@ -0,0 +1,129 @@ +// @ts-check +/// + +/** + * Helper functions for updating pull request descriptions + * Handles append, prepend, replace, and replace-island operations + * @module update_pr_description_helpers + */ + +const { getFooterMessage } = require("./messages_footer.cjs"); + +/** + * Build the AI footer with workflow attribution + * Uses the messages system to support custom templates from frontmatter + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @returns {string} AI attribution footer + */ +function buildAIFooter(workflowName, runUrl) { + return "\n\n" + getFooterMessage({ workflowName, runUrl }); +} + +/** + * Build the island start marker for replace-island mode + * @param {number} runId - Workflow run ID + * @returns {string} Island start marker + */ +function buildIslandStartMarker(runId) { + return ``; +} + +/** + * Build the island end marker for replace-island mode + * @param {number} runId - Workflow run ID + * @returns {string} Island end marker + */ +function buildIslandEndMarker(runId) { + return ``; +} + +/** + * Find and extract island content from body + * @param {string} body - The body content to search + * @param {number} runId - Workflow run ID + * @returns {{found: boolean, startIndex: number, endIndex: number}} Island location info + */ +function findIsland(body, runId) { + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + + const startIndex = body.indexOf(startMarker); + if (startIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + + const endIndex = body.indexOf(endMarker, startIndex); + if (endIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + + return { found: true, startIndex, endIndex: endIndex + endMarker.length }; +} + +/** + * Update PR body with the specified operation + * @param {Object} params - Update parameters + * @param {string} params.currentBody - Current PR body content + * @param {string} params.newContent - New content to add/replace + * @param {string} params.operation - Operation type: "append", "prepend", "replace", or "replace-island" + * @param {string} params.workflowName - Name of the workflow + * @param {string} params.runUrl - URL of the workflow run + * @param {number} params.runId - Workflow run ID + * @returns {string} Updated body content + */ +function updatePRBody(params) { + const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; + const aiFooter = buildAIFooter(workflowName, runUrl); + + if (operation === "replace") { + // Replace: just use the new content as-is + core.info("Operation: replace (full body replacement)"); + return newContent; + } + + if (operation === "replace-island") { + // Try to find existing island for this run ID + const island = findIsland(currentBody, runId); + + if (island.found) { + // Replace the island content + core.info(`Operation: replace-island (updating existing island for run ${runId})`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + + const before = currentBody.substring(0, island.startIndex); + const after = currentBody.substring(island.endIndex); + return before + islandContent + after; + } else { + // Island not found, fall back to append mode + core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + const appendSection = `\n\n---\n\n${islandContent}`; + return currentBody + appendSection; + } + } + + if (operation === "prepend") { + // Prepend: add content, AI footer, and horizontal line at the start + core.info("Operation: prepend (add to start with separator)"); + const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; + return prependSection + currentBody; + } + + // Default to append + core.info("Operation: append (add to end with separator)"); + const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; + return currentBody + appendSection; +} + +module.exports = { + buildAIFooter, + buildIslandStartMarker, + buildIslandEndMarker, + findIsland, + updatePRBody, +}; diff --git a/pkg/workflow/js/update_pr_description_helpers.test.cjs b/pkg/workflow/js/update_pr_description_helpers.test.cjs new file mode 100644 index 00000000000..5b93d8fb047 --- /dev/null +++ b/pkg/workflow/js/update_pr_description_helpers.test.cjs @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock core for logging +const mockCore = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +}; + +global.core = mockCore; + +// Import the module +const { + buildAIFooter, + buildIslandStartMarker, + buildIslandEndMarker, + findIsland, + updatePRBody, +} = await import("./update_pr_description_helpers.cjs"); + +describe("update_pr_description_helpers.cjs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("buildAIFooter", () => { + it("should build AI footer with workflow name and run URL using messages system", () => { + const footer = buildAIFooter("Test Workflow", "https://github.com/owner/repo/actions/runs/123"); + // Should use the default pirate-themed footer from messages system + expect(footer).toContain("Test Workflow"); + expect(footer).toContain("https://github.com/owner/repo/actions/runs/123"); + expect(footer).toContain("Ahoy"); // Pirate theme + }); + + it("should handle special characters in workflow name", () => { + const footer = buildAIFooter("Test & Workflow", "https://github.com/owner/repo/actions/runs/123"); + expect(footer).toContain("Test & Workflow"); + }); + }); + + describe("buildIslandStartMarker", () => { + it("should build island start marker with run ID", () => { + const marker = buildIslandStartMarker(12345); + expect(marker).toBe(""); + }); + + it("should handle different run IDs", () => { + expect(buildIslandStartMarker(1)).toBe(""); + expect(buildIslandStartMarker(999999)).toBe(""); + }); + }); + + describe("buildIslandEndMarker", () => { + it("should build island end marker with run ID", () => { + const marker = buildIslandEndMarker(12345); + expect(marker).toBe(""); + }); + + it("should handle different run IDs", () => { + expect(buildIslandEndMarker(1)).toBe(""); + expect(buildIslandEndMarker(999999)).toBe(""); + }); + }); + + describe("findIsland", () => { + it("should find island when both markers are present", () => { + const body = "Before\n\nIsland content\n\nAfter"; + const result = findIsland(body, 123); + expect(result.found).toBe(true); + expect(result.startIndex).toBeGreaterThanOrEqual(0); + expect(result.endIndex).toBeGreaterThan(result.startIndex); + }); + + it("should not find island when start marker is missing", () => { + const body = "Before\nIsland content\n\nAfter"; + const result = findIsland(body, 123); + expect(result.found).toBe(false); + expect(result.startIndex).toBe(-1); + expect(result.endIndex).toBe(-1); + }); + + it("should not find island when end marker is missing", () => { + const body = "Before\n\nIsland content\nAfter"; + const result = findIsland(body, 123); + expect(result.found).toBe(false); + expect(result.startIndex).toBe(-1); + expect(result.endIndex).toBe(-1); + }); + + it("should not find island when run ID does not match", () => { + const body = "Before\n\nIsland content\n\nAfter"; + const result = findIsland(body, 456); + expect(result.found).toBe(false); + }); + + it("should handle multiple islands with different run IDs", () => { + const body = "\nIsland 1\n\n\nIsland 2\n"; + const result1 = findIsland(body, 100); + const result2 = findIsland(body, 200); + expect(result1.found).toBe(true); + expect(result2.found).toBe(true); + expect(result1.startIndex).toBeLessThan(result2.startIndex); + }); + }); + + describe("updatePRBody - replace operation", () => { + it("should replace entire body", () => { + const result = updatePRBody({ + currentBody: "Old content", + newContent: "New content", + operation: "replace", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toBe("New content"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("replace")); + }); + }); + + describe("updatePRBody - append operation", () => { + it("should append to empty body", () => { + const result = updatePRBody({ + currentBody: "", + newContent: "New content", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("---"); + expect(result).toContain("New content"); + // Check for footer elements (uses messages system with pirate theme by default) + expect(result).toContain("Test"); + expect(result).toContain("https://github.com/test/actions/runs/123"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("append")); + }); + + it("should append to existing body", () => { + const result = updatePRBody({ + currentBody: "Original content", + newContent: "New content", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Original content"); + expect(result).toContain("New content"); + expect(result.indexOf("Original content")).toBeLessThan(result.indexOf("New content")); + }); + + it("should preserve markdown formatting", () => { + const result = updatePRBody({ + currentBody: "# Title\n\n**Bold**", + newContent: "- List item", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("# Title"); + expect(result).toContain("**Bold**"); + expect(result).toContain("- List item"); + }); + }); + + describe("updatePRBody - prepend operation", () => { + it("should prepend to empty body", () => { + const result = updatePRBody({ + currentBody: "", + newContent: "New content", + operation: "prepend", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("---"); + expect(result).toContain("New content"); + // Check for footer elements (uses messages system) + expect(result).toContain("Test"); + expect(result).toContain("https://github.com/test/actions/runs/123"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("prepend")); + }); + + it("should prepend to existing body", () => { + const result = updatePRBody({ + currentBody: "Original content", + newContent: "New content", + operation: "prepend", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Original content"); + expect(result).toContain("New content"); + expect(result.indexOf("New content")).toBeLessThan(result.indexOf("Original content")); + }); + }); + + describe("updatePRBody - replace-island operation", () => { + it("should create new island when not found", () => { + const result = updatePRBody({ + currentBody: "Original content", + newContent: "Island content", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Original content"); + expect(result).toContain("Island content"); + expect(result).toContain(""); + expect(result).toContain(""); + // Check for footer elements (uses messages system) + expect(result).toContain("Test"); + expect(result).toContain("https://github.com/test/actions/runs/123"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("falling back to append")); + }); + + it("should replace existing island content", () => { + const currentBody = "Before\n\nOld island\n\nAfter"; + const result = updatePRBody({ + currentBody, + newContent: "New island", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Before"); + expect(result).toContain("After"); + expect(result).toContain("New island"); + expect(result).not.toContain("Old island"); + expect(result).toContain(""); + expect(result).toContain(""); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("updating existing island")); + }); + + it("should preserve content outside island when replacing", () => { + const currentBody = "# Title\n\nSome intro\n\n\nOld\n\n\n## Footer\n\nMore content"; + const result = updatePRBody({ + currentBody, + newContent: "Updated content", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("# Title"); + expect(result).toContain("Some intro"); + expect(result).toContain("## Footer"); + expect(result).toContain("More content"); + expect(result).toContain("Updated content"); + expect(result).not.toContain("Old"); + }); + + it("should not replace island with different run ID", () => { + const currentBody = "Before\n\nOther island\n\nAfter"; + const result = updatePRBody({ + currentBody, + newContent: "New island", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + // Should append because island 123 doesn't exist + expect(result).toContain("Other island"); + expect(result).toContain("New island"); + expect(result).toContain(""); + expect(result).toContain(""); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("falling back to append")); + }); + + it("should handle multiple islands with same run ID (replace first)", () => { + const currentBody = "\nFirst\n\n\nSecond\n"; + const result = updatePRBody({ + currentBody, + newContent: "Replaced", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Replaced"); + // First island should be replaced + expect(result.indexOf("Replaced")).toBeLessThan(result.indexOf("Second")); + }); + + it("should handle empty island content", () => { + const currentBody = "Before\n\n\n\nAfter"; + const result = updatePRBody({ + currentBody, + newContent: "New content", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("New content"); + expect(result).toContain("Before"); + expect(result).toContain("After"); + }); + + it("should handle special characters in island content", () => { + const currentBody = "\nOld\n"; + const result = updatePRBody({ + currentBody, + newContent: "Content with **markdown**, `code`, [links](http://example.com)", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("**markdown**"); + expect(result).toContain("`code`"); + expect(result).toContain("[links](http://example.com)"); + }); + + it("should handle newlines and whitespace correctly", () => { + const currentBody = "\n \n\nOld\n\n \n"; + const result = updatePRBody({ + currentBody, + newContent: "New\n\nMultiline\n\nContent", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("New\n\nMultiline\n\nContent"); + }); + }); + + describe("updatePRBody - edge cases", () => { + it("should handle empty new content", () => { + const result = updatePRBody({ + currentBody: "Original", + newContent: "", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Original"); + // Check for footer elements + expect(result).toContain("Test"); + }); + + it("should handle empty current body", () => { + const result = updatePRBody({ + currentBody: "", + newContent: "New", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("New"); + }); + + it("should handle unicode characters", () => { + const result = updatePRBody({ + currentBody: "Original δ½ ε₯½", + newContent: "New δΈ–η•Œ πŸš€", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("δ½ ε₯½"); + expect(result).toContain("δΈ–η•Œ πŸš€"); + }); + + it("should handle very long content", () => { + const longContent = "A".repeat(10000); + const result = updatePRBody({ + currentBody: "Original", + newContent: longContent, + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain(longContent); + expect(result.length).toBeGreaterThan(10000); + }); + + it("should handle default to append for unknown operation", () => { + const result = updatePRBody({ + currentBody: "Original", + newContent: "New", + operation: "unknown", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + }); + expect(result).toContain("Original"); + expect(result).toContain("New"); + expect(result).toContain("---"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("append")); + }); + }); +}); diff --git a/pkg/workflow/js/update_pull_request.cjs b/pkg/workflow/js/update_pull_request.cjs index 54d38a5b8e1..1188b705f0b 100644 --- a/pkg/workflow/js/update_pull_request.cjs +++ b/pkg/workflow/js/update_pull_request.cjs @@ -2,6 +2,7 @@ /// const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); +const { updatePRBody } = require("./update_pr_description_helpers.cjs"); /** * Check if the current context is a valid pull request context @@ -56,16 +57,16 @@ const renderStagedItem = createRenderStagedItem({ * @returns {Promise} Updated pull request */ async function executePRUpdate(github, context, prNumber, updateData) { - // Handle body operation (append/prepend/replace) + // Handle body operation (append/prepend/replace/replace-island) const operation = updateData._operation || "replace"; const rawBody = updateData._rawBody; // Remove internal fields const { _operation, _rawBody, ...apiData } = updateData; - // If we have a body with append/prepend operation, handle it - if (rawBody !== undefined && (operation === "append" || operation === "prepend")) { - // Fetch current PR body + // If we have a body with operation, handle it + if (rawBody !== undefined && operation !== "replace") { + // Fetch current PR body for operations that need it const { data: currentPR } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, @@ -77,20 +78,16 @@ async function executePRUpdate(github, context, prNumber, updateData) { const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - // Build the AI footer - const aiFooter = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - if (operation === "prepend") { - // Prepend: add content, AI footer, and horizontal line at the start - const prependSection = `${rawBody}${aiFooter}\n\n---\n\n`; - apiData.body = prependSection + currentBody; - core.info("Operation: prepend (add to start with separator)"); - } else { - // Append: add horizontal line, content, and AI footer at the end - const appendSection = `\n\n---\n\n${rawBody}${aiFooter}`; - apiData.body = currentBody + appendSection; - core.info("Operation: append (add to end with separator)"); - } + // Use helper to update body + apiData.body = updatePRBody({ + currentBody, + newContent: rawBody, + operation, + workflowName, + runUrl, + runId: context.runId, + }); + core.info(`Will update body (length: ${apiData.body.length})`); } else if (rawBody !== undefined) { // Replace: just use the new content as-is (already in apiData.body) diff --git a/pkg/workflow/js/update_pull_request.test.cjs b/pkg/workflow/js/update_pull_request.test.cjs index 3308de75b00..47a93febeab 100644 --- a/pkg/workflow/js/update_pull_request.test.cjs +++ b/pkg/workflow/js/update_pull_request.test.cjs @@ -614,4 +614,123 @@ describe("update_pull_request.cjs - executePRUpdate function", () => { expect(expectedBody).toContain("GitHub Agentic Workflow"); }); }); + + describe("Replace-island operation", () => { + it("should create new island when not found", async () => { + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: "Original content", + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const newContent = "Island content"; + const expectedBody = `Original content\n\n---\n\n\n${newContent}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n`; + + expect(expectedBody).toContain("Original content"); + expect(expectedBody).toContain("Island content"); + expect(expectedBody).toContain(""); + expect(expectedBody).toContain(""); + }); + + it("should replace existing island content", async () => { + const existingBody = "Before\n\nOld island\n\nAfter"; + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: existingBody, + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const newContent = "New island"; + const expectedBody = `Before\n\n${newContent}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n\nAfter`; + + expect(expectedBody).toContain("Before"); + expect(expectedBody).toContain("After"); + expect(expectedBody).toContain("New island"); + expect(expectedBody).not.toContain("Old island"); + }); + + it("should preserve content outside island", async () => { + const existingBody = "# Title\n\n\nOld\n\n\n## Footer"; + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: existingBody, + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const newContent = "Updated"; + const expectedBody = `# Title\n\n\n${newContent}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n\n\n## Footer`; + + expect(expectedBody).toContain("# Title"); + expect(expectedBody).toContain("## Footer"); + expect(expectedBody).toContain("Updated"); + expect(expectedBody).not.toContain("Old"); + }); + + it("should not replace island with different run ID", async () => { + const existingBody = "\nOther island\n"; + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: existingBody, + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const newContent = "New island"; + // Should append because island with run ID 12345 doesn't exist + const expectedBody = `${existingBody}\n\n---\n\n\n${newContent}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n`; + + expect(expectedBody).toContain("Other island"); + expect(expectedBody).toContain("New island"); + expect(expectedBody).toContain(""); + expect(expectedBody).toContain(""); + }); + + it("should allow multiple updates to same island", async () => { + // First update creates the island + const initialBody = "Original content"; + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: initialBody, + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const firstUpdate = "First update"; + const bodyAfterFirst = `${initialBody}\n\n---\n\n\n${firstUpdate}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n`; + + // Second update replaces the island + mockGithub.rest.pulls.get.mockResolvedValueOnce({ + data: { + number: 100, + title: "Test PR", + body: bodyAfterFirst, + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + const secondUpdate = "Second update"; + const bodyAfterSecond = `${initialBody}\n\n---\n\n\n${secondUpdate}\n\n> AI generated by [Test Workflow](https://github.com/testowner/testrepo/actions/runs/12345)\n`; + + // Should only have one island with the latest content + expect(bodyAfterSecond).toContain("Original content"); + expect(bodyAfterSecond).toContain("Second update"); + expect(bodyAfterSecond).not.toContain("First update"); + // Should only have one island marker pair + const startMarkerCount = (bodyAfterSecond.match(//g) || []).length; + expect(startMarkerCount).toBe(1); + }); + }); }); diff --git a/pkg/workflow/scripts.go b/pkg/workflow/scripts.go index ed35403cadf..fa1cd1d1f08 100644 --- a/pkg/workflow/scripts.go +++ b/pkg/workflow/scripts.go @@ -70,6 +70,9 @@ var updateIssueScriptSource string //go:embed js/update_pull_request.cjs var updatePullRequestScriptSource string +//go:embed js/update_pr_description_helpers.cjs +var updatePRDescriptionHelpersScriptSource string + //go:embed js/update_release.cjs var updateReleaseScriptSource string @@ -147,6 +150,7 @@ func init() { DefaultScriptRegistry.Register("close_pull_request", closePullRequestScriptSource) DefaultScriptRegistry.Register("update_issue", updateIssueScriptSource) DefaultScriptRegistry.Register("update_pull_request", updatePullRequestScriptSource) + DefaultScriptRegistry.Register("update_pr_description_helpers", updatePRDescriptionHelpersScriptSource) DefaultScriptRegistry.Register("update_release", updateReleaseScriptSource) DefaultScriptRegistry.Register("create_code_scanning_alert", createCodeScanningAlertScriptSource) DefaultScriptRegistry.Register("create_pr_review_comment", createPRReviewCommentScriptSource)