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
94 changes: 77 additions & 17 deletions action/dist/index.js

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

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
28 changes: 28 additions & 0 deletions action/src/comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { decideCommentAction, statuses } from "./comment.ts";

const clean = [statuses.success, statuses.success];
const warned = [statuses.warning, statuses.success];
const failed = [statuses.error, statuses.success];

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

it("always updates the existing comment, whatever it said before", () => {
for (const body of ["<table>✅success</table>", "<table>⚠️warning</table>", undefined]) {
const previous: { id: number; body?: string } = body === undefined ? { id: 7 } : { id: 7, body };
assert.deepEqual(decideCommentAction(clean, previous), { kind: "update", previous, unminimize: false });
}
});

it("asks for the comment to be resurfaced only when the new result has warnings", () => {
const previous = { id: 7, body: "<table></table>" };
assert.deepEqual(decideCommentAction(warned, previous), { kind: "update", previous, unminimize: true });
assert.deepEqual(decideCommentAction(failed, previous), { kind: "update", previous, unminimize: false });
});
});
25 changes: 25 additions & 0 deletions action/src/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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: "update"; previous: T; unminimize: boolean };

// The bot keeps a single comment and overwrites it on every run, so its links
// always point at the latest run and nobody is notified. A comment someone
// minimized is brought back only when there is a warning to look at.
export function decideCommentAction<T extends CommentLike>(
cellStatuses: readonly string[],
previous: T | undefined,
): CommentAction<T> {
if (previous === undefined) return { kind: "create" };
const unminimize = cellStatuses.includes(statuses.warning);
return { kind: "update", previous, unminimize };
}
80 changes: 60 additions & 20 deletions action/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";

import { Octokit } from "@octokit/rest";
import { graphql } from "@octokit/graphql";

import { decideCommentAction, statuses } from "./comment.ts";
import { headSha, renderTrailer } from "./trailer.ts";

if (!process.env.GITHUB_REF?.startsWith("refs/pull/")) {
console.log("not a pull request, exiting.");
process.exit(0);
Expand All @@ -14,6 +19,8 @@ function requireEnv(name: string): string {

const githubRepository = requireEnv("GITHUB_REPOSITORY");
const githubRef = requireEnv("GITHUB_REF");
const githubSha = requireEnv("GITHUB_SHA");
const githubEventPath = requireEnv("GITHUB_EVENT_PATH");

const runId = parseInt(requireEnv("INPUT_RUN_ID"));
const jobId = parseInt(requireEnv("INPUT_JOB_ID"));
Expand Down Expand Up @@ -85,19 +92,19 @@ for (const job of jobList.jobs) {
}
}

let compileResult = "✅success";
let compileResult: string = statuses.success;
let firstIssueLine = 1;
const warningIdx = lines.findIndex((line) => line.match(warningRegex));
console.log(`warningIdx: ${warningIdx}`);
if (warningIdx !== -1) {
compileResult = "⚠️warning";
compileResult = statuses.warning;
firstIssueLine = warningIdx - offset + 1;
console.log(`matched warning line: ${lines[warningIdx]}`);
} else {
const errorIdx = lines.findIndex((line) => line.match(errorRegex));
console.log(`errorIdx: ${errorIdx}`);
if (errorIdx !== -1) {
compileResult = "❌error";
compileResult = statuses.error;
firstIssueLine = errorIdx - offset + 1;
console.log(`matched error line: ${lines[errorIdx]}`);
}
Expand Down Expand Up @@ -208,12 +215,20 @@ function generateTable(entries: Row[]): string {
return `<table>${thead}${tbody}</table>`;
}

const body = generateTable(rows);
const table = generateTable(rows);
const event: unknown = JSON.parse(readFileSync(githubEventPath, "utf8"));
const trailer = renderTrailer({
owner,
repo,
sha: headSha(event, githubSha),
updatedAt: new Date(),
});
const body = `${table}\n\n${trailer}`;

console.log("body is", body);

if (body) {
console.log("outdates previous comments");
console.log("checking previous comments");
const { data: comments } = await octokit.issues.listComments({
owner,
repo,
Expand All @@ -234,26 +249,51 @@ if (body) {
.filter((comment) => comment.user?.login === "cppwarningnotifier[bot]")
.toSorted((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());

if (sortedComments.length > 0) {
const latestComment = sortedComments[sortedComments.length - 1];

if (body.includes("warning") || latestComment.body?.includes("warning")) {
await gql(
`mutation MinimizeComment($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
clientMutationId
}
}`,
{ id: latestComment.node_id },
);
const action = decideCommentAction(
rows.map((r) => r.status),
sortedComments.at(-1),
);

switch (action.kind) {
case "create":
await postComment();
}
} else {
await postComment();
break;
case "update":
console.log("updating the previous comment");
await octokit.issues.updateComment({
owner,
repo,
comment_id: action.previous.id,
body,
});
if (action.unminimize) await unminimizeIfHidden(action.previous.node_id);
break;
}
}

async function unminimizeIfHidden(nodeId: string): Promise<void> {
const { node } = await gql<{ node: { isMinimized: boolean } | null }>(
`query IsMinimized($id: ID!) {
node(id: $id) {
... on IssueComment {
isMinimized
}
}
}`,
{ id: nodeId },
);
if (!node?.isMinimized) return;
console.log("unminimizing the previous comment");
await gql(
`mutation UnminimizeComment($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`,
{ id: nodeId },
);
}

// ── Worker authentication helper ────────────────────────────────────────────

async function getInstallationTokenFromWorker(workerUrl: string): Promise<string> {
Expand Down
32 changes: 32 additions & 0 deletions action/src/trailer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { headSha, renderTrailer } from "./trailer.ts";

describe("headSha", () => {
it("prefers the pull request head over the merge commit", () => {
const event = { pull_request: { head: { sha: "abc123" } } };
assert.equal(headSha(event, "merge456"), "abc123");
});

it("falls back when the payload has no pull request head", () => {
assert.equal(headSha({}, "merge456"), "merge456");
assert.equal(headSha(undefined, "merge456"), "merge456");
assert.equal(headSha({ pull_request: { head: { sha: 42 } } }, "merge456"), "merge456");
});
});

describe("renderTrailer", () => {
it("states the update time in UTC and links the commit by its short SHA", () => {
const sha = "0123456789abcdef0123456789abcdef01234567";
assert.equal(
renderTrailer({
owner: "iris-cpp",
repo: "cpp-warning-notifier",
sha,
updatedAt: new Date("2026-10-15T06:19:11.123Z"),
}),
`<sub>Updated at 2026-10-15 06:19:11 UTC for commit <a href="https://github.com/iris-cpp/cpp-warning-notifier/commit/${sha}"><code>0123456</code></a></sub>`,
);
});
});
Loading
Loading