Add script and Action for managing PR comments

This commit is contained in:
Mario Campos
2026-10-08 23:02:25 -05:00
parent 9fe741e77d
commit 8c8b0827d5
3 changed files with 505 additions and 0 deletions

46
.github/actions/post-comment/action.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: "Post comment"
description: "Create, update, or delete a sticky PR comment identified by an automatically-derived HTML-comment marker. If this action is used more than once within the same job, give each step an explicit 'id:' so the derived marker stays unique."
inputs:
body:
description: "Full comment markdown."
required: true
action-condition:
description: "The business condition ('true' or 'false') that selects which action to perform."
required: true
action-if-true:
description: "Action to perform when action-condition is 'true'. One of: none, insert, upsert, delete."
required: true
action-if-false:
description: "Action to perform when action-condition is 'false'. One of: none, insert, upsert, delete."
required: true
issue-id:
description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided."
required: false
token:
description: "The GitHub token to authenticate with."
required: true
runs:
using: composite
steps:
- name: Post comment
shell: bash
env:
BODY: ${{ inputs.body }}
# Derived from the workflow name, job id, and step id/sequence-number so that repeated
# runs of the same step find the same comment (for upsert/delete), while distinct steps
# (even within the same job) get distinct markers.
MARKER: <!-- post-comment:${{ github.workflow }}:${{ github.job }}:${{ github.action }} -->
GH_TOKEN: ${{ inputs.token }}
ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }}
ACTION_CONDITION: ${{ inputs.action-condition }}
ACTION_IF_TRUE: ${{ inputs.action-if-true }}
ACTION_IF_FALSE: ${{ inputs.action-if-false }}
run: |
npx tsx ./pr-checks/post-comment.ts \
--body "$BODY" \
--marker "$MARKER" \
--action-condition "$ACTION_CONDITION" \
--action-if-true "$ACTION_IF_TRUE" \
--action-if-false "$ACTION_IF_FALSE" \
--issue-id "$ISSUE_ID" \
--repository "$GITHUB_REPOSITORY"

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env npx tsx
/*
Tests for post-comment.ts.
*/
import * as assert from "node:assert/strict";
import { describe, it } from "node:test";
import { type ApiClient } from "./api-client";
import {
findExistingComment,
parseOptions,
resolveToken,
} from "./post-comment";
/** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */
const validFlags = [
"--body",
"hello",
"--marker",
"<!-- marker -->",
"--action-condition",
"true",
"--action-if-true",
"insert",
"--action-if-false",
"delete",
"--issue-id",
"42",
"--repository",
"github/codeql-action",
];
/** Returns a copy of `validFlags` with the value following `flag` replaced by `value`. */
function withFlag(flag: string, value: string): string[] {
const flags = [...validFlags];
const index = flags.indexOf(flag);
flags[index + 1] = value;
return flags;
}
/** Returns a copy of `validFlags` with `flag` (and its value) removed entirely. */
function withoutFlag(flag: string): string[] {
const flags = [...validFlags];
const index = flags.indexOf(flag);
flags.splice(index, 2);
return flags;
}
describe("parseOptions", async () => {
await it("parses valid flags", () => {
const options = parseOptions(validFlags);
assert.deepEqual(options, {
body: "hello",
marker: "<!-- marker -->",
actionCondition: true,
actionIfTrue: "insert",
actionIfFalse: "delete",
issueId: 42,
repository: { owner: "github", repo: "codeql-action" },
});
});
await it("parses action-condition=false", () => {
const options = parseOptions(withFlag("--action-condition", "false"));
assert.equal(options.actionCondition, false);
});
await it("rejects a missing --body", () => {
assert.throws(
() => parseOptions(withoutFlag("--body")),
/Missing required flag --body/,
);
});
await it("rejects a missing --marker", () => {
assert.throws(
() => parseOptions(withoutFlag("--marker")),
/Missing required flag --marker/,
);
});
await it("rejects an invalid --action-condition value", () => {
assert.throws(
() => parseOptions(withFlag("--action-condition", "yes")),
/Invalid value 'yes' for --action-condition/,
);
});
await it("rejects an invalid --action-if-true value", () => {
assert.throws(
() => parseOptions(withFlag("--action-if-true", "bogus")),
/Invalid value 'bogus' for --action-if-true/,
);
});
await it("rejects an invalid --action-if-false value", () => {
assert.throws(
() => parseOptions(withFlag("--action-if-false", "bogus")),
/Invalid value 'bogus' for --action-if-false/,
);
});
await it("rejects a missing --issue-id", () => {
assert.throws(
() => parseOptions(withoutFlag("--issue-id")),
/Missing required flag --issue-id/,
);
});
await it("rejects a non-numeric --issue-id", () => {
assert.throws(
() => parseOptions(withFlag("--issue-id", "not-a-number")),
/Invalid value 'not-a-number' for --issue-id/,
);
});
await it("rejects a missing --repository", () => {
assert.throws(
() => parseOptions(withoutFlag("--repository")),
/Missing required flag --repository/,
);
});
await it("rejects a --repository that isn't in the form 'owner/repo'", () => {
assert.throws(
() => parseOptions(withFlag("--repository", "not-a-slug")),
/Invalid value 'not-a-slug' for --repository/,
);
});
});
describe("resolveToken", async () => {
await it("reads the token from GH_TOKEN", () => {
assert.equal(
resolveToken({ GH_TOKEN: "gh-token", GITHUB_TOKEN: "gha-token" }),
"gh-token",
);
});
await it("falls back to GITHUB_TOKEN", () => {
assert.equal(resolveToken({ GITHUB_TOKEN: "gha-token" }), "gha-token");
});
await it("throws when no token is set", () => {
assert.throws(() => resolveToken({}), /Missing authentication token/);
});
});
/** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */
function fakeClient(existingComments: Array<{ id: number; body?: string }>) {
const calls: {
createComment: unknown[];
updateComment: unknown[];
deleteComment: unknown[];
} = { createComment: [], updateComment: [], deleteComment: [] };
const client = {
paginate: async () => existingComments,
rest: {
issues: {
listComments: () => {},
createComment: async (params: unknown) => {
calls.createComment.push(params);
},
updateComment: async (params: unknown) => {
calls.updateComment.push(params);
},
deleteComment: async (params: unknown) => {
calls.deleteComment.push(params);
},
},
},
};
return { client: client as unknown as ApiClient, calls };
}
describe("findExistingComment", async () => {
await it("finds the id of the first comment containing the marker", async () => {
const { client } = fakeClient([
{ id: 1, body: "unrelated" },
{ id: 2, body: "<!-- marker -->\nhello" },
{ id: 3, body: "<!-- marker -->\nanother" },
]);
const id = await findExistingComment(
client,
{ owner: "owner", repo: "repo" },
1,
"<!-- marker -->",
);
assert.equal(id, 2);
});
await it("returns undefined when no comment matches", async () => {
const { client } = fakeClient([{ id: 1, body: "unrelated" }]);
const id = await findExistingComment(
client,
{ owner: "owner", repo: "repo" },
1,
"<!-- marker -->",
);
assert.equal(id, undefined);
});
});

253
pr-checks/post-comment.ts Normal file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env npx tsx
/*
Generic CLI for creating, updating, or deleting a "sticky" PR comment identified by a hidden
HTML-comment marker. This centralizes the gh api logic previously duplicated across PR-check
workflow steps that each managed their own marked comment (e.g. a CHANGELOG.md modification
warning).
The marker is passed explicitly via `--marker` and can be any non-empty string, though an
HTML comment (e.g. `<!-- my-bot -->`) is recommended so it doesn't render visibly. It is used
both to find a pre-existing comment on the pull request so that
`upsert`/`delete` actions know which comment to operate on, and is automatically prepended to
`--body` when posting or updating a comment so that future invocations can find it again. Callers
typically derive this value automatically (see `.github/actions/post-comment`) rather than
hand-authoring it.
*/
import { parseArgs } from "node:util";
import { type ApiClient, getApiClient } from "./api-client";
/** The set of valid actions that can be performed against a (possibly pre-existing) marked comment. */
export const ACTIONS = ["none", "insert", "upsert", "delete"] as const;
/** An action to perform against a (possibly pre-existing) marked comment. */
export type Action = (typeof ACTIONS)[number];
/** Represents the command-line options. */
export interface Options {
/** The full comment body. */
body: string;
/** The hidden HTML-comment marker used to find a pre-existing comment. */
marker: string;
/** The condition that selects which action to perform. */
actionCondition: boolean;
/** The action to perform when `actionCondition` is `true`. */
actionIfTrue: Action;
/** The action to perform when `actionCondition` is `false`. */
actionIfFalse: Action;
/** The issue/pull request number to post/update/delete the comment on. */
issueId: number;
/** The repository to operate on. */
repository: { owner: string; repo: string };
}
/**
* Validates and parses a required CLI flag's raw string `value`, delegating the actual
* value-specific validation/conversion to `parse`.
*/
function parseFlag<T>(
flag: string,
value: string | undefined,
parse: (value: string) => T,
): T {
if (value === undefined) {
throw new Error(`Missing required flag --${flag}.`);
}
try {
return parse(value);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid value '${value}' for --${flag}. ${reason}`);
}
}
/** Parses and validates `value` as an `Action`. */
function parseAction(flag: string, value: string | undefined): Action {
return parseFlag(flag, value, (raw) => {
if (!(ACTIONS as readonly string[]).includes(raw)) {
throw new Error(`Must be one of: ${ACTIONS.join(", ")}.`);
}
return raw as Action;
});
}
/** Parses and validates `value` as a boolean. */
function parseBoolean(flag: string, value: string | undefined): boolean {
return parseFlag(flag, value, (raw) => {
if (raw !== "true" && raw !== "false") {
throw new Error("Must be 'true' or 'false'.");
}
return raw === "true";
});
}
/** Parses and validates `value` as an issue/pull request number. */
function parseIssueId(value: string | undefined): number {
return parseFlag("issue-id", value, (raw) => {
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new Error("Must be an integer.");
}
return parsed;
});
}
/** Parses and validates `value` as an `owner/repo` repository reference. */
function parseRepository(value: string | undefined): {
owner: string;
repo: string;
} {
return parseFlag("repository", value, (raw) => {
const [owner, repo, ...rest] = raw.split("/");
if (!owner || !repo || rest.length > 0) {
throw new Error("Must be in the form 'owner/repo'.");
}
return { owner, repo };
});
}
/** Parses the command-line arguments into `Options`. */
export function parseOptions(argv?: string[]): Options {
const { values } = parseArgs({
args: argv,
options: {
body: { type: "string" },
marker: { type: "string" },
"action-condition": { type: "string" },
"action-if-true": { type: "string" },
"action-if-false": { type: "string" },
"issue-id": { type: "string" },
repository: { type: "string" },
},
strict: true,
});
if (values.body === undefined) {
throw new Error("Missing required flag --body.");
}
if (values.marker === undefined) {
throw new Error("Missing required flag --marker.");
}
return {
body: values.body,
marker: values.marker,
actionCondition: parseBoolean(
"action-condition",
values["action-condition"],
),
actionIfTrue: parseAction("action-if-true", values["action-if-true"]),
actionIfFalse: parseAction("action-if-false", values["action-if-false"]),
issueId: parseIssueId(values["issue-id"]),
repository: parseRepository(values.repository),
};
}
/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */
export function resolveToken(env: NodeJS.ProcessEnv): string {
const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
if (!token) {
throw new Error(
"Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.",
);
}
return token;
}
/**
* Finds the first comment on the given issue/pull request whose body starts with `marker`, if any.
*/
export async function findExistingComment(
client: ApiClient,
repository: { owner: string; repo: string },
issueNumber: number,
marker: string,
): Promise<number | undefined> {
const comments = await client.paginate(client.rest.issues.listComments, {
...repository,
issue_number: issueNumber,
per_page: 100,
});
return comments.find((comment) => comment.body?.startsWith(marker))?.id;
}
async function main(): Promise<number> {
const options = parseOptions();
const action = options.actionCondition
? options.actionIfTrue
: options.actionIfFalse;
console.info(`Resolved action: ${action}`);
const token = resolveToken(process.env);
const { issueId, marker, repository } = options;
const client = getApiClient(token);
const body = `${marker}\n${options.body}`;
let existingCommentId: number | undefined;
if (action === "upsert" || action === "delete") {
existingCommentId = await findExistingComment(
client,
repository,
issueId,
marker,
);
}
switch (action) {
case "none":
console.info("No action needed.");
break;
case "insert":
console.info("Creating a new comment.");
await client.rest.issues.createComment({
...repository,
issue_number: issueId,
body,
});
break;
case "upsert":
if (existingCommentId === undefined) {
console.info("No existing comment found; creating a new comment.");
await client.rest.issues.createComment({
...repository,
issue_number: issueId,
body,
});
} else {
console.info(`Updating existing comment ${existingCommentId}.`);
await client.rest.issues.updateComment({
...repository,
comment_id: existingCommentId,
body,
});
}
break;
case "delete":
if (existingCommentId === undefined) {
console.info("No existing comment found to delete; skipping.");
} else {
console.info(`Deleting existing comment ${existingCommentId}.`);
await client.rest.issues.deleteComment({
...repository,
comment_id: existingCommentId,
});
}
break;
}
return 0;
}
async function run(): Promise<void> {
try {
process.exit(await main());
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
if (import.meta.main) {
void run();
}