Skip to content

Commit 8f3152a

Browse files
authored
Change behavior to keep single live comment (#29)
1 parent 0cde301 commit 8f3152a

9 files changed

Lines changed: 247 additions & 40 deletions

File tree

‎action/dist/index.js‎

Lines changed: 77 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎action/dist/index.js.map‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎action/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"type": "module",
44
"main": "src/index.ts",
55
"scripts": {
6-
"build": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript"
6+
"build": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
7+
"test": "node --test \"src/**/*.test.ts\""
78
},
89
"devDependencies": {
910
"@octokit/tsconfig": "^4.0.0",

‎action/src/comment.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import { decideCommentAction, statuses } from "./comment.ts";
5+
6+
const clean = [statuses.success, statuses.success];
7+
const warned = [statuses.warning, statuses.success];
8+
const failed = [statuses.error, statuses.success];
9+
10+
describe("decideCommentAction", () => {
11+
it("creates a comment when the bot has not commented yet", () => {
12+
assert.deepEqual(decideCommentAction(clean, undefined), { kind: "create" });
13+
assert.deepEqual(decideCommentAction(warned, undefined), { kind: "create" });
14+
});
15+
16+
it("always updates the existing comment, whatever it said before", () => {
17+
for (const body of ["<table>✅success</table>", "<table>⚠️warning</table>", undefined]) {
18+
const previous: { id: number; body?: string } = body === undefined ? { id: 7 } : { id: 7, body };
19+
assert.deepEqual(decideCommentAction(clean, previous), { kind: "update", previous, unminimize: false });
20+
}
21+
});
22+
23+
it("asks for the comment to be resurfaced only when the new result has warnings", () => {
24+
const previous = { id: 7, body: "<table></table>" };
25+
assert.deepEqual(decideCommentAction(warned, previous), { kind: "update", previous, unminimize: true });
26+
assert.deepEqual(decideCommentAction(failed, previous), { kind: "update", previous, unminimize: false });
27+
});
28+
});

‎action/src/comment.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const statuses = {
2+
success: "✅success",
3+
warning: "⚠️warning",
4+
error: "❌error",
5+
} as const;
6+
7+
export interface CommentLike {
8+
readonly body?: string | undefined;
9+
}
10+
11+
export type CommentAction<T extends CommentLike> =
12+
| { kind: "create" }
13+
| { kind: "update"; previous: T; unminimize: boolean };
14+
15+
// The bot keeps a single comment and overwrites it on every run, so its links
16+
// always point at the latest run and nobody is notified. A comment someone
17+
// minimized is brought back only when there is a warning to look at.
18+
export function decideCommentAction<T extends CommentLike>(
19+
cellStatuses: readonly string[],
20+
previous: T | undefined,
21+
): CommentAction<T> {
22+
if (previous === undefined) return { kind: "create" };
23+
const unminimize = cellStatuses.includes(statuses.warning);
24+
return { kind: "update", previous, unminimize };
25+
}

‎action/src/index.ts‎

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { readFileSync } from "node:fs";
2+
13
import { Octokit } from "@octokit/rest";
24
import { graphql } from "@octokit/graphql";
35

6+
import { decideCommentAction, statuses } from "./comment.ts";
7+
import { headSha, renderTrailer } from "./trailer.ts";
8+
49
if (!process.env.GITHUB_REF?.startsWith("refs/pull/")) {
510
console.log("not a pull request, exiting.");
611
process.exit(0);
@@ -14,6 +19,8 @@ function requireEnv(name: string): string {
1419

1520
const githubRepository = requireEnv("GITHUB_REPOSITORY");
1621
const githubRef = requireEnv("GITHUB_REF");
22+
const githubSha = requireEnv("GITHUB_SHA");
23+
const githubEventPath = requireEnv("GITHUB_EVENT_PATH");
1724

1825
const runId = parseInt(requireEnv("INPUT_RUN_ID"));
1926
const jobId = parseInt(requireEnv("INPUT_JOB_ID"));
@@ -85,19 +92,19 @@ for (const job of jobList.jobs) {
8592
}
8693
}
8794

88-
let compileResult = "✅success";
95+
let compileResult: string = statuses.success;
8996
let firstIssueLine = 1;
9097
const warningIdx = lines.findIndex((line) => line.match(warningRegex));
9198
console.log(`warningIdx: ${warningIdx}`);
9299
if (warningIdx !== -1) {
93-
compileResult = "⚠️warning";
100+
compileResult = statuses.warning;
94101
firstIssueLine = warningIdx - offset + 1;
95102
console.log(`matched warning line: ${lines[warningIdx]}`);
96103
} else {
97104
const errorIdx = lines.findIndex((line) => line.match(errorRegex));
98105
console.log(`errorIdx: ${errorIdx}`);
99106
if (errorIdx !== -1) {
100-
compileResult = "❌error";
107+
compileResult = statuses.error;
101108
firstIssueLine = errorIdx - offset + 1;
102109
console.log(`matched error line: ${lines[errorIdx]}`);
103110
}
@@ -208,12 +215,20 @@ function generateTable(entries: Row[]): string {
208215
return `<table>${thead}${tbody}</table>`;
209216
}
210217

211-
const body = generateTable(rows);
218+
const table = generateTable(rows);
219+
const event: unknown = JSON.parse(readFileSync(githubEventPath, "utf8"));
220+
const trailer = renderTrailer({
221+
owner,
222+
repo,
223+
sha: headSha(event, githubSha),
224+
updatedAt: new Date(),
225+
});
226+
const body = `${table}\n\n${trailer}`;
212227

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

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

237-
if (sortedComments.length > 0) {
238-
const latestComment = sortedComments[sortedComments.length - 1];
239-
240-
if (body.includes("warning") || latestComment.body?.includes("warning")) {
241-
await gql(
242-
`mutation MinimizeComment($id: ID!) {
243-
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
244-
clientMutationId
245-
}
246-
}`,
247-
{ id: latestComment.node_id },
248-
);
252+
const action = decideCommentAction(
253+
rows.map((r) => r.status),
254+
sortedComments.at(-1),
255+
);
249256

257+
switch (action.kind) {
258+
case "create":
250259
await postComment();
251-
}
252-
} else {
253-
await postComment();
260+
break;
261+
case "update":
262+
console.log("updating the previous comment");
263+
await octokit.issues.updateComment({
264+
owner,
265+
repo,
266+
comment_id: action.previous.id,
267+
body,
268+
});
269+
if (action.unminimize) await unminimizeIfHidden(action.previous.node_id);
270+
break;
254271
}
255272
}
256273

274+
async function unminimizeIfHidden(nodeId: string): Promise<void> {
275+
const { node } = await gql<{ node: { isMinimized: boolean } | null }>(
276+
`query IsMinimized($id: ID!) {
277+
node(id: $id) {
278+
... on IssueComment {
279+
isMinimized
280+
}
281+
}
282+
}`,
283+
{ id: nodeId },
284+
);
285+
if (!node?.isMinimized) return;
286+
console.log("unminimizing the previous comment");
287+
await gql(
288+
`mutation UnminimizeComment($id: ID!) {
289+
unminimizeComment(input: { subjectId: $id }) {
290+
clientMutationId
291+
}
292+
}`,
293+
{ id: nodeId },
294+
);
295+
}
296+
257297
// ── Worker authentication helper ────────────────────────────────────────────
258298

259299
async function getInstallationTokenFromWorker(workerUrl: string): Promise<string> {

‎action/src/trailer.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import { headSha, renderTrailer } from "./trailer.ts";
5+
6+
describe("headSha", () => {
7+
it("prefers the pull request head over the merge commit", () => {
8+
const event = { pull_request: { head: { sha: "abc123" } } };
9+
assert.equal(headSha(event, "merge456"), "abc123");
10+
});
11+
12+
it("falls back when the payload has no pull request head", () => {
13+
assert.equal(headSha({}, "merge456"), "merge456");
14+
assert.equal(headSha(undefined, "merge456"), "merge456");
15+
assert.equal(headSha({ pull_request: { head: { sha: 42 } } }, "merge456"), "merge456");
16+
});
17+
});
18+
19+
describe("renderTrailer", () => {
20+
it("states the update time in UTC and links the commit by its short SHA", () => {
21+
const sha = "0123456789abcdef0123456789abcdef01234567";
22+
assert.equal(
23+
renderTrailer({
24+
owner: "iris-cpp",
25+
repo: "cpp-warning-notifier",
26+
sha,
27+
updatedAt: new Date("2026-10-15T06:19:11.123Z"),
28+
}),
29+
`<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>`,
30+
);
31+
});
32+
});

0 commit comments

Comments
 (0)