diff --git a/.changeset/patch-increase-copilot-headers.md b/.changeset/patch-increase-copilot-headers.md new file mode 100644 index 0000000000..c715ec5ce6 --- /dev/null +++ b/.changeset/patch-increase-copilot-headers.md @@ -0,0 +1,10 @@ +--- +"gh-aw": patch +--- + +Increase markdown header levels by 1 for Copilot `conversation.md` outputs +before writing them to GitHub Actions step summaries. This change adds a +JavaScript transformer (used in the Copilot log parser), associated tests, +and integration wiring. This is an internal tooling change and includes +comprehensive tests; it does not introduce breaking changes. + diff --git a/actions/setup/js/log_parser_bootstrap.cjs b/actions/setup/js/log_parser_bootstrap.cjs index 6667b3d513..172125c4f8 100644 --- a/actions/setup/js/log_parser_bootstrap.cjs +++ b/actions/setup/js/log_parser_bootstrap.cjs @@ -50,10 +50,15 @@ async function runLogParser(options) { core.info(`Found conversation.md generated by --share flag, using it for step summary preview`); content = fs.readFileSync(conversationMdPath, "utf8"); + // Transform markdown to increase header levels by 1 + // This adjusts the conversation.md headers (# to ##, etc.) for better display in step summary + const { increaseHeaderLevel } = require("./markdown_transformer.cjs"); + const transformedContent = increaseHeaderLevel(content); + // Mark this content as already markdown formatted // We'll need to adjust the parser to handle this const result = { - markdown: content, + markdown: transformedContent, isPreformatted: true, logEntries: [], }; diff --git a/actions/setup/js/log_parser_bootstrap.test.cjs b/actions/setup/js/log_parser_bootstrap.test.cjs index 19bf4c411f..9007de6e78 100644 --- a/actions/setup/js/log_parser_bootstrap.test.cjs +++ b/actions/setup/js/log_parser_bootstrap.test.cjs @@ -203,6 +203,40 @@ describe("log_parser_bootstrap.cjs", () => { (fs.writeFileSync(logFile, "content"), (process.env.GH_AW_AGENT_OUTPUT = logFile), (process.env.GH_AW_SAFE_OUTPUTS = "/non/existent/file.jsonl")); const mockParseLog = vi.fn().mockReturnValue({ markdown: "## Result\n", mcpFailures: [], maxTurnsHit: false }); (runLogParser({ parseLog: mockParseLog, parserName: "TestParser" }), expect(mockCore.warning).not.toHaveBeenCalled(), fs.unlinkSync(logFile), fs.rmdirSync(tmpDir)); + }), + it("should transform conversation.md headers for Copilot parser", () => { + const tmpDir = fs.mkdtempSync(path.join(__dirname, "test-")); + const conversationMd = path.join(tmpDir, "conversation.md"); + const conversationContent = `# Main Title + +## Section 1 + +Some content here. + +### Subsection + +More content.`; + + fs.writeFileSync(conversationMd, conversationContent); + process.env.GH_AW_AGENT_OUTPUT = tmpDir; + + const mockParseLog = vi.fn(); + runLogParser({ parseLog: mockParseLog, parserName: "Copilot", supportsDirectories: true }); + + // Should transform headers (# to ##, ## to ###, etc.) + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + expect(summaryCall).toBeDefined(); + expect(summaryCall[0]).toContain("## Main Title"); + expect(summaryCall[0]).toContain("### Section 1"); + expect(summaryCall[0]).toContain("#### Subsection"); + // Verify the original header level was transformed (check start of line) + expect(summaryCall[0].split("\n")[0]).toBe("## Main Title"); + + // Parser should not be called since conversation.md is used directly + expect(mockParseLog).not.toHaveBeenCalled(); + + fs.unlinkSync(conversationMd); + fs.rmdirSync(tmpDir); })); })); }); diff --git a/actions/setup/js/markdown_transformer.cjs b/actions/setup/js/markdown_transformer.cjs new file mode 100644 index 0000000000..0f99324da0 --- /dev/null +++ b/actions/setup/js/markdown_transformer.cjs @@ -0,0 +1,84 @@ +// @ts-check + +/** + * Transform markdown by increasing header levels by 1 + * (# becomes ##, ## becomes ###, etc.) + * + * This transformer is used to adjust the markdown generated by Copilot CLI + * conversation.md when displaying it in GitHub Actions step summaries. + * + * Rules: + * - Only transforms ATX-style headers (# prefix) + * - Preserves whitespace and indentation + * - Does not transform headers in code blocks + * - Does not transform headers in inline code + * - Maximum header level is 6 (h6) + * - Uses regex for efficient processing + * + * @param {string} markdown - Markdown content to transform + * @returns {string} Transformed markdown with increased header levels + */ +function increaseHeaderLevel(markdown) { + if (!markdown || typeof markdown !== "string") { + return markdown || ""; + } + + // Normalize line endings to \n for consistent processing + const normalizedMarkdown = markdown.replace(/\r\n/g, "\n"); + + // Split into lines for processing + const lines = normalizedMarkdown.split("\n"); + const result = []; + let inCodeBlock = false; + let codeBlockFence = ""; + + for (const line of lines) { + // Track code block state (fenced code blocks with ``` or ~~~) + const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})/); + if (fenceMatch) { + if (!inCodeBlock) { + // Starting a code block + inCodeBlock = true; + codeBlockFence = fenceMatch[2][0]; // Get the fence character (` or ~) + } else if (line.includes(codeBlockFence.repeat(3))) { + // Ending a code block (must have at least 3 fence chars) + inCodeBlock = false; + codeBlockFence = ""; + } + result.push(line); + continue; + } + + // If we're in a code block, don't transform + if (inCodeBlock) { + result.push(line); + continue; + } + + // Check if this line is an ATX-style header (# prefix) + // Pattern: optional whitespace, 1-6 #, at least one space, then content + const headerMatch = line.match(/^(\s*)(#{1,6})(\s+.*)$/); + if (headerMatch) { + const indent = headerMatch[1]; + const hashes = headerMatch[2]; + const content = headerMatch[3]; + + // Only increase if we're below h6 (6 hashes) + if (hashes.length < 6) { + result.push(`${indent}#${hashes}${content}`); + } else { + // Already at max level, keep as-is + result.push(line); + } + } else { + // Not a header, keep as-is + result.push(line); + } + } + + return result.join("\n"); +} + +module.exports = { + increaseHeaderLevel, +}; diff --git a/actions/setup/js/markdown_transformer.test.cjs b/actions/setup/js/markdown_transformer.test.cjs new file mode 100644 index 0000000000..fa675abceb --- /dev/null +++ b/actions/setup/js/markdown_transformer.test.cjs @@ -0,0 +1,487 @@ +import { describe, it, expect } from "vitest"; + +describe("markdown_transformer.cjs", () => { + let markdownTransformer; + + beforeEach(async () => { + markdownTransformer = await import("./markdown_transformer.cjs"); + }); + + describe("increaseHeaderLevel", () => { + it("should transform h1 to h2", () => { + const input = "# Header 1"; + const expected = "## Header 1"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should transform h2 to h3", () => { + const input = "## Header 2"; + const expected = "### Header 2"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should transform h3 to h4", () => { + const input = "### Header 3"; + const expected = "#### Header 3"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should transform h4 to h5", () => { + const input = "#### Header 4"; + const expected = "##### Header 4"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should transform h5 to h6", () => { + const input = "##### Header 5"; + const expected = "###### Header 5"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform h6 (max level)", () => { + const input = "###### Header 6"; + const expected = "###### Header 6"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should transform multiple headers in sequence", () => { + const input = `# Title +## Section +### Subsection +#### Detail`; + const expected = `## Title +### Section +#### Subsection +##### Detail`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should preserve non-header lines", () => { + const input = `# Header + +This is a paragraph. + +## Another Header + +More text here.`; + const expected = `## Header + +This is a paragraph. + +### Another Header + +More text here.`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform headers in fenced code blocks (backticks)", () => { + const input = `# Real Header + +\`\`\` +# Fake Header +## Another Fake +\`\`\` + +## Real Header 2`; + const expected = `## Real Header + +\`\`\` +# Fake Header +## Another Fake +\`\`\` + +### Real Header 2`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform headers in fenced code blocks (tildes)", () => { + const input = `# Real Header + +~~~ +# Fake Header +## Another Fake +~~~ + +## Real Header 2`; + const expected = `## Real Header + +~~~ +# Fake Header +## Another Fake +~~~ + +### Real Header 2`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform headers in code blocks with language specifier", () => { + const input = `# Real Header + +\`\`\`markdown +# Fake Header in Markdown +## Another Fake +\`\`\` + +## Real Header 2`; + const expected = `## Real Header + +\`\`\`markdown +# Fake Header in Markdown +## Another Fake +\`\`\` + +### Real Header 2`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle nested code blocks correctly", () => { + const input = `# Header + +\`\`\` +Outer code block +# Not a header +\`\`\` + +## Section + +\`\`\` +Another code block +### Still not a header +\`\`\``; + const expected = `## Header + +\`\`\` +Outer code block +# Not a header +\`\`\` + +### Section + +\`\`\` +Another code block +### Still not a header +\`\`\``; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should preserve indentation in headers", () => { + const input = `# Header + ## Indented Header + ### More Indented`; + const expected = `## Header + ### Indented Header + #### More Indented`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with special characters", () => { + const input = `# Header with **bold** +## Header with *italic* +### Header with \`code\` +#### Header with [link](url)`; + const expected = `## Header with **bold** +### Header with *italic* +#### Header with \`code\` +##### Header with [link](url)`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with trailing spaces", () => { + const input = "# Header \n## Section "; + const expected = "## Header \n### Section "; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with multiple spaces after #", () => { + const input = "# Header\n## Section"; + const expected = "## Header\n### Section"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform lines without space after #", () => { + const input = "#No space\n## Valid Header"; + const expected = "#No space\n### Valid Header"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform # in middle of line", () => { + const input = "This is # not a header\n# This is a header"; + const expected = "This is # not a header\n## This is a header"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle empty string", () => { + expect(markdownTransformer.increaseHeaderLevel("")).toBe(""); + }); + + it("should handle null input", () => { + expect(markdownTransformer.increaseHeaderLevel(null)).toBe(""); + }); + + it("should handle undefined input", () => { + expect(markdownTransformer.increaseHeaderLevel(undefined)).toBe(""); + }); + + it("should handle markdown with no headers", () => { + const input = "Just some text\nWith multiple lines\nBut no headers"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(input); + }); + + it("should handle mixed ATX headers and content", () => { + const input = `# Main Title + +Paragraph with some content. + +## Section 1 + +Some text here. + +### Subsection 1.1 + +More content. + +## Section 2 + +Final section.`; + const expected = `## Main Title + +Paragraph with some content. + +### Section 1 + +Some text here. + +#### Subsection 1.1 + +More content. + +### Section 2 + +Final section.`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with emojis", () => { + const input = "# 🚀 Header\n## 📝 Section"; + const expected = "## 🚀 Header\n### 📝 Section"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with numbers", () => { + const input = "# 1. First Header\n## 2.1 Second Header"; + const expected = "## 1. First Header\n### 2.1 Second Header"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle complex real-world example", () => { + const input = `# Conversation Summary + +## Initialization + +Model: gpt-4 + +## Turn 1 + +### User + +Hello, world! + +### Assistant + +Hi there! + +## Turn 2 + +### User + +\`\`\`javascript +# This is not a header +const x = 1; +\`\`\` + +### Assistant + +\`\`\` +# Also not a header +## Nope +\`\`\` + +## Final Summary + +All done!`; + + const expected = `## Conversation Summary + +### Initialization + +Model: gpt-4 + +### Turn 1 + +#### User + +Hello, world! + +#### Assistant + +Hi there! + +### Turn 2 + +#### User + +\`\`\`javascript +# This is not a header +const x = 1; +\`\`\` + +#### Assistant + +\`\`\` +# Also not a header +## Nope +\`\`\` + +### Final Summary + +All done!`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle code blocks with longer fences", () => { + const input = `# Header + +\`\`\`\` +# Not a header +\`\`\`\` + +## Section`; + const expected = `## Header + +\`\`\`\` +# Not a header +\`\`\`\` + +### Section`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle indented code blocks", () => { + const input = `# Header + + \`\`\` + # Not a header + \`\`\` + +## Section`; + const expected = `## Header + + \`\`\` + # Not a header + \`\`\` + +### Section`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle Windows line endings", () => { + const input = "# Header\r\n## Section\r\n"; + const result = markdownTransformer.increaseHeaderLevel(input); + // Line endings are normalized to \n + expect(result).toBe("## Header\n### Section\n"); + }); + + it("should handle headers at different nesting levels", () => { + const input = `# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 +##### H5 again +#### H4 again +### H3 again +## H2 again +# H1 again`; + const expected = `## H1 +### H2 +#### H3 +##### H4 +###### H5 +###### H6 +###### H5 again +##### H4 again +#### H3 again +### H2 again +## H1 again`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with HTML entities", () => { + const input = "# Header & Content\n## Section <tag>"; + const expected = "## Header & Content\n### Section <tag>"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should not transform setext-style headers", () => { + const input = `Header 1 +======== + +Header 2 +-------- + +# ATX Header`; + // Setext headers are not transformed, only ATX + const expected = `Header 1 +======== + +Header 2 +-------- + +## ATX Header`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle consecutive code blocks", () => { + const input = `# Header + +\`\`\` +Block 1 +# Not a header +\`\`\` + +\`\`\` +Block 2 +## Also not a header +\`\`\` + +## Section`; + const expected = `## Header + +\`\`\` +Block 1 +# Not a header +\`\`\` + +\`\`\` +Block 2 +## Also not a header +\`\`\` + +### Section`; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle empty headers", () => { + const input = "# \n## Section"; + const expected = "## \n### Section"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + + it("should handle headers with only whitespace after #", () => { + const input = "# \n## Section"; + const expected = "## \n### Section"; + expect(markdownTransformer.increaseHeaderLevel(input)).toBe(expected); + }); + }); +});