Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/patch-increase-copilot-headers.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion actions/setup/js/log_parser_bootstrap.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
};
Expand Down
34 changes: 34 additions & 0 deletions actions/setup/js/log_parser_bootstrap.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}));
}));
});
84 changes: 84 additions & 0 deletions actions/setup/js/markdown_transformer.cjs
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading