diff --git a/actions/setup/js/comment_memory.cjs b/actions/setup/js/comment_memory.cjs index 81889b6a90a..ffd71472f33 100644 --- a/actions/setup/js/comment_memory.cjs +++ b/actions/setup/js/comment_memory.cjs @@ -14,7 +14,7 @@ const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { enforceCommentLimits } = require("./comment_limit_helpers.cjs"); -const { COMMENT_MEMORY_TAG, COMMENT_MEMORY_MAX_SCAN_PAGES } = require("./comment_memory_helpers.cjs"); +const { COMMENT_MEMORY_TAG, COMMENT_MEMORY_MAX_SCAN_PAGES, COMMENT_MEMORY_CODE_FENCE } = require("./comment_memory_helpers.cjs"); // Require provenance marker to avoid accidentally updating user-authored comments // that happen to contain a matching comment-memory tag. const MANAGED_COMMENT_PROVENANCE_MARKER = ""; const COMMENT_MEMORY_PROMPT_END_MARKER = ""; +const COMMENT_MEMORY_CODE_FENCE = "``````"; +const ESCAPED_COMMENT_MEMORY_CODE_FENCE = COMMENT_MEMORY_CODE_FENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function stripCommentMemoryCodeFence(content) { + const trimmed = typeof content === "string" ? content.trim() : ""; + if (trimmed.length === 0) { + return ""; + } + if (!trimmed.startsWith(COMMENT_MEMORY_CODE_FENCE)) { + return trimmed; + } + const match = trimmed.match(new RegExp(`^${ESCAPED_COMMENT_MEMORY_CODE_FENCE}[^\\n]*\\n([\\s\\S]*)\\n${ESCAPED_COMMENT_MEMORY_CODE_FENCE}$`)); + if (!match) { + return trimmed; + } + return match[1].trim(); +} function isSafeMemoryId(memoryId) { if (typeof memoryId !== "string" || memoryId.length === 0 || memoryId.length > MAX_MEMORY_ID_LENGTH) { @@ -57,7 +74,7 @@ function extractCommentMemoryEntries(commentBody, warn = () => {}) { if (isSafeMemoryId(memoryId)) { entries.push({ memoryId, - content: (commentBody.slice(contentStart, closeStart) || "").trim(), + content: stripCommentMemoryCodeFence(commentBody.slice(contentStart, closeStart)), }); } else { warn(`skipping unsafe memory_id '${memoryId}'`); @@ -99,7 +116,9 @@ module.exports = { COMMENT_MEMORY_MAX_SCAN_EMPTY_PAGES, COMMENT_MEMORY_PROMPT_START_MARKER, COMMENT_MEMORY_PROMPT_END_MARKER, + COMMENT_MEMORY_CODE_FENCE, isSafeMemoryId, + stripCommentMemoryCodeFence, extractCommentMemoryEntries, listCommentMemoryFiles, resolveCommentMemoryConfig, diff --git a/actions/setup/js/comment_memory_helpers.test.cjs b/actions/setup/js/comment_memory_helpers.test.cjs index 0c7ef0d79a2..405929f9af3 100644 --- a/actions/setup/js/comment_memory_helpers.test.cjs +++ b/actions/setup/js/comment_memory_helpers.test.cjs @@ -1,12 +1,47 @@ import { describe, it, expect, vi } from "vitest"; -import { extractCommentMemoryEntries, isSafeMemoryId } from "./comment_memory_helpers.cjs"; +import { extractCommentMemoryEntries, isSafeMemoryId, stripCommentMemoryCodeFence } from "./comment_memory_helpers.cjs"; describe("comment_memory_helpers", () => { it("extracts managed memory entries", () => { + const entries = extractCommentMemoryEntries('\n``````\nhello\n``````\n'); + expect(entries).toEqual([{ memoryId: "default", content: "hello" }]); + }); + + it("supports legacy memory entries without code fence markers", () => { const entries = extractCommentMemoryEntries('\nhello\n'); expect(entries).toEqual([{ memoryId: "default", content: "hello" }]); }); + it("keeps fenced text unchanged when trailing content exists after closing fence", () => { + const content = "``````\nhello\n``````\ntrailing"; + expect(stripCommentMemoryCodeFence(content)).toBe(content); + }); + + it("keeps fenced text unchanged when closing fence is missing", () => { + const content = "``````\nhello"; + expect(stripCommentMemoryCodeFence(content)).toBe(content); + }); + + it("keeps malformed fenced text unchanged", () => { + const content = "``````hello\n``````"; + expect(stripCommentMemoryCodeFence(content)).toBe(content); + }); + + it("strips valid fenced text with extra newlines before content", () => { + const content = "``````\n\nhello\n``````"; + expect(stripCommentMemoryCodeFence(content)).toBe("hello"); + }); + + it("strips valid fenced text when content contains six-backtick lines", () => { + const content = "``````\nline 1\n``````\nline 2\n``````"; + expect(stripCommentMemoryCodeFence(content)).toBe("line 1\n``````\nline 2"); + }); + + it("keeps fenced text unchanged when closing fence has no leading newline", () => { + const content = "``````\nhello``````"; + expect(stripCommentMemoryCodeFence(content)).toBe(content); + }); + it("rejects unsafe memory IDs", () => { const warning = vi.fn(); const entries = extractCommentMemoryEntries('\nhello\n', warning); diff --git a/actions/setup/js/setup_comment_memory_files.test.cjs b/actions/setup/js/setup_comment_memory_files.test.cjs index 1580880cf30..be2bf764834 100644 --- a/actions/setup/js/setup_comment_memory_files.test.cjs +++ b/actions/setup/js/setup_comment_memory_files.test.cjs @@ -35,7 +35,7 @@ describe("setup_comment_memory_files", () => { it("extracts memory entries from managed comment body", async () => { const module = await import("./setup_comment_memory_files.cjs"); - const entries = module.extractCommentMemoryEntries('\nhello\n'); + const entries = module.extractCommentMemoryEntries('\n``````\nhello\n``````\n'); expect(entries).toEqual([{ memoryId: "default", content: "hello" }]); }); @@ -47,7 +47,7 @@ describe("setup_comment_memory_files", () => { listComments: vi.fn().mockResolvedValue({ data: [ { - body: '\nSaved memory\n\nfooter', + body: '\n``````\nSaved memory\n``````\n\nfooter', }, ], }), @@ -76,7 +76,7 @@ describe("setup_comment_memory_files", () => { }); } if (page === 6) { - return Promise.resolve({ data: [{ body: '\nLate memory\n' }] }); + return Promise.resolve({ data: [{ body: '\n``````\nLate memory\n``````\n' }] }); } return Promise.resolve({ data: [] }); }); @@ -156,7 +156,7 @@ describe("setup_comment_memory_files", () => { }) ); const listComments = vi.fn().mockResolvedValue({ - data: [{ body: '\nCross repo memory\n' }], + data: [{ body: '\n``````\nCross repo memory\n``````\n' }], }); global.github = { rest: { @@ -192,7 +192,7 @@ describe("setup_comment_memory_files", () => { }) ); const listComments = vi.fn().mockResolvedValue({ - data: [{ body: '\nSame repo memory\n' }], + data: [{ body: '\n``````\nSame repo memory\n``````\n' }], }); global.github = { rest: { diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index c6b739b93fc..f46eabc88ec 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) { // Spec: "Sets a field in frontmatter YAML" func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) { tests := []struct { - name string - content string - fieldName string - fieldValue string - wantErr bool + name string + content string + fieldName string + fieldValue string + wantErr bool checkContains string }{ {