From 950e7998eb4fd40587c90e56cf9cc4a49dbf9283 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 7 Dec 2025 13:42:43 +0000
Subject: [PATCH 1/5] Initial plan
From 81fa8a03c338ade07acf70b06d8f83e8122a714f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 7 Dec 2025 13:50:58 +0000
Subject: [PATCH 2/5] Add PR description helper with replace-island mode
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../js/update_pr_description_helpers.cjs | 126 ++++++
.../js/update_pr_description_helpers.test.cjs | 395 ++++++++++++++++++
pkg/workflow/js/update_pull_request.cjs | 33 +-
pkg/workflow/js/update_pull_request.test.cjs | 119 ++++++
pkg/workflow/scripts.go | 4 +
5 files changed, 659 insertions(+), 18 deletions(-)
create mode 100644 pkg/workflow/js/update_pr_description_helpers.cjs
create mode 100644 pkg/workflow/js/update_pr_description_helpers.test.cjs
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..3d4aedf40ae
--- /dev/null
+++ b/pkg/workflow/js/update_pr_description_helpers.cjs
@@ -0,0 +1,126 @@
+// @ts-check
+///
+
+/**
+ * Helper functions for updating pull request descriptions
+ * Handles append, prepend, replace, and replace-island operations
+ * @module update_pr_description_helpers
+ */
+
+/**
+ * Build the AI footer with workflow attribution
+ * @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> AI generated by [${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..e6af3a69541
--- /dev/null
+++ b/pkg/workflow/js/update_pr_description_helpers.test.cjs
@@ -0,0 +1,395 @@
+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", () => {
+ const footer = buildAIFooter("Test Workflow", "https://github.com/owner/repo/actions/runs/123");
+ expect(footer).toBe("\n\n> AI generated by [Test Workflow](https://github.com/owner/repo/actions/runs/123)");
+ });
+
+ 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");
+ expect(result).toContain("> AI generated by");
+ 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");
+ expect(result).toContain("> AI generated by");
+ 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("");
+ expect(result).toContain("> AI generated by");
+ 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");
+ expect(result).toContain("> AI generated by");
+ });
+
+ 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)
From f5955bce542c0e20a4b710f2394e8e12b6de1103 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 7 Dec 2025 13:56:45 +0000
Subject: [PATCH 3/5] Run recompile to update lock files with bundled helper
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/changeset.lock.yml | 287 +-----------------
.github/workflows/release.lock.yml | 6 +-
.../smoke-copilot-no-firewall.lock.yml | 287 +-----------------
3 files changed, 25 insertions(+), 555 deletions(-)
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/release.lock.yml b/.github/workflows/release.lock.yml
index bfddcfddf97..0ba6e35b975 100644
--- a/.github/workflows/release.lock.yml
+++ b/.github/workflows/release.lock.yml
@@ -5973,19 +5973,19 @@ jobs:
- name: Download Go modules
run: go mod download
- name: Generate SBOM (SPDX format)
- uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0
+ uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10
with:
artifact-name: sbom.spdx.json
format: spdx-json
output-file: sbom.spdx.json
- name: Generate SBOM (CycloneDX format)
- uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0
+ uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10
with:
artifact-name: sbom.cdx.json
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Upload SBOM artifacts
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: sbom-artifacts
path: |
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)");
From ce758773d90c7d7af0fc421c6721061d7b4a4795 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 7 Dec 2025 14:21:41 +0000
Subject: [PATCH 4/5] Use messages system for customizable footer in PR updates
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/release.lock.yml | 6 ++---
.../js/update_pr_description_helpers.cjs | 5 ++++-
.../js/update_pr_description_helpers.test.cjs | 22 ++++++++++++++-----
3 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml
index 0ba6e35b975..bfddcfddf97 100644
--- a/.github/workflows/release.lock.yml
+++ b/.github/workflows/release.lock.yml
@@ -5973,19 +5973,19 @@ jobs:
- name: Download Go modules
run: go mod download
- name: Generate SBOM (SPDX format)
- uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10
+ uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0
with:
artifact-name: sbom.spdx.json
format: spdx-json
output-file: sbom.spdx.json
- name: Generate SBOM (CycloneDX format)
- uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10
+ uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0
with:
artifact-name: sbom.cdx.json
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Upload SBOM artifacts
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: sbom-artifacts
path: |
diff --git a/pkg/workflow/js/update_pr_description_helpers.cjs b/pkg/workflow/js/update_pr_description_helpers.cjs
index 3d4aedf40ae..67bb92f9971 100644
--- a/pkg/workflow/js/update_pr_description_helpers.cjs
+++ b/pkg/workflow/js/update_pr_description_helpers.cjs
@@ -7,14 +7,17 @@
* @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> AI generated by [${workflowName}](${runUrl})`;
+ return "\n\n" + getFooterMessage({ workflowName, runUrl });
}
/**
diff --git a/pkg/workflow/js/update_pr_description_helpers.test.cjs b/pkg/workflow/js/update_pr_description_helpers.test.cjs
index e6af3a69541..5b93d8fb047 100644
--- a/pkg/workflow/js/update_pr_description_helpers.test.cjs
+++ b/pkg/workflow/js/update_pr_description_helpers.test.cjs
@@ -25,9 +25,12 @@ describe("update_pr_description_helpers.cjs", () => {
});
describe("buildAIFooter", () => {
- it("should build AI footer with workflow name and run URL", () => {
+ 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");
- expect(footer).toBe("\n\n> AI generated by [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", () => {
@@ -128,7 +131,9 @@ describe("update_pr_description_helpers.cjs", () => {
});
expect(result).toContain("---");
expect(result).toContain("New content");
- expect(result).toContain("> AI generated by");
+ // 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"));
});
@@ -173,7 +178,9 @@ describe("update_pr_description_helpers.cjs", () => {
});
expect(result).toContain("---");
expect(result).toContain("New content");
- expect(result).toContain("> AI generated by");
+ // 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"));
});
@@ -206,7 +213,9 @@ describe("update_pr_description_helpers.cjs", () => {
expect(result).toContain("Island content");
expect(result).toContain("");
expect(result).toContain("");
- expect(result).toContain("> AI generated by");
+ // 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"));
});
@@ -335,7 +344,8 @@ describe("update_pr_description_helpers.cjs", () => {
runId: 123,
});
expect(result).toContain("Original");
- expect(result).toContain("> AI generated by");
+ // Check for footer elements
+ expect(result).toContain("Test");
});
it("should handle empty current body", () => {
From 381205dd4f1add8eb778c64555a831efc3bd73fb Mon Sep 17 00:00:00 2001
From: gh-aw-bot
Date: Sun, 7 Dec 2025 14:27:49 +0000
Subject: [PATCH 5/5] Add changeset [skip-ci]
---
.changeset/patch-refactor-pr-description-update.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 .changeset/patch-refactor-pr-description-update.md
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.
+