diff --git a/change/change-e38ab074-9193-4447-95c9-467ed1f4ebb6.json b/change/change-e38ab074-9193-4447-95c9-467ed1f4ebb6.json new file mode 100644 index 000000000..20fa8f744 --- /dev/null +++ b/change/change-e38ab074-9193-4447-95c9-467ed1f4ebb6.json @@ -0,0 +1,18 @@ +{ + "changes": [ + { + "type": "patch", + "comment": "chore: add Github Actions Reporter", + "packageName": "@lage-run/cli", + "email": "sanajmi@microsoft.com", + "dependentChangeType": "patch" + }, + { + "type": "minor", + "comment": "chore: add Github Actions Reporter", + "packageName": "@lage-run/reporters", + "email": "sanajmi@microsoft.com", + "dependentChangeType": "patch" + } + ] +} \ No newline at end of file diff --git a/packages/cli/src/__tests__/customReporter.test.ts b/packages/cli/src/__tests__/customReporter.test.ts index 5de5579da..ac12f45ef 100644 --- a/packages/cli/src/__tests__/customReporter.test.ts +++ b/packages/cli/src/__tests__/customReporter.test.ts @@ -21,7 +21,7 @@ describe("initializeReporters with custom reporters", () => { customReporters ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Invalid --reporter option: "nonExistentReporter123". Supported reporters are: json, azureDevops, npmLog, verboseFileLog, vfl, adoLog, fancy, default"` + `"Invalid --reporter option: "nonExistentReporter123". Supported reporters are: json, azureDevops, npmLog, verboseFileLog, vfl, adoLog, githubActions, gha, fancy, default"` ); }); diff --git a/packages/cli/src/__tests__/initializeReporters.test.ts b/packages/cli/src/__tests__/initializeReporters.test.ts index bea3617d3..6ac43434c 100644 --- a/packages/cli/src/__tests__/initializeReporters.test.ts +++ b/packages/cli/src/__tests__/initializeReporters.test.ts @@ -1,5 +1,5 @@ import { Logger, type Reporter } from "@lage-run/logger"; -import { AdoReporter, BasicReporter, ChromeTraceEventsReporter, LogReporter } from "@lage-run/reporters"; +import { AdoReporter, BasicReporter, ChromeTraceEventsReporter, GithubActionsReporter, LogReporter } from "@lage-run/reporters"; import fs from "fs"; import isInteractive from "is-interactive"; import os from "os"; @@ -11,16 +11,33 @@ jest.mock("is-interactive", () => jest.fn(() => true)); describe("initializeReporters", () => { let tmpDir: string; let reporters: Reporter[] | undefined; + let savedGithubActions: string | undefined; + let savedTfBuild: string | undefined; beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lage-")); }); + beforeEach(() => { + // Save and clear CI env vars so default-reporter tests are environment-independent + savedGithubActions = process.env.GITHUB_ACTIONS; + savedTfBuild = process.env.TF_BUILD; + delete process.env.GITHUB_ACTIONS; + delete process.env.TF_BUILD; + }); + afterEach(async () => { for (const reporter of reporters || []) { reporter.cleanup?.(); } reporters = undefined; + // Restore CI env vars + if (savedGithubActions !== undefined) { + process.env.GITHUB_ACTIONS = savedGithubActions; + } + if (savedTfBuild !== undefined) { + process.env.TF_BUILD = savedTfBuild; + } }); afterAll(() => { @@ -115,4 +132,34 @@ describe("initializeReporters", () => { expect(reporters.length).toBe(1); expect(reporters).toContainEqual(expect.any(AdoReporter)); }); + + it("should auto-detect GitHub Actions and use GithubActionsReporter", async () => { + process.env.GITHUB_ACTIONS = "true"; + const logger = new Logger(); + reporters = await initializeReporters(logger, { + concurrency: 1, + grouped: false, + logLevel: "info", + progress: false, + reporter: [], + verbose: false, + }); + expect(reporters.length).toBe(1); + expect(reporters).toContainEqual(expect.any(GithubActionsReporter)); + }); + + it("should auto-detect Azure DevOps and use AdoReporter", async () => { + process.env.TF_BUILD = "True"; + const logger = new Logger(); + reporters = await initializeReporters(logger, { + concurrency: 1, + grouped: false, + logLevel: "info", + progress: false, + reporter: [], + verbose: false, + }); + expect(reporters.length).toBe(1); + expect(reporters).toContainEqual(expect.any(AdoReporter)); + }); }); diff --git a/packages/cli/src/commands/createReporter.ts b/packages/cli/src/commands/createReporter.ts index f5f50886d..540aa2753 100644 --- a/packages/cli/src/commands/createReporter.ts +++ b/packages/cli/src/commands/createReporter.ts @@ -2,6 +2,7 @@ import { LogLevel } from "@lage-run/logger"; import { JsonReporter, AdoReporter, + GithubActionsReporter, LogReporter, ProgressReporter, BasicReporter, @@ -40,6 +41,10 @@ export async function createReporter( case "adoLog": return new AdoReporter({ grouped, logLevel: verbose ? LogLevel.verbose : logLevel }); + case "githubActions": + case "gha": + return new GithubActionsReporter({ grouped, logLevel: verbose ? LogLevel.verbose : logLevel }); + case "npmLog": case "old": return new LogReporter({ grouped, logLevel: verbose ? LogLevel.verbose : logLevel }); @@ -78,7 +83,15 @@ export async function createReporter( } } - // Default reporter behavior + // Default reporter behavior - auto-detect CI environments + if (process.env.GITHUB_ACTIONS) { + return new GithubActionsReporter({ grouped: true, logLevel: verbose ? LogLevel.verbose : logLevel }); + } + + if (process.env.TF_BUILD) { + return new AdoReporter({ grouped: true, logLevel: verbose ? LogLevel.verbose : logLevel }); + } + if (progress && isInteractive() && !(logLevel >= LogLevel.verbose || verbose || grouped)) { return new BasicReporter({ concurrency, version }); } diff --git a/packages/cli/src/types/ReporterInitOptions.ts b/packages/cli/src/types/ReporterInitOptions.ts index b563dac7d..2254fbfbe 100644 --- a/packages/cli/src/types/ReporterInitOptions.ts +++ b/packages/cli/src/types/ReporterInitOptions.ts @@ -7,6 +7,8 @@ export type BuiltInReporterName = | "json" | "azureDevops" | "adoLog" + | "githubActions" + | "gha" | "npmLog" | "old" | "verboseFileLog" @@ -23,6 +25,8 @@ const shouldListBuiltInReporters: Record = { verboseFileLog: true, vfl: true, adoLog: true, + githubActions: true, + gha: true, fancy: true, default: true, // Not encouraged diff --git a/packages/reporters/src/AdoReporter.ts b/packages/reporters/src/AdoReporter.ts index e6298cdec..5d77ce520 100644 --- a/packages/reporters/src/AdoReporter.ts +++ b/packages/reporters/src/AdoReporter.ts @@ -1,242 +1,51 @@ import { formatDuration, hrToSeconds } from "@lage-run/format-hrtime"; -import { isTargetStatusLogEntry } from "./isTargetStatusLogEntry.js"; -import { LogLevel, type LogStructuredData } from "@lage-run/logger"; import chalk from "chalk"; -import type { Reporter, LogEntry } from "@lage-run/logger"; -import type { SchedulerRunSummary, TargetStatus } from "@lage-run/scheduler-types"; -import type { TargetMessageEntry, TargetStatusEntry } from "./types/TargetLogEntry.js"; -import type { Writable } from "stream"; -import { slowestTargetRuns } from "./slowestTargetRuns.js"; - -const colors = { - [LogLevel.info]: chalk.white, - [LogLevel.verbose]: chalk.gray, - [LogLevel.warn]: chalk.white, - [LogLevel.error]: chalk.white, - [LogLevel.silly]: chalk.green, - task: chalk.cyan, - pkg: chalk.magenta, - ok: chalk.green, - error: chalk.red, - warn: chalk.yellow, -}; - -const logLevelLabel = { - [LogLevel.info]: "INFO", - [LogLevel.warn]: "WARN", - [LogLevel.error]: "ERR!", - [LogLevel.silly]: "SILLY", - [LogLevel.verbose]: "VERB", -}; - -function getTaskLogPrefix(pkg: string, task: string) { - return `${colors.pkg(pkg)} ${colors.task(task)}`; -} - -function normalize(prefixOrMessage: string, message?: string) { - if (typeof message === "string") { - const prefix = prefixOrMessage; - return { prefix, message }; - } else { - const prefix = ""; - const message = prefixOrMessage; - return { prefix, message }; +import type { TargetRun } from "@lage-run/scheduler-types"; +import { colors, GroupedReporter } from "./GroupedReporter.js"; + +export class AdoReporter extends GroupedReporter { + protected formatGroupStart(packageName: string, task: string, status: string, duration?: [number, number]): string { + return `##[group] ${colors.pkg(packageName)} ${colors.task(task)} ${status}${ + duration ? `, took ${formatDuration(hrToSeconds(duration))}` : "" + }\n`; } -} - -function format(level: LogLevel, prefix: string, message: string) { - return `${logLevelLabel[level]}: ${prefix} ${message}\n`; -} - -export class AdoReporter implements Reporter { - private logStream: Writable; - private logEntries = new Map(); - private readonly groupedEntries: Map[]> = new Map(); - - constructor( - private options: { - logLevel?: LogLevel; - grouped?: boolean; - /** stream for testing */ - logStream?: Writable; - } - ) { - options.logLevel = options.logLevel || LogLevel.info; - this.logStream = options.logStream || process.stdout; + protected formatGroupEnd(): string { + return `##[endgroup]\n`; } - public log(entry: LogEntry): boolean | void { - if (entry.data && entry.data.target && entry.data.target.hidden) { - return; - } - - if (entry.data && entry.data.target) { - if (!this.logEntries.has(entry.data.target.id)) { - this.logEntries.set(entry.data.target.id, []); - } - - this.logEntries.get(entry.data.target.id)!.push(entry); - } - - if (this.options.logLevel! >= entry.level) { - if (this.options.grouped && entry.data?.target) { - return this.logTargetEntryByGroup(entry); - } - - return this.logTargetEntry(entry); - } - } - - private logTargetEntry(entry: LogEntry) { - const colorFn = colors[entry.level]; - const data = entry.data!; - - if (isTargetStatusLogEntry(data)) { - const { target, hash, duration } = data; - const { packageName, task } = target; - - const normalizedArgs = this.options.grouped - ? normalize(entry.msg) - : normalize(getTaskLogPrefix(packageName ?? "", task), entry.msg); - - const pkgTask = this.options.grouped ? `${chalk.magenta(packageName)} ${chalk.cyan(task)}` : ""; - - switch (data.status) { - case "running": - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.ok("➔")} start ${pkgTask}`))); - - case "success": - return this.logStream.write( - format( - entry.level, - normalizedArgs.prefix, - colorFn(`${colors.ok("✓")} done ${pkgTask} - ${formatDuration(hrToSeconds(duration!))}`) - ) - ); - - case "failed": - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.error("✖")} fail ${pkgTask}`))); - - case "skipped": - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.ok("»")} skip ${pkgTask} - ${hash!}`))); - - case "aborted": - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.warn("-")} aborted ${pkgTask}`))); - - case "queued": - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.warn("…")} aborted ${pkgTask}`))); - } - } else if (entry?.data?.target) { - const { target } = data; - const { packageName, task } = target; - const normalizedArgs = this.options.grouped - ? normalize(entry.msg) - : normalize(getTaskLogPrefix(packageName ?? "", task), entry.msg); - return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn("| " + normalizedArgs.message))); - } else if (entry?.msg.trim() !== "") { - return this.logStream.write(format(entry.level, "", entry.msg)); - } + protected writeSummaryHeader(): void { + this.logStream.write(chalk.cyanBright(`##[section]Summary\n`)); } - private logTargetEntryByGroup(entry: LogEntry) { - const data = entry.data!; - - const target = data.target; - const { id } = target; - - this.groupedEntries.set(id, this.groupedEntries.get(id) || []); - this.groupedEntries.get(id)?.push(entry); - - if (isTargetStatusLogEntry(data)) { - if (data.status === "success" || data.status === "failed" || data.status === "skipped" || data.status === "aborted") { - const { status, duration } = data; - this.logStream.write( - `##[group] ${colors.pkg(data.target.packageName ?? "")} ${colors.task(data.target.task)} ${status}${ - duration ? `, took ${formatDuration(hrToSeconds(duration))}` : "" - }\n` - ); - const entries = this.groupedEntries.get(id)! as LogEntry[]; - - for (const targetEntry of entries) { - this.logTargetEntry(targetEntry); - } - - this.logStream.write(`##[endgroup]\n`); - } - } + protected writeSummaryFooter(): void { + // ADO sections have no closing marker } - public summarize(schedulerRunSummary: SchedulerRunSummary): void { - const { targetRuns, targetRunByStatus, duration } = schedulerRunSummary; - const { failed, aborted, skipped, success, pending } = targetRunByStatus; + protected writeFailures(failed: string[], targetRuns: Map>): void { + let packagesMessage = `##vso[task.logissue type=error]Your build failed on the following packages => `; - const statusColorFn: { - [status in TargetStatus]: chalk.Chalk; - } = { - success: chalk.greenBright, - failed: chalk.redBright, - skipped: chalk.gray, - running: chalk.yellow, - pending: chalk.gray, - aborted: chalk.red, - queued: chalk.magenta, - }; - - this.logStream.write(chalk.cyanBright(`##[section]Summary\n`)); + for (const targetId of failed) { + const target = targetRuns.get(targetId)?.target; - if (targetRuns.size > 0) { - const slowestTargets = slowestTargetRuns([...targetRuns.values()]); + if (target) { + const { packageName, task } = target; + const taskLogs = this.logEntries.get(targetId); - for (const wrappedTarget of slowestTargets) { - const colorFn = statusColorFn[wrappedTarget.status]; - const target = wrappedTarget.target; + packagesMessage += `[${packageName} ${task}], `; - this.logStream.write( - format( - LogLevel.info, - getTaskLogPrefix(target.packageName || "[GLOBAL]", target.task), - colorFn( - `${wrappedTarget.status}${wrappedTarget.duration ? `, took ${formatDuration(hrToSeconds(wrappedTarget.duration))}` : ""}` - ) - ) - ); - } - - this.logStream.write( - `[Tasks Count] success: ${success.length}, skipped: ${skipped.length}, pending: ${pending.length}, aborted: ${aborted.length}\n` - ); - } else { - this.logStream.write("Nothing has been run.\n"); - } + this.logStream.write(`##[error] [${chalk.magenta(packageName)} ${chalk.cyan(task)}] ${chalk.redBright("ERROR DETECTED")}\n`); - if (failed && failed.length > 0) { - let packagesMessage = `##vso[task.logissue type=error]Your build failed on the following packages => `; - - for (const targetId of failed) { - const target = targetRuns.get(targetId)?.target; - - if (target) { - const { packageName, task } = target; - const taskLogs = this.logEntries.get(targetId); - - packagesMessage += `[${packageName} ${task}], `; - - this.logStream.write(`##[error] [${chalk.magenta(packageName)} ${chalk.cyan(task)}] ${chalk.redBright("ERROR DETECTED")}\n`); - - if (taskLogs) { - for (const entry of taskLogs) { - // Log each entry separately to prevent truncation - this.logStream.write(`##[error] ${entry.msg}\n`); - } + if (taskLogs) { + for (const entry of taskLogs) { + // Log each entry separately to prevent truncation + this.logStream.write(`##[error] ${entry.msg}\n`); } } } - - packagesMessage += "find the error logs above with the prefix '##[error]!'\n"; - this.logStream.write(packagesMessage); } - this.logStream.write(format(LogLevel.info, "", `Took a total of ${formatDuration(hrToSeconds(duration))} to complete`)); + packagesMessage += "find the error logs above with the prefix '##[error]!'\n"; + this.logStream.write(packagesMessage); } } diff --git a/packages/reporters/src/GithubActionsReporter.ts b/packages/reporters/src/GithubActionsReporter.ts new file mode 100644 index 000000000..39bd779f8 --- /dev/null +++ b/packages/reporters/src/GithubActionsReporter.ts @@ -0,0 +1,42 @@ +import { formatDuration, hrToSeconds } from "@lage-run/format-hrtime"; +import type { TargetRun } from "@lage-run/scheduler-types"; +import { GroupedReporter } from "./GroupedReporter.js"; + +export class GithubActionsReporter extends GroupedReporter { + protected formatGroupStart(packageName: string, task: string, status: string, duration?: [number, number]): string { + return `::group::${packageName} ${task} ${status}${duration ? `, took ${formatDuration(hrToSeconds(duration))}` : ""}\n`; + } + + protected formatGroupEnd(): string { + return `::endgroup::\n`; + } + + protected writeSummaryHeader(): void { + this.logStream.write(`::group::Summary\n`); + } + + protected writeSummaryFooter(): void { + this.logStream.write(`::endgroup::\n`); + } + + protected writeFailures(failed: string[], targetRuns: Map>): void { + for (const targetId of failed) { + const target = targetRuns.get(targetId)?.target; + + if (target) { + const { packageName, task } = target; + const taskLogs = this.logEntries.get(targetId); + + this.logStream.write(`::error title=${packageName} ${task}::Build failed\n`); + + if (taskLogs) { + for (const entry of taskLogs) { + if (entry.msg.trim() !== "") { + this.logStream.write(`::error::${entry.msg}\n`); + } + } + } + } + } + } +} diff --git a/packages/reporters/src/GroupedReporter.ts b/packages/reporters/src/GroupedReporter.ts new file mode 100644 index 000000000..c0f99f5d2 --- /dev/null +++ b/packages/reporters/src/GroupedReporter.ts @@ -0,0 +1,229 @@ +import { formatDuration, hrToSeconds } from "@lage-run/format-hrtime"; +import { isTargetStatusLogEntry } from "./isTargetStatusLogEntry.js"; +import { LogLevel, type LogStructuredData } from "@lage-run/logger"; +import chalk from "chalk"; +import type { Reporter, LogEntry } from "@lage-run/logger"; +import type { SchedulerRunSummary, TargetRun, TargetStatus } from "@lage-run/scheduler-types"; +import type { TargetMessageEntry, TargetStatusEntry } from "./types/TargetLogEntry.js"; +import type { Writable } from "stream"; +import { slowestTargetRuns } from "./slowestTargetRuns.js"; + +export const colors = { + [LogLevel.info]: chalk.white, + [LogLevel.verbose]: chalk.gray, + [LogLevel.warn]: chalk.white, + [LogLevel.error]: chalk.white, + [LogLevel.silly]: chalk.green, + task: chalk.cyan, + pkg: chalk.magenta, + ok: chalk.green, + error: chalk.red, + warn: chalk.yellow, +}; + +export const logLevelLabel = { + [LogLevel.info]: "INFO", + [LogLevel.warn]: "WARN", + [LogLevel.error]: "ERR!", + [LogLevel.silly]: "SILLY", + [LogLevel.verbose]: "VERB", +}; + +export function getTaskLogPrefix(pkg: string, task: string): string { + return `${colors.pkg(pkg)} ${colors.task(task)}`; +} + +function normalize(prefixOrMessage: string, message?: string) { + if (typeof message === "string") { + const prefix = prefixOrMessage; + return { prefix, message }; + } else { + const prefix = ""; + const message = prefixOrMessage; + return { prefix, message }; + } +} + +export function format(level: LogLevel, prefix: string, message: string): string { + return `${logLevelLabel[level]}: ${prefix} ${message}\n`; +} + +export abstract class GroupedReporter implements Reporter { + protected logStream: Writable; + protected logEntries = new Map(); + private readonly groupedEntries: Map[]> = new Map(); + + constructor( + protected options: { + logLevel?: LogLevel; + grouped?: boolean; + /** stream for testing */ + logStream?: Writable; + } + ) { + options.logLevel = options.logLevel || LogLevel.info; + this.logStream = options.logStream || process.stdout; + } + + public log(entry: LogEntry): boolean | void { + if (entry.data && entry.data.target && entry.data.target.hidden) { + return; + } + + if (entry.data && entry.data.target) { + if (!this.logEntries.has(entry.data.target.id)) { + this.logEntries.set(entry.data.target.id, []); + } + + this.logEntries.get(entry.data.target.id)!.push(entry); + } + + if (this.options.logLevel! >= entry.level) { + if (this.options.grouped && entry.data?.target) { + return this.logTargetEntryByGroup(entry); + } + + return this.logTargetEntry(entry); + } + } + + protected logTargetEntry(entry: LogEntry): boolean | void { + const colorFn = colors[entry.level]; + const data = entry.data!; + + if (isTargetStatusLogEntry(data)) { + const { target, hash, duration } = data; + const { packageName, task } = target; + + const normalizedArgs = this.options.grouped + ? normalize(entry.msg) + : normalize(getTaskLogPrefix(packageName ?? "", task), entry.msg); + + const pkgTask = this.options.grouped ? `${chalk.magenta(packageName)} ${chalk.cyan(task)}` : ""; + + switch (data.status) { + case "running": + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.ok("➔")} start ${pkgTask}`))); + + case "success": + return this.logStream.write( + format( + entry.level, + normalizedArgs.prefix, + colorFn(`${colors.ok("✓")} done ${pkgTask} - ${formatDuration(hrToSeconds(duration!))}`) + ) + ); + + case "failed": + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.error("✖")} fail ${pkgTask}`))); + + case "skipped": + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.ok("»")} skip ${pkgTask} - ${hash!}`))); + + case "aborted": + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.warn("-")} aborted ${pkgTask}`))); + + case "queued": + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn(`${colors.warn("…")} queued ${pkgTask}`))); + } + } else if (entry?.data?.target) { + const { target } = data; + const { packageName, task } = target; + const normalizedArgs = this.options.grouped + ? normalize(entry.msg) + : normalize(getTaskLogPrefix(packageName ?? "", task), entry.msg); + return this.logStream.write(format(entry.level, normalizedArgs.prefix, colorFn("| " + normalizedArgs.message))); + } else if (entry?.msg.trim() !== "") { + return this.logStream.write(format(entry.level, "", entry.msg)); + } + } + + private logTargetEntryByGroup(entry: LogEntry) { + const data = entry.data!; + + const target = data.target; + const { id } = target; + + this.groupedEntries.set(id, this.groupedEntries.get(id) || []); + this.groupedEntries.get(id)?.push(entry); + + if (isTargetStatusLogEntry(data)) { + if (data.status === "success" || data.status === "failed" || data.status === "skipped" || data.status === "aborted") { + const { status, duration } = data; + this.logStream.write(this.formatGroupStart(data.target.packageName ?? "", data.target.task, status, duration)); + + const entries = this.groupedEntries.get(id)! as LogEntry[]; + for (const targetEntry of entries) { + this.logTargetEntry(targetEntry); + } + + this.logStream.write(this.formatGroupEnd()); + } + } + } + + public summarize(schedulerRunSummary: SchedulerRunSummary): void { + const { targetRuns, targetRunByStatus, duration } = schedulerRunSummary; + const { failed, aborted, skipped, success, pending } = targetRunByStatus; + + const statusColorFn: { [status in TargetStatus]: chalk.Chalk } = { + success: chalk.greenBright, + failed: chalk.redBright, + skipped: chalk.gray, + running: chalk.yellow, + pending: chalk.gray, + aborted: chalk.red, + queued: chalk.magenta, + }; + + this.writeSummaryHeader(); + + if (targetRuns.size > 0) { + const slowestTargets = slowestTargetRuns([...targetRuns.values()]); + + for (const wrappedTarget of slowestTargets) { + const colorFn = statusColorFn[wrappedTarget.status]; + const target = wrappedTarget.target; + + this.logStream.write( + format( + LogLevel.info, + getTaskLogPrefix(target.packageName || "[GLOBAL]", target.task), + colorFn( + `${wrappedTarget.status}${wrappedTarget.duration ? `, took ${formatDuration(hrToSeconds(wrappedTarget.duration))}` : ""}` + ) + ) + ); + } + + this.logStream.write( + `[Tasks Count] success: ${success.length}, skipped: ${skipped.length}, pending: ${pending.length}, aborted: ${aborted.length}\n` + ); + } else { + this.logStream.write("Nothing has been run.\n"); + } + + this.writeSummaryFooter(); + + if (failed && failed.length > 0) { + this.writeFailures(failed, targetRuns); + } + + this.logStream.write(format(LogLevel.info, "", `Took a total of ${formatDuration(hrToSeconds(duration))} to complete`)); + } + + /** Returns the opening line for a grouped target log block, including trailing newline. */ + protected abstract formatGroupStart(packageName: string, task: string, status: string, duration?: [number, number]): string; + + /** Returns the closing line for a grouped target log block, including trailing newline. */ + protected abstract formatGroupEnd(): string; + + /** Writes the summary section header. */ + protected abstract writeSummaryHeader(): void; + + /** Writes anything needed after the summary target list (e.g. closing a group). */ + protected abstract writeSummaryFooter(): void; + + /** Writes per-CI-system error annotations for all failed targets. */ + protected abstract writeFailures(failed: string[], targetRuns: Map>): void; +} diff --git a/packages/reporters/src/__tests__/GithubActionsReporter.test.ts b/packages/reporters/src/__tests__/GithubActionsReporter.test.ts new file mode 100644 index 000000000..b1598a2a3 --- /dev/null +++ b/packages/reporters/src/__tests__/GithubActionsReporter.test.ts @@ -0,0 +1,318 @@ +import { LogLevel } from "@lage-run/logger"; +import type { TargetRun } from "@lage-run/scheduler-types"; +import streams from "memory-streams"; +import { GithubActionsReporter } from "../GithubActionsReporter.js"; +import type { TargetMessageEntry, TargetStatusEntry } from "../types/TargetLogEntry.js"; +import { writerToString } from "./writerToString.js"; + +function createTarget(packageName: string, task: string) { + return { + id: `${packageName}#${task}`, + cwd: `/repo/root/packages/${packageName}`, + dependencies: [], + dependents: [], + depSpecs: [], + packageName, + task, + label: `${packageName} - ${task}`, + }; +} + +describe("GithubActionsReporter", () => { + it("records a target status entry", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: false, logLevel: LogLevel.verbose, logStream: writer }); + + reporter.log({ + data: { + target: createTarget("a", "task"), + status: "running", + duration: [0, 0], + startTime: [0, 0], + } as TargetStatusEntry, + level: LogLevel.verbose, + msg: "test message", + timestamp: 0, + }); + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "VERB: a task ➔ start + " + `); + }); + + it("records a target message entry", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: false, logLevel: LogLevel.verbose, logStream: writer }); + + reporter.log({ + data: { + target: createTarget("a", "task"), + pid: 1, + } as TargetMessageEntry, + level: LogLevel.verbose, + msg: "test message", + timestamp: 0, + }); + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "VERB: a task | test message + " + `); + }); + + it("groups messages together using GitHub Actions ::group:: syntax", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: true, logLevel: LogLevel.verbose, logStream: writer }); + + const aBuildTarget = createTarget("a", "build"); + const aTestTarget = createTarget("a", "test"); + const bBuildTarget = createTarget("b", "build"); + + const logs = [ + [{ target: aBuildTarget, status: "running", duration: [0, 0], startTime: [0, 0] }], + [{ target: aTestTarget, status: "running", duration: [0, 0], startTime: [1, 0] }], + [{ target: bBuildTarget, status: "running", duration: [0, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test"], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build again"], + [{ target: aTestTarget, status: "success", duration: [10, 0], startTime: [0, 0] }], + [{ target: bBuildTarget, status: "success", duration: [30, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, status: "failed", duration: [60, 0], startTime: [1, 0] }], + ] as [TargetStatusEntry | TargetMessageEntry, string?][]; + + for (const log of logs) { + reporter.log({ + data: log[0], + level: LogLevel.verbose, + msg: log[1] ?? "empty message", + timestamp: 0, + }); + } + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "::group::a test success, took 10.00s + VERB: ➔ start a test + VERB: | test message for a#test + VERB: | test message for a#test again + VERB: ✓ done a test - 10.00s + ::endgroup:: + ::group::b build success, took 30.00s + VERB: ➔ start b build + VERB: | test message for b#build + VERB: | test message for b#build again + VERB: ✓ done b build - 30.00s + ::endgroup:: + ::group::a build failed, took 60.00s + VERB: ➔ start a build + VERB: | test message for a#build + VERB: | test message for a#build again + VERB: ✖ fail a build + ::endgroup:: + " + `); + }); + + it("interweaves messages when ungrouped", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: false, logLevel: LogLevel.verbose, logStream: writer }); + + const aBuildTarget = createTarget("a", "build"); + const aTestTarget = createTarget("a", "test"); + const bBuildTarget = createTarget("b", "build"); + + const logs = [ + [{ target: aBuildTarget, status: "running", duration: [0, 0], startTime: [0, 0] }], + [{ target: aTestTarget, status: "running", duration: [0, 0], startTime: [1, 0] }], + [{ target: bBuildTarget, status: "running", duration: [0, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test"], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build again"], + [{ target: aTestTarget, status: "success", duration: [10, 0], startTime: [0, 0] }], + [{ target: bBuildTarget, status: "success", duration: [30, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, status: "failed", duration: [60, 0], startTime: [1, 0] }], + ] as [TargetStatusEntry | TargetMessageEntry, string?][]; + + for (const log of logs) { + reporter.log({ + data: log[0], + level: LogLevel.verbose, + msg: log[1] ?? "empty message", + timestamp: 0, + }); + } + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "VERB: a build ➔ start + VERB: a test ➔ start + VERB: b build ➔ start + VERB: a build | test message for a#build + VERB: a test | test message for a#test + VERB: a build | test message for a#build again + VERB: b build | test message for b#build + VERB: a test | test message for a#test again + VERB: b build | test message for b#build again + VERB: a test ✓ done - 10.00s + VERB: b build ✓ done - 30.00s + VERB: a build ✖ fail + " + `); + }); + + it("can filter out verbose messages", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: false, logLevel: LogLevel.info, logStream: writer }); + + const aBuildTarget = createTarget("a", "build"); + const aTestTarget = createTarget("a", "test"); + const bBuildTarget = createTarget("b", "build"); + + const logs = [ + [{ target: aBuildTarget, status: "running", duration: [0, 0], startTime: [0, 0] }], + [{ target: aTestTarget, status: "running", duration: [0, 0], startTime: [1, 0] }], + [{ target: bBuildTarget, status: "running", duration: [0, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test"], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build again"], + [{ target: aTestTarget, status: "success", duration: [10, 0], startTime: [0, 0] }], + [{ target: bBuildTarget, status: "success", duration: [30, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, status: "failed", duration: [60, 0], startTime: [1, 0] }], + ] as [TargetStatusEntry | TargetMessageEntry, string?][]; + + for (const log of logs) { + reporter.log({ + data: log[0], + level: "status" in log[0] ? LogLevel.info : LogLevel.verbose, + msg: log[1] ?? "empty message", + timestamp: 0, + }); + } + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "INFO: a build ➔ start + INFO: a test ➔ start + INFO: b build ➔ start + INFO: a test ✓ done - 10.00s + INFO: b build ✓ done - 30.00s + INFO: a build ✖ fail + " + `); + }); + + it("uses ::error:: and ::group::Summary in summarize", () => { + const writer = new streams.WritableStream(); + + const reporter = new GithubActionsReporter({ grouped: true, logLevel: LogLevel.verbose, logStream: writer }); + + const aBuildTarget = createTarget("a", "build"); + const aTestTarget = createTarget("a", "test"); + const bBuildTarget = createTarget("b", "build"); + + const logs = [ + [{ target: aBuildTarget, status: "running", duration: [0, 0], startTime: [0, 0] }], + [{ target: aTestTarget, status: "running", duration: [0, 0], startTime: [1, 0] }], + [{ target: bBuildTarget, status: "running", duration: [0, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test"], + [{ target: aBuildTarget, pid: 1 }, "test message for a#build again, but look there is an error!"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build"], + [{ target: aTestTarget, pid: 1 }, "test message for a#test again"], + [{ target: bBuildTarget, pid: 1 }, "test message for b#build again"], + [{ target: aTestTarget, status: "success", duration: [10, 0], startTime: [0, 0] }], + [{ target: bBuildTarget, status: "success", duration: [30, 0], startTime: [2, 0] }], + [{ target: aBuildTarget, status: "failed", duration: [60, 0], startTime: [1, 0] }], + ] as [TargetStatusEntry | TargetMessageEntry, string?][]; + + for (const log of logs) { + reporter.log({ + data: log[0], + level: "status" in log[0] ? LogLevel.info : LogLevel.verbose, + msg: log[1] ?? "", + timestamp: 0, + }); + } + + reporter.summarize({ + duration: [100, 0], + startTime: [0, 0], + results: "failed", + targetRunByStatus: { + success: [aTestTarget.id, bBuildTarget.id], + failed: [aBuildTarget.id], + pending: [], + running: [], + aborted: [], + skipped: [], + queued: [], + }, + targetRuns: new Map>([ + [aBuildTarget.id, { target: aBuildTarget, status: "failed", duration: [60, 0], startTime: [1, 0], queueTime: [0, 0], threadId: 0 }], + [aTestTarget.id, { target: aTestTarget, status: "success", duration: [60, 0], startTime: [1, 0], queueTime: [0, 0], threadId: 0 }], + [ + bBuildTarget.id, + { target: bBuildTarget, status: "success", duration: [60, 0], startTime: [1, 0], queueTime: [0, 0], threadId: 0 }, + ], + ]), + maxWorkerMemoryUsage: 0, + workerRestarts: 0, + }); + + writer.end(); + + expect(writerToString(writer)).toMatchInlineSnapshot(` + "::group::a test success, took 10.00s + INFO: ➔ start a test + VERB: | test message for a#test + VERB: | test message for a#test again + INFO: ✓ done a test - 10.00s + ::endgroup:: + ::group::b build success, took 30.00s + INFO: ➔ start b build + VERB: | test message for b#build + VERB: | test message for b#build again + INFO: ✓ done b build - 30.00s + ::endgroup:: + ::group::a build failed, took 60.00s + INFO: ➔ start a build + VERB: | test message for a#build + VERB: | test message for a#build again, but look there is an error! + INFO: ✖ fail a build + ::endgroup:: + ::group::Summary + INFO: a build failed, took 60.00s + INFO: a test success, took 60.00s + INFO: b build success, took 60.00s + [Tasks Count] success: 2, skipped: 0, pending: 0, aborted: 0 + ::endgroup:: + ::error title=a build::Build failed + ::error::test message for a#build + ::error::test message for a#build again, but look there is an error! + INFO: Took a total of 1m 40.00s to complete + " + `); + }); +}); diff --git a/packages/reporters/src/index.ts b/packages/reporters/src/index.ts index f790fcbba..fc41fa164 100644 --- a/packages/reporters/src/index.ts +++ b/packages/reporters/src/index.ts @@ -1,4 +1,5 @@ export { AdoReporter } from "./AdoReporter.js"; +export { GithubActionsReporter } from "./GithubActionsReporter.js"; export { BasicReporter } from "./BasicReporter.js"; export { JsonReporter } from "./JsonReporter.js"; export { LogReporter } from "./LogReporter.js";