Skip to content
Closed
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
380 changes: 290 additions & 90 deletions action/dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion action/dist/index.js.map

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"type": "module",
"main": "src/index.ts",
"scripts": {
"build": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript"
"build": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
"test": "node --test \"src/**/*.test.ts\""
},
"devDependencies": {
"@octokit/tsconfig": "^4.0.0",
Expand Down
101 changes: 101 additions & 0 deletions action/src/comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { decideCommentAction, statuses } from "./comment.ts";
import { renderBody } from "./render.ts";
import type { Cell, Report } from "./report.ts";

const layout = { rowHeaderFields: ["os"], columnField: "cpp" };

// A repository name containing "warning", as iris-cpp/cpp-warning-notifier does.
const url = (run: number) => `https://github.com/example/cpp-warning-notifier/actions/runs/${run}/job/2#step:3:4`;

const unused = { file: "src/a.cpp", line: 8, column: 7, message: "unused variable ‘x’", flag: "-Wunused-variable" };
const cpp = { file: "src/a.cpp", line: 3, column: 2, message: '#warning "W"', flag: "-Wcpp" };

function cell(cppVersion: string, status: string, run: number, warnings: Cell["warnings"] = []): Cell {
return { env: { os: "ubuntu", cpp: cppVersion }, status, url: url(run), warnings };
}

const clean = (run: number): Report => ({
cells: [cell("20", statuses.success, run), cell("23", statuses.success, run)],
});
const warned = (run: number, warnings = [cpp, unused]): Report => ({
cells: [cell("20", statuses.warning, run, warnings), cell("23", statuses.success, run)],
});

// A previous bot comment as this version of the bot posts it.
const posted = (report: Report) => ({ id: 7, body: renderBody(report, layout) });

describe("decideCommentAction", () => {
it("creates a comment when the bot has not commented yet", () => {
assert.deepEqual(decideCommentAction(clean(1), undefined), { kind: "create" });
});

it("updates in place when the warnings are exactly the same, even if there are some", () => {
const previous = posted(warned(1));
const moved = warned(2, [{ ...unused, line: 20 }, cpp]);
assert.deepEqual(decideCommentAction(moved, previous), { kind: "update", previous });
});

it("updates in place when both builds are clean", () => {
const previous = posted(clean(1));
assert.deepEqual(decideCommentAction(clean(2), previous), { kind: "update", previous });
});

it("replaces when a warning appears", () => {
const previous = posted(clean(1));
assert.deepEqual(decideCommentAction(warned(2), previous), { kind: "replace", previous });
});

it("replaces when a warning disappears", () => {
const previous = posted(warned(1));
assert.deepEqual(decideCommentAction(clean(2), previous), { kind: "replace", previous });
});

it("replaces when the set of warnings changes while the status stays", () => {
const previous = posted(warned(1));
assert.deepEqual(decideCommentAction(warned(2, [cpp]), previous), { kind: "replace", previous });
assert.deepEqual(
decideCommentAction(warned(2, [cpp, unused, unused]), previous),
{ kind: "replace", previous },
);
});

it("replaces when the set of environments changes and a warning is involved", () => {
const previous = posted(warned(1));
const extra: Report = { cells: [...warned(2).cells, cell("26", statuses.success, 2)] };
assert.deepEqual(decideCommentAction(extra, previous), { kind: "replace", previous });
});

it("updates when the set of environments changes but nothing warns", () => {
const previous = posted(clean(1));
const extra: Report = { cells: [...clean(2).cells, cell("26", statuses.success, 2)] };
assert.deepEqual(decideCommentAction(extra, previous), { kind: "update", previous });
});

describe("previous comment without a block (older bot versions)", () => {
const table = (status: string) =>
`<table><tbody><tr><th>ubuntu</th><td><a href="${url(1)}">${status}</a></td></tr></tbody></table>`;

it("replaces when its status cells show a warning", () => {
const previous = { id: 7, body: table(statuses.warning) };
assert.deepEqual(decideCommentAction(clean(2), previous), { kind: "replace", previous });
});

it("replaces when the new build warns", () => {
const previous = { id: 7, body: table(statuses.success) };
assert.deepEqual(decideCommentAction(warned(2), previous), { kind: "replace", previous });
});

it("updates when neither side warns, ignoring \"warning\" inside link URLs", () => {
const previous = { id: 7, body: table(statuses.success) };
assert.deepEqual(decideCommentAction(clean(2), previous), { kind: "update", previous });
});

it("treats a previous comment without a body as warning-free", () => {
const previous: { id: number; body?: string } = { id: 7 };
assert.deepEqual(decideCommentAction(clean(2), previous), { kind: "update", previous });
});
});
});
47 changes: 47 additions & 0 deletions action/src/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type Report, decodeBlock, reportMatchesBlock } from "./report.ts";

export const statuses = {
success: "✅success",
warning: "⚠️warning",
error: "❌error",
} as const;

export interface CommentLike {
readonly body?: string | undefined;
}

export type CommentAction<T extends CommentLike> =
| { kind: "create" }
| { kind: "replace"; previous: T }
| { kind: "update"; previous: T };

// A fresh comment (with the old one marked outdated) notifies reviewers, so it
// is posted only when the warnings changed and a warning is involved on either
// side. Otherwise the previous comment is edited in place, which refreshes its
// links to the latest run without a notification.
export function decideCommentAction<T extends CommentLike>(
report: Report,
previous: T | undefined,
): CommentAction<T> {
if (previous === undefined) return { kind: "create" };

const body = previous.body ?? "";
const block = decodeBlock(body);
if (block !== undefined && reportMatchesBlock(report, block)) return { kind: "update", previous };

const previousWarned =
block === undefined
? hasWarningCell(body)
: block.cells.some((c) => c.status === statuses.warning);
const currentWarns = report.cells.some((c) => c.status === statuses.warning);
if (currentWarns || previousWarned) return { kind: "replace", previous };
return { kind: "update", previous };
}

// Comments posted before the block existed only carry status cells. Link URLs
// may contain "warning" (e.g. a repository name), so only the link text counts.
const statusCell = /<td><a href="[^"]*">([^<]*)<\/a><\/td>/g;

function hasWarningCell(table: string): boolean {
return [...table.matchAll(statusCell)].some((m) => m[1] === statuses.warning);
}
103 changes: 103 additions & 0 deletions action/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { parseDiagnostics } from "./diagnostics.ts";

const ws = "/home/runner/work/cpp-warning-notifier/cpp-warning-notifier";

// Lines captured from a real job log of this repository's TEST workflow.
const log = [
"2026-10-15T02:35:17.1156336Z hint: to use in all of your new repositories, which will suppress this warning,",
`2026-10-15T02:35:23.0242206Z ${ws}/test/source.cpp:3:2: warning: #warning before C++23 is a GCC extension`,
'2026-10-15T02:35:23.0285225Z 3 | #warning "WARNING!!!!"',
"2026-10-15T02:35:23.0285477Z | ^~~~~~~",
`2026-10-15T02:35:23.0286584Z ${ws}/test/source.cpp:3:2: warning: #warning "WARNING!!!!" [-Wcpp]`,
`2026-10-15T02:35:23.2641841Z ${ws}/test/source.cpp: In function ‘int main()’:`,
`2026-10-15T02:35:23.2648116Z ${ws}/test/source.cpp:8:7: warning: unused variable ‘unused_variable’ [-Wunused-variable]`,
"2026-10-15T02:35:24.0000000Z CMake Warning (dev) at CMakeLists.txt:1 (project):",
].join("\n");

describe("parseDiagnostics", () => {
it("parses GCC/Clang diagnostics with repository-relative paths and log positions", () => {
const found = parseDiagnostics(log, "cpp-warning-notifier");
assert.deepEqual(found, [
{
severity: "warning",
file: "test/source.cpp",
line: 3,
column: 2,
message: "#warning before C++23 is a GCC extension",
flag: undefined,
logLine: 1,
},
{
severity: "warning",
file: "test/source.cpp",
line: 3,
column: 2,
message: '#warning "WARNING!!!!"',
flag: "-Wcpp",
logLine: 4,
},
{
severity: "warning",
file: "test/source.cpp",
line: 8,
column: 7,
message: "unused variable ‘unused_variable’",
flag: "-Wunused-variable",
logLine: 6,
},
]);
});

it("parses MSVC diagnostics and normalises path separators", () => {
const line =
"2026-10-15T02:35:23.0000000Z D:\\a\\cpp-warning-notifier\\cpp-warning-notifier\\test\\source.cpp(8): warning C4189: 'unused_variable': local variable is initialized but not referenced";
assert.deepEqual(parseDiagnostics(line, "cpp-warning-notifier"), [
{
severity: "warning",
file: "test/source.cpp",
line: 8,
column: undefined,
message: "'unused_variable': local variable is initialized but not referenced",
flag: "C4189",
logLine: 0,
},
]);
});

it("parses errors and keeps paths outside the workspace as they are", () => {
const line = "/usr/include/c++/13/bits/foo.h:10:5: error: ‘x’ was not declared in this scope";
assert.deepEqual(parseDiagnostics(line, "cpp-warning-notifier"), [
{
severity: "error",
file: "/usr/include/c++/13/bits/foo.h",
line: 10,
column: 5,
message: "‘x’ was not declared in this scope",
flag: undefined,
logLine: 0,
},
]);
});

it("falls back to a file-less diagnostic for warning lines without a location", () => {
const line = "2026-10-15T02:35:25.0000000Z ld: warning: libfoo.so, needed by bar, not found";
assert.deepEqual(parseDiagnostics(line, "cpp-warning-notifier"), [
{
severity: "warning",
file: undefined,
line: undefined,
column: undefined,
message: "libfoo.so, needed by bar, not found",
flag: undefined,
logLine: 0,
},
]);
});

it("ignores lines that are not diagnostics", () => {
assert.deepEqual(parseDiagnostics("echo CPPWARNINGNOTIFIER_LOG_MARKER\nall good", "r"), []);
});
});
78 changes: 78 additions & 0 deletions action/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export type Severity = "warning" | "error";

export interface Diagnostic {
readonly severity: Severity;
// Repository-relative when the path is under the runner workspace.
readonly file: string | undefined;
readonly line: number | undefined;
readonly column: number | undefined;
readonly message: string;
// "-Wunused-variable" for GCC/Clang, "C4189" for MSVC.
readonly flag: string | undefined;
// 0-based index of the line in the job log.
readonly logLine: number;
}

const timestamp = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z /;
// GCC and Clang: file:line[:column]: severity: message [flag]
const gccLike = /^(.+?):(\d+)(?::(\d+))?: (warning|error): (.*?)(?: \[([^\]]+)\])?$/;
// MSVC: file(line[,column]): severity CODE: message
const msvc = /^(.+?)\((\d+)(?:,(\d+))?\): (warning|error) ([A-Z]+\d+): (.*)$/;
// Anything else the former line-based detection counted, e.g. linker output.
const bare = /(warning|error)(?: .\d+)?: (.*)$/;

export function parseDiagnostics(log: string, repository: string): Diagnostic[] {
const workspace = new RegExp(
`^.*?[\\\\/]${escapeRegExp(repository)}[\\\\/]${escapeRegExp(repository)}[\\\\/]`,
);
const relative = (file: string) => file.replace(workspace, "").replaceAll("\\", "/");
const number = (s: string | undefined) => (s === undefined ? undefined : Number(s));

const found: Diagnostic[] = [];
log.split("\n").forEach((raw, logLine) => {
const line = raw.replace(timestamp, "");
const g = line.match(gccLike);
if (g) {
found.push({
severity: g[4] as Severity,
file: relative(g[1]),
line: Number(g[2]),
column: number(g[3]),
message: g[5],
flag: g[6],
logLine,
});
return;
}
const m = line.match(msvc);
if (m) {
found.push({
severity: m[4] as Severity,
file: relative(m[1]),
line: Number(m[2]),
column: number(m[3]),
message: m[6],
flag: m[5],
logLine,
});
return;
}
const b = line.match(bare);
if (b) {
found.push({
severity: b[1] as Severity,
file: undefined,
line: undefined,
column: undefined,
message: b[2],
flag: undefined,
logLine,
});
}
});
return found;
}

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Loading
Loading