ci: collapse mock e2e PR comment tables (#1465)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Engel Nyst
2026-06-23 15:56:16 +02:00
committed by GitHub
co-authored by openhands
parent c95176f517
commit 01d141d0cd
7 changed files with 505 additions and 108 deletions
+5 -2
View File
@@ -403,8 +403,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh pr comment "${{ steps.ctx.outputs.pr_number }}" \
--body-file "$MOCK_LLM_REPORT_PATH"
node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \
--issue-number "${{ steps.ctx.outputs.pr_number }}" \
--body-file "$MOCK_LLM_REPORT_PATH" \
--marker "<!-- agent-canvas-mock-llm-docker-e2e-report -->" \
--legacy-title "Mock-LLM Docker E2E Test Results"
- name: Fail job when tests fail
if: always()
+12 -5
View File
@@ -6,8 +6,8 @@
#
# Same-repo PRs already get an inline comment from the test workflow
# itself; this workflow covers the fork-PR gap. It is safe because it
# never checks out or executes PR code — it only downloads the rendered
# report artifact and posts it.
# only checks out base-repository workflow code, never PR code, then downloads
# the rendered report artifact and posts it.
name: "Mock-LLM E2E: Post PR Comment"
@@ -17,6 +17,7 @@ on:
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
@@ -28,6 +29,9 @@ jobs:
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name != github.repository
steps:
- name: Check out base repository
uses: actions/checkout@v6
- name: Download PR comment payload
uses: actions/download-artifact@v4
id: download
@@ -41,12 +45,15 @@ jobs:
if: steps.download.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}
run: |
PR_NUMBER=$(cat pr_number.txt)
if [ -z "$PR_NUMBER" ]; then
echo "::warning::Could not determine PR number; skipping comment."
exit 0
fi
gh pr comment "$PR_NUMBER" --body-file mock-llm-report.md
node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \
--issue-number "$PR_NUMBER" \
--body-file mock-llm-report.md \
--marker "<!-- agent-canvas-mock-llm-e2e-report -->" \
--legacy-title "Mock-LLM E2E Tests"
+5 -2
View File
@@ -322,8 +322,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--body-file "$MOCK_LLM_REPORT_PATH"
node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \
--issue-number "${{ github.event.pull_request.number }}" \
--body-file "$MOCK_LLM_REPORT_PATH" \
--marker "<!-- agent-canvas-mock-llm-e2e-report -->" \
--legacy-title "Mock-LLM E2E Tests"
- name: Fail job when tests fail
if: always()
+1 -1
View File
@@ -179,7 +179,7 @@ you are running inside of — NOT the automation backend.
- `regressions/` — CSS isolation, event pagination, workspace persistence (`mock-llm-ui-regressions.spec.ts`). Always included in selective runs.
- **Selective test execution**: `test-mapping.json` maps source paths to test subdirectories. The `scripts/resolve-affected-tests.mjs` script reads the PR's changed files and outputs which test directories to run. Four resolution modes: (1) changed files match specific `mappings` → run only those subdirs + `regressions`; (2) changed mock-LLM spec files → run the containing feature subdirectory + `regressions`, so test-only PRs that add new specs still execute the new tests; (3) changed files match `runAllSources` patterns (cross-cutting files like `src/api/agent-server-adapter.ts`, `package.json`, shared test helpers, or `test-mapping.json`) or are unmapped `src/` files → run full suite (`__ALL__`); (4) changed files are outside the E2E-relevant tree (docs, specs) → nothing; the workflow still starts so required checks do not remain pending, but the heavy test job is skipped by its internal change detector. The CI workflow's "Resolve affected test directories" step runs the script and passes the result to Playwright; `workflow_dispatch` always runs the full suite.
- Tests run serially (`workers: 1`, `mode: "serial"` per describe block). Each spec is self-contained (configures its own LLM profile, resets mock LLM in `afterEach`). The `afterEach` hook resets the mock LLM to its default trajectory so subsequent specs start fresh even when a preceding test fails.
- CI workflow: `.github/workflows/mock-llm-e2e.yml` runs on PR commits (opened, synchronize, reopened) and on manual dispatch. It intentionally does **not** use `pull_request.paths` filters, because path-skipped workflows can leave required checks pending. Instead a lightweight `detect-pr-changes` job marks the heavy `mock-llm-e2e` job skipped-success for PRs that only touch docs, specs, or other non-stack files. Relevant paths are `src/**`, `public/**`, `scripts/**`, `bin/**`, `config/**`, `tests/e2e/mock-llm/**`, `tests/e2e/support/**`, `package.json`, `package-lock.json`, build/TS configs, styling configs, and the workflow file itself. `workflow_dispatch` is unaffected by path filters and always runs. The workflow builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results. The PR comment marks tests from newly added spec files with a 🆕 badge; the "Detect newly added spec files" step queries the GitHub API for files with `status == "added"` matching `tests/e2e/mock-llm/**/*.spec.ts`, and passes them as `--new-files` to `render-mock-llm-report.mjs`. The summary line shows the count of new tests (e.g. `🆕 2 new`). The same detection is wired into the Docker E2E workflow (`mock-llm-docker-e2e.yml`).
- CI workflow: `.github/workflows/mock-llm-e2e.yml` runs on PR commits (opened, synchronize, reopened) and on manual dispatch. It intentionally does **not** use `pull_request.paths` filters, because path-skipped workflows can leave required checks pending. Instead a lightweight `detect-pr-changes` job marks the heavy `mock-llm-e2e` job skipped-success for PRs that only touch docs, specs, or other non-stack files. Relevant paths are `src/**`, `public/**`, `scripts/**`, `bin/**`, `config/**`, `tests/e2e/mock-llm/**`, `tests/e2e/support/**`, `package.json`, `package-lock.json`, build/TS configs, styling configs, and the workflow file itself. `workflow_dispatch` is unaffected by path filters and always runs. The workflow builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results. `render-mock-llm-report.mjs` keeps only the heading/summary/commit links visible and wraps the full test table in a `<details><summary>Details</summary>` block. `upsert-pr-comment.mjs` deletes any older comment for the same mock-LLM job (matched by hidden marker or the legacy bot-authored heading) before posting the latest report, so same-job comments do not accumulate. The PR comment marks tests from newly added spec files with a 🆕 badge; the "Detect newly added spec files" step queries the GitHub API for files with `status == "added"` matching `tests/e2e/mock-llm/**/*.spec.ts`, and passes them as `--new-files` to `render-mock-llm-report.mjs`. The summary line shows the count of new tests (e.g. `🆕 2 new`). The same detection and comment replacement flow are wired into the Docker E2E workflow (`mock-llm-docker-e2e.yml`).
- The custom `DoneMarkerReporter` writes `.mock-llm-markers/.tests-done` after all tests complete (before webServer teardown) so the CI wrapper can detect completion and kill the lingering teardown process.
### Docker Image Testing (Shared Specs)
+89
View File
@@ -0,0 +1,89 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderReport } from "../../tests/e2e/mock-llm/scripts/render-mock-llm-report.mjs";
import {
buildCommentBody,
findMatchingJobComments,
} from "../../tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs";
const MOCK_MARKER = "<!-- agent-canvas-mock-llm-e2e-report -->";
describe("mock-LLM E2E reporting", () => {
it("keeps the summary visible while hiding the results table in details", () => {
const report = renderReport({
tests: [
{
title: "conversations/mock-llm-conversation.spec.ts › runs command",
file: "conversations/mock-llm-conversation.spec.ts",
status: "passed",
durationMs: 1250,
retryCount: 0,
error: "",
},
],
workflowUrl:
"https://github.com/OpenHands/agent-canvas/actions/runs/28000401257",
commit: "82c9e1d04d62961e14742e20a4237ecd6db20ff0",
artifactUrl:
"https://github.com/OpenHands/agent-canvas/actions/runs/28000401257/artifacts/7811037798",
title: "Mock-LLM Docker E2E Test Results",
newFiles: [],
markerMeta: null,
});
const detailsIndex = report.indexOf("<details>");
const tableIndex = report.indexOf("| Status | Test | Duration |");
const visibleSummary = report.slice(0, detailsIndex);
expect(visibleSummary).toContain("## ✅ Mock-LLM Docker E2E Test Results");
expect(visibleSummary).toContain("**1/1 passed**");
expect(visibleSummary).toContain("Commit: `82c9e1d0`");
expect(visibleSummary).toContain("[Workflow run]");
expect(visibleSummary).toContain("[Test artifacts]");
expect(visibleSummary).not.toContain("| Status | Test | Duration |");
expect(visibleSummary).not.toContain("Posted by the Mock-LLM E2E workflow");
expect(report).toContain("<summary>Details</summary>");
expect(detailsIndex).toBeGreaterThan(-1);
expect(tableIndex).toBeGreaterThan(detailsIndex);
});
it("marks new comments and finds older comments for the same job", () => {
const body = "## ✅ Mock-LLM E2E Tests\n\n**60/60 passed**";
expect(buildCommentBody(body, MOCK_MARKER)).toBe(`${MOCK_MARKER}\n${body}`);
expect(buildCommentBody(`${MOCK_MARKER}\n${body}`, MOCK_MARKER)).toBe(
`${MOCK_MARKER}\n${body}`,
);
const matching = findMatchingJobComments(
[
{
id: 1,
body: `${MOCK_MARKER}\n## ✅ Mock-LLM E2E Tests`,
user: { login: "someone", type: "User" },
},
{
id: 2,
body: "## ✅ Mock-LLM E2E Tests\n\nolder unmarked body",
user: { login: "github-actions[bot]", type: "Bot" },
},
{
id: 3,
body: "## ✅ Mock-LLM E2E Tests\n\nhuman mention",
user: { login: "maintainer", type: "User" },
},
{
id: 4,
body: "## ✅ Mock-LLM Docker E2E Test Results\n\nother job",
user: { login: "github-actions[bot]", type: "Bot" },
},
],
{ marker: MOCK_MARKER, legacyTitle: "Mock-LLM E2E Tests" },
);
expect(matching.map((comment: { id: number }) => comment.id)).toEqual([
1, 2,
]);
});
});
@@ -14,8 +14,7 @@
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
// Each CI run posts a fresh comment (no upsert), so no dedup marker needed.
import { pathToFileURL } from "node:url";
// ── CLI args ───────────────────────────────────────────────────────────
@@ -84,7 +83,10 @@ function collectTests(suites, parents = [], parentFile = "") {
function extractError(result) {
if (!result) return "";
const errorMessages = Array.isArray(result.errors)
? result.errors.map((e) => e.message).filter(Boolean).join("\n\n")
? result.errors
.map((e) => e.message)
.filter(Boolean)
.join("\n\n")
: "";
const msg = result.error?.message ?? errorMessages;
// Trim to avoid bloating the comment
@@ -145,7 +147,7 @@ function overallIcon(status) {
// ── Report rendering ───────────────────────────────────────────────────
function renderReport({
export function renderReport({
tests,
workflowUrl,
commit,
@@ -163,7 +165,8 @@ function renderReport({
const skipped = tests.filter((t) => t.status === "skipped").length;
const total = tests.length;
const wasKilledMidSuite =
markerMeta?.status === "in_progress" && markerMeta.total > markerMeta.completed;
markerMeta?.status === "in_progress" &&
markerMeta.total > markerMeta.completed;
// Determine which tests are new (from newly added spec files).
// Playwright's JSON file paths are relative to testDir (e.g. "mock-llm-skills.spec.ts")
@@ -197,7 +200,9 @@ function renderReport({
if (newCount) parts.push(`🆕 ${newCount} new`);
if (wasKilledMidSuite) {
const notRun = markerMeta.total - markerMeta.completed;
parts.push(`⚠️ **${notRun} not run** (process killed at ${markerMeta.completed}/${markerMeta.total})`);
parts.push(
`⚠️ **${notRun} not run** (process killed at ${markerMeta.completed}/${markerMeta.total})`,
);
}
lines.push(parts.join(" · "));
lines.push("");
@@ -212,6 +217,10 @@ function renderReport({
lines.push("");
}
lines.push("<details>");
lines.push("<summary>Details</summary>");
lines.push("");
// New-tests callout (prominent, above the table)
if (newCount > 0) {
const newTests = tests.filter(isNewTest);
@@ -222,10 +231,14 @@ function renderReport({
if (!byFile.has(key)) byFile.set(key, []);
byFile.get(key).push(t);
}
lines.push(`> **🟢 ${newCount} new test${newCount === 1 ? "" : "s"} added in this PR**`);
lines.push(
`> **🟢 ${newCount} new test${newCount === 1 ? "" : "s"} added in this PR**`,
);
for (const [file, fileTests] of byFile) {
for (const t of fileTests) {
lines.push(`> - ${statusIcon(t.status)} \`${file}\` › ${t.title.replace(/^.*› /, "")}`);
lines.push(
`> - ${statusIcon(t.status)} \`${file}\` › ${t.title.replace(/^.*› /, "")}`,
);
}
}
lines.push("");
@@ -244,8 +257,7 @@ function renderReport({
// Error details for failed tests
const failures = tests.filter(
(t) =>
(t.status === "failed" || t.status === "timedOut") && t.error,
(t) => (t.status === "failed" || t.status === "timedOut") && t.error,
);
if (failures.length > 0) {
lines.push("<details>");
@@ -267,114 +279,125 @@ function renderReport({
"<sub>Posted by the Mock-LLM E2E workflow · results are deterministic (scripted LLM responses)</sub>",
);
lines.push("");
lines.push("</details>");
lines.push("");
return lines.join("\n");
}
// ── Main ───────────────────────────────────────────────────────────────
const args = parseArgs(process.argv.slice(2));
const resultsPath = args.results || "test-results-mock-llm/results.json";
const outputPath = args.output || "mock-llm-report.md";
function main() {
const args = parseArgs(process.argv.slice(2));
const resultsPath = args.results || "test-results-mock-llm/results.json";
const outputPath = args.output || "mock-llm-report.md";
const data = loadResults(resultsPath);
let tests = data ? collectTests(data.suites) : [];
const data = loadResults(resultsPath);
let tests = data ? collectTests(data.suites) : [];
// When Playwright is killed during webServer teardown (or mid-suite),
// the JSON reporter never flushes results.json. Fall back to .results.json
// written incrementally by DoneMarkerReporter after every onTestEnd().
let markerMeta = null;
if (!data || tests.length === 0) {
const markerDir = args.marker_dir || ".mock-llm-markers";
const markerResultsPath = `${markerDir}/.results.json`;
const donePath = `${markerDir}/.tests-done`;
// When Playwright is killed during webServer teardown (or mid-suite),
// the JSON reporter never flushes results.json. Fall back to .results.json
// written incrementally by DoneMarkerReporter after every onTestEnd().
let markerMeta = null;
if (!data || tests.length === 0) {
const markerDir = args.marker_dir || ".mock-llm-markers";
const markerResultsPath = `${markerDir}/.results.json`;
const donePath = `${markerDir}/.tests-done`;
if (existsSync(markerResultsPath)) {
// Rich results from DoneMarkerReporter — has per-test timing & errors.
// May be partial (status: "in_progress") if the process was killed
// before all tests finished.
const markerData = JSON.parse(readFileSync(markerResultsPath, "utf8"));
tests = (markerData.tests ?? []).map((t) => ({
title: t.title,
status: t.status,
durationMs: t.durationMs ?? 0,
retryCount: 0,
error: t.error ?? "",
}));
markerMeta = {
status: markerData.status,
completed: markerData.completed ?? tests.length,
total: markerData.total ?? tests.length,
};
console.log(
`No results.json; using marker results (${tests.length} tests run, ${markerMeta.completed}/${markerMeta.total} completed, status: ${markerData.status})`,
);
} else if (existsSync(donePath)) {
// Minimal fallback — just pass/fail status, no timing
const markerStatus = readFileSync(donePath, "utf8").trim();
console.log(
`No results.json; using done marker (status: ${markerStatus})`,
);
tests = [
{
title: "mock-LLM agent-server conversation",
status: markerStatus === "passed" ? "passed" : "failed",
durationMs: 0,
if (existsSync(markerResultsPath)) {
// Rich results from DoneMarkerReporter — has per-test timing & errors.
// May be partial (status: "in_progress") if the process was killed
// before all tests finished.
const markerData = JSON.parse(readFileSync(markerResultsPath, "utf8"));
tests = (markerData.tests ?? []).map((t) => ({
title: t.title,
status: t.status,
durationMs: t.durationMs ?? 0,
retryCount: 0,
error:
markerStatus !== "passed"
? "Test failed (details in workflow logs)"
: "",
},
];
} else {
// No results file AND no marker files — Playwright was likely killed
// before the DoneMarkerReporter could run. Check the exit code to
// distinguish a genuine timeout from other failures.
const exitCode = args.exit_code || "";
if (exitCode === "124") {
console.warn(
`Warning: test suite timed out (exit code 124) — no results were collected`,
error: t.error ?? "",
}));
markerMeta = {
status: markerData.status,
completed: markerData.completed ?? tests.length,
total: markerData.total ?? tests.length,
};
console.log(
`No results.json; using marker results (${tests.length} tests run, ${markerMeta.completed}/${markerMeta.total} completed, status: ${markerData.status})`,
);
} else if (existsSync(donePath)) {
// Minimal fallback — just pass/fail status, no timing
const markerStatus = readFileSync(donePath, "utf8").trim();
console.log(
`No results.json; using done marker (status: ${markerStatus})`,
);
tests = [
{
title: "(test suite timed out before completing)",
status: "timedOut",
title: "mock-LLM agent-server conversation",
status: markerStatus === "passed" ? "passed" : "failed",
durationMs: 0,
retryCount: 0,
error:
"The CI wrapper killed the Playwright process after the 5-minute deadline. " +
"No test results were collected. Check the workflow logs for details.",
markerStatus !== "passed"
? "Test failed (details in workflow logs)"
: "",
},
];
} else {
console.warn(
`Warning: no results file at ${resultsPath} and no marker files` +
(exitCode ? ` (exit code: ${exitCode})` : ""),
);
// No results file AND no marker files — Playwright was likely killed
// before the DoneMarkerReporter could run. Check the exit code to
// distinguish a genuine timeout from other failures.
const exitCode = args.exit_code || "";
if (exitCode === "124") {
console.warn(
`Warning: test suite timed out (exit code 124) — no results were collected`,
);
tests = [
{
title: "(test suite timed out before completing)",
status: "timedOut",
durationMs: 0,
retryCount: 0,
error:
"The CI wrapper killed the Playwright process after the 5-minute deadline. " +
"No test results were collected. Check the workflow logs for details.",
},
];
} else {
console.warn(
`Warning: no results file at ${resultsPath} and no marker files` +
(exitCode ? ` (exit code: ${exitCode})` : ""),
);
}
}
}
// Parse --new-files: comma-separated list of spec file paths added in this PR
const newFiles = args.new_files
? args.new_files
.split(",")
.map((f) => f.trim())
.filter(Boolean)
: [];
const report = renderReport({
tests,
workflowUrl: args.workflow_url || "",
commit: args.commit || "",
artifactUrl: args.artifact_url || "",
title: args.title || "",
newFiles,
markerMeta,
});
writeFileSync(outputPath, report);
console.log(
`Report written to ${outputPath} (${tests.length} tests, ${overallStatus(tests)})`,
);
}
// Parse --new-files: comma-separated list of spec file paths added in this PR
const newFiles = args.new_files
? args.new_files
.split(",")
.map((f) => f.trim())
.filter(Boolean)
: [];
const report = renderReport({
tests,
workflowUrl: args.workflow_url || "",
commit: args.commit || "",
artifactUrl: args.artifact_url || "",
title: args.title || "",
newFiles,
markerMeta,
});
writeFileSync(outputPath, report);
console.log(
`Report written to ${outputPath} (${tests.length} tests, ${overallStatus(tests)})`,
);
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main();
}
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const DEFAULT_MARKER = "<!-- agent-canvas-mock-llm-e2e-report -->";
const API_ROOT = process.env.GITHUB_API_URL ?? "https://api.github.com";
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) {
continue;
}
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
const key = rawKey.replaceAll("-", "_");
if (inlineValue !== undefined) {
args[key] = inlineValue;
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
args[key] = next;
index += 1;
} else {
args[key] = "";
}
}
return args;
}
function requireValue(name, value) {
if (!value) {
throw new Error(`Missing required value: ${name}`);
}
return value;
}
function assertWithinCwd(label, path) {
const relativePath = relative(realpathSync(resolve(".")), path);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
throw new Error(`${label} must be within the current working directory.`);
}
}
function resolveWithinCwd(label, filePath) {
const resolvedPath = resolve(filePath);
if (!existsSync(resolvedPath)) {
throw new Error(`${label} does not exist: ${resolvedPath}`);
}
assertWithinCwd(label, realpathSync(resolvedPath));
return resolvedPath;
}
function validateRepo(repo) {
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9._-]+$/.test(repo)) {
throw new Error(`Invalid repo format: ${repo}`);
}
return repo;
}
function validateIssueNumber(issueNumber) {
if (!/^\d+$/.test(String(issueNumber))) {
throw new Error(`Invalid issue number: ${issueNumber}`);
}
return String(issueNumber);
}
function sleep(ms) {
return new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}
function retryDelayMs(attempt, response) {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1000;
}
return 1000 * 2 ** attempt;
}
function parseGitHubPayload(method, path, status, text) {
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(
`GitHub API ${method} ${path} returned invalid JSON with status ${status}: ${text.slice(
0,
500,
)}`,
);
}
}
async function githubRequest(method, path, token, body) {
const retryableStatuses = new Set([429, 502, 503, 504]);
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${API_ROOT}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
if (!response.ok && retryableStatuses.has(response.status) && attempt < 4) {
await sleep(retryDelayMs(attempt, response));
continue;
}
const payload = parseGitHubPayload(method, path, response.status, text);
if (!response.ok) {
throw new Error(
`GitHub API ${method} ${path} failed with ${response.status}: ${text}`,
);
}
return payload;
}
throw new Error(`GitHub API ${method} ${path} failed after retries.`);
}
async function listIssueComments(repo, issueNumber, token) {
const comments = [];
for (let page = 1; page <= 10; page += 1) {
const batch = await githubRequest(
"GET",
`/repos/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`,
token,
);
comments.push(...batch);
if (batch.length < 100) {
break;
}
}
return comments;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function buildCommentBody(body, marker = DEFAULT_MARKER) {
if (body.includes(marker)) {
return body;
}
return `${marker}\n${body}`;
}
export function isMatchingJobComment(comment, { marker, legacyTitle }) {
const body = comment.body ?? "";
if (body.includes(marker)) {
return true;
}
if (!legacyTitle) {
return false;
}
const user = comment.user ?? {};
const isBot = user.type === "Bot" || user.login?.endsWith("[bot]");
if (!isBot) {
return false;
}
const legacyHeading = new RegExp(
`^##\\s+\\S+\\s+${escapeRegExp(legacyTitle)}\\s*$`,
"m",
);
return legacyHeading.test(body);
}
export function findMatchingJobComments(comments, options) {
return comments.filter((comment) => isMatchingJobComment(comment, options));
}
export async function replaceJobComment({
repo,
issueNumber,
token,
body,
marker = DEFAULT_MARKER,
legacyTitle = "",
}) {
const comments = await listIssueComments(repo, issueNumber, token);
const existing = findMatchingJobComments(comments, { marker, legacyTitle });
for (const comment of existing) {
await githubRequest(
"DELETE",
`/repos/${repo}/issues/comments/${comment.id}`,
token,
);
}
const created = await githubRequest(
"POST",
`/repos/${repo}/issues/${issueNumber}/comments`,
token,
{ body: buildCommentBody(body, marker) },
);
return { deleted: existing.length, created };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const issueNumber =
args.issue_number ??
process.env.PR_NUMBER ??
process.env.ISSUE_NUMBER ??
"";
if (!issueNumber) {
console.log("Skipping PR comment because no PR number was provided.");
return;
}
const repo = validateRepo(
requireValue("repo", args.repo ?? process.env.GITHUB_REPOSITORY),
);
const validatedIssueNumber = validateIssueNumber(issueNumber);
const token = requireValue(
"token",
args.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
);
const marker = args.marker ?? DEFAULT_MARKER;
const bodyFile = resolveWithinCwd(
"body-file",
requireValue("body-file", args.body_file),
);
const body = readFileSync(bodyFile, "utf8");
const result = await replaceJobComment({
repo,
issueNumber: validatedIssueNumber,
token,
body,
marker,
legacyTitle: args.legacy_title ?? "",
});
console.log(
`Deleted ${result.deleted} existing PR comment${
result.deleted === 1 ? "" : "s"
}; created PR comment ${result.created.id}.`,
);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}