diff --git a/__tests__/comment.test.ts b/__tests__/comment.test.ts new file mode 100644 index 00000000..4df76955 --- /dev/null +++ b/__tests__/comment.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { + buildCommentBody, + type CommentParts, + parseDiff, + parseSummary, + truncate, +} from "../src/comment"; + +describe("parseDiff", () => { + test("prefixes each action keyword with its diff symbol", () => { + const show = [ + "Terraform will perform the following actions:", + " # aws_s3_bucket.a will be created", + " # aws_instance.b will be destroyed", + " # aws_iam_role.c will be updated in-place", + " # aws_db.d must be replaced", + " # data.aws_ami.e will be read during apply", + " # module.f.x will be moved to module.g.x", + " some non-comment line", + ].join("\n"); + const { lines, count } = parseDiff(show); + expect(lines).toEqual([ + "+ aws_s3_bucket.a will be created", + "- aws_instance.b will be destroyed", + "! aws_iam_role.c will be updated in-place", + "! aws_db.d must be replaced", + "~ data.aws_ami.e will be read during apply", + "# module.f.x will be moved to module.g.x", + ]); + // the fallback `#` line is not counted as a change + expect(count).toBe(5); + }); + + test("returns no lines when there is no diff", () => { + expect( + parseDiff("No changes. Your infrastructure matches the configuration."), + ).toEqual({ + lines: [], + count: 0, + }); + }); +}); + +describe("parseSummary", () => { + test("returns the last matching status line", () => { + const out = + "Initializing...\nPlan: 1 to add, 0 to change, 0 to destroy.\ntrailing noise"; + expect(parseSummary(out)).toBe( + "Plan: 1 to add, 0 to change, 0 to destroy.", + ); + }); + + test("matches apply/no-changes/error forms", () => { + expect(parseSummary("Apply complete! Resources: 1 added.")).toBe( + "Apply complete! Resources: 1 added.", + ); + expect( + parseSummary( + "No changes. Your infrastructure matches the configuration.", + ), + ).toBe("No changes. Your infrastructure matches the configuration."); + expect(parseSummary("Error: Invalid resource type")).toBe( + "Error: Invalid resource type", + ); + }); + + test("falls back to 'View output.' when nothing matches", () => { + expect(parseSummary("just some logs\nmore logs")).toBe("View output."); + }); +}); + +describe("truncate", () => { + test("returns the text unchanged when within the limit", () => { + expect(truncate("short", 10)).toBe("short"); + }); + + test("truncates by code point and appends the sentinel", () => { + expect(truncate("abcdef", 3)).toBe("abc\n…"); + }); + + test("counts astral code points as one (never splits a character)", () => { + // four šŸ˜€ (each one code point); limit 2 keeps two, not a broken surrogate + expect(truncate("šŸ˜€šŸ˜€šŸ˜€šŸ˜€", 2)).toBe("šŸ˜€šŸ˜€\n…"); + }); +}); + +function makeParts(overrides: Partial = {}): CommentParts { + return { + positions: ["P1", "P2", "P3", "P4", "P5", "P6"], + command: "terraform plan", + diff: { lines: [], count: 0 }, + console: "Plan: 1 to add", + diffText: "", + summary: "Plan: 1 to add, 0 to change, 0 to destroy.", + syntax: "hcl", + actorTag: "", + actor: "octocat", + timestamp: "2026-06-28T00:00:00Z", + runUrl: "https://example/run", + marker: "terraform-7-abc.tfplan", + expandDiff: false, + expandSummary: false, + ...overrides, + }; +} + +describe("buildCommentBody", () => { + test("includes the command block, placeholders, marker, and actor footer", () => { + const body = buildCommentBody(makeParts({ actorTag: "@" })); + expect(body).toContain("```fish\nterraform plan\n```"); + expect(body).toContain(""); + expect(body).toContain( + "By @octocat at 2026-06-28T00:00:00Z [(view log)](https://example/run).", + ); + for (const p of ["P1", "P2", "P3", "P4", "P5", "P6"]) + expect(body).toContain(p); + }); + + test("omits the diff block when there are no diff lines", () => { + expect(buildCommentBody(makeParts())).not.toContain("Diff of"); + }); + + test("renders a collapsible diff block with the right count and noun", () => { + const body = buildCommentBody( + makeParts({ + diff: { lines: ["+ a will be created"], count: 1 }, + diffText: "+ a will be created", + expandDiff: true, + }), + ); + expect(body).toContain( + "
Diff of 1 change.", + ); + expect(body).toContain("```diff\n+ a will be created\n```"); + }); + + test("expand-summary opens the summary details", () => { + expect(buildCommentBody(makeParts({ expandSummary: true }))).toContain( + "
", + ); + }); +}); diff --git a/src/comment.ts b/src/comment.ts new file mode 100644 index 00000000..9d92b57f --- /dev/null +++ b/src/comment.ts @@ -0,0 +1,141 @@ +/** + * PR-comment and job-summary rendering (Phase 5 of the TypeScript migration). + * + * Pure functions that reproduce the composite action's `post`-step markdown: the + * diff symbol-prefixing, the summary line extraction, length truncation, and the + * final comment body. The orchestrator supplies the already-resolved command + * string, console output, and context; this module only formats. + * + * Truncation is by Unicode code point (not bytes, per the migration decision), + * so it never splits a multi-byte character. + */ + +/** A parsed plan diff: symbol-prefixed lines and the count of real changes. */ +export interface PlanDiff { + lines: string[]; + /** Number of changed resources (lines not starting with `# `). */ + count: number; +} + +/** + * Turn `terraform show` output into diff-highlighted lines, mirroring the + * composite action's `grep '^ # ' | sed` symbol-prefixing. Each ` # …` line + * becomes `+`/`-`/`!`/`~`/`#`-prefixed by its action keyword. First match wins, + * matching the sequential `sed` rewrite. + */ +export function parseDiff(showOutput: string): PlanDiff { + const lines: string[] = []; + for (const raw of showOutput.split("\n")) { + if (!raw.startsWith(" # ")) continue; + const rest = raw.slice(4); + let symbol: string; + if (rest.includes(" be created")) symbol = "+"; + else if (rest.includes(" be destroyed")) symbol = "-"; + else if (rest.includes(" be updated") || rest.includes(" be replaced")) + symbol = "!"; + else if (rest.includes(" be read")) symbol = "~"; + else symbol = "#"; + lines.push(`${symbol} ${rest}`); + } + const count = lines.filter((line) => !line.startsWith("# ")).length; + return { lines, count }; +} + +/** + * The last meaningful status line from console output, mirroring the composite + * action's `awk` over `^(Error:|Plan:|Apply complete!|No changes.|Success)`; + * falls back to "View output." when none is present. + */ +export function parseSummary(consoleOutput: string): string { + // `No changes.` keeps the composite's unescaped `.` (any char) so a future + // "No changes:"/"!" variant matches identically to the bash awk. + const pattern = /^(Error:|Plan:|Apply complete!|No changes.|Success)/; + let summary = ""; + for (const line of consoleOutput.split("\n")) { + if (pattern.test(line)) summary = line; + } + return summary !== "" ? summary : "View output."; +} + +/** + * Truncate by Unicode code point, appending a `\n…` sentinel when shortened + * (the composite used a byte limit + `…`; we use code points so a multi-byte + * character is never split). + */ +export function truncate(text: string, maxCodePoints: number): string { + const codePoints = Array.from(text); + if (codePoints.length <= maxCodePoints) return text; + return `${codePoints.slice(0, maxCodePoints).join("")}\n…`; +} + +export interface CommentParts { + /** `comment-pos-1..6` markdown placeholders, in order. */ + positions: readonly [string, string, string, string, string, string]; + /** The (hide/show-args filtered, secret-scrubbed) command line. */ + command: string; + /** Parsed diff; rendered as a collapsible block when it has lines. */ + diff: PlanDiff; + /** Console output (already truncated by the caller). */ + console: string; + /** Diff text (already truncated by the caller). */ + diffText: string; + /** Summary line for the collapsible header. */ + summary: string; + /** Syntax highlight for the console block: `hcl` normally, `diff` on fmt failure. */ + syntax: "hcl" | "diff"; + /** `@` to tag the actor, or `""`. */ + actorTag: string; + actor: string; + timestamp: string; + runUrl: string; + /** The hidden identifier marker (without the `` wrapper). */ + marker: string; + expandDiff: boolean; + expandSummary: boolean; +} + +/** The collapsible diff block, or "" when there are no changed-resource lines. */ +function renderDiffBlock(parts: CommentParts): string { + if (parts.diff.lines.length === 0) return ""; + const noun = parts.diff.count === 1 ? "change" : "changes"; + const open = parts.expandDiff ? " open" : ""; + return ` +Diff of ${parts.diff.count} ${noun}. + +\`\`\`diff +${parts.diffText} +\`\`\` +
`; +} + +/** + * Assemble the full PR-comment / job-summary body, reproducing the composite + * action's heredoc layout (the six `comment-pos` placeholders, the ```fish + * command block, the collapsible diff, the collapsible summary with the actor + * footer and view-log link, the console block, and the trailing identifier + * marker that the upsert logic keys off). + */ +export function buildCommentBody(parts: CommentParts): string { + const [pos1, pos2, pos3, pos4, pos5, pos6] = parts.positions; + const summaryOpen = parts.expandSummary ? " open" : ""; + return `${pos1} +\`\`\`fish +${parts.command} +\`\`\` +${pos2} +${renderDiffBlock(parts)} +${pos3} +${parts.summary}
+ +${pos4} +###### By ${parts.actorTag}${parts.actor} at ${parts.timestamp} [(view log)](${parts.runUrl}). +
+ +\`\`\`${parts.syntax} +${parts.console} +\`\`\` +
+${pos5} + +${pos6}`; +}