mirror of
https://github.com/github/codeql-action.git
synced 2026-10-21 10:11:28 +00:00
Add script and Action for managing PR comments
This commit is contained in:
46
.github/actions/post-comment/action.yml
vendored
Normal file
46
.github/actions/post-comment/action.yml
vendored
Normal 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"
|
||||
206
pr-checks/post-comment.test.ts
Normal file
206
pr-checks/post-comment.test.ts
Normal 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
253
pr-checks/post-comment.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user