Skip to content

Commit 8c8b082

Browse files
committed
Add script and Action for managing PR comments
1 parent 9fe741e commit 8c8b082

3 files changed

Lines changed: 505 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: "Post comment"
2+
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."
3+
inputs:
4+
body:
5+
description: "Full comment markdown."
6+
required: true
7+
action-condition:
8+
description: "The business condition ('true' or 'false') that selects which action to perform."
9+
required: true
10+
action-if-true:
11+
description: "Action to perform when action-condition is 'true'. One of: none, insert, upsert, delete."
12+
required: true
13+
action-if-false:
14+
description: "Action to perform when action-condition is 'false'. One of: none, insert, upsert, delete."
15+
required: true
16+
issue-id:
17+
description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided."
18+
required: false
19+
token:
20+
description: "The GitHub token to authenticate with."
21+
required: true
22+
runs:
23+
using: composite
24+
steps:
25+
- name: Post comment
26+
shell: bash
27+
env:
28+
BODY: ${{ inputs.body }}
29+
# Derived from the workflow name, job id, and step id/sequence-number so that repeated
30+
# runs of the same step find the same comment (for upsert/delete), while distinct steps
31+
# (even within the same job) get distinct markers.
32+
MARKER: <!-- post-comment:${{ github.workflow }}:${{ github.job }}:${{ github.action }} -->
33+
GH_TOKEN: ${{ inputs.token }}
34+
ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }}
35+
ACTION_CONDITION: ${{ inputs.action-condition }}
36+
ACTION_IF_TRUE: ${{ inputs.action-if-true }}
37+
ACTION_IF_FALSE: ${{ inputs.action-if-false }}
38+
run: |
39+
npx tsx ./pr-checks/post-comment.ts \
40+
--body "$BODY" \
41+
--marker "$MARKER" \
42+
--action-condition "$ACTION_CONDITION" \
43+
--action-if-true "$ACTION_IF_TRUE" \
44+
--action-if-false "$ACTION_IF_FALSE" \
45+
--issue-id "$ISSUE_ID" \
46+
--repository "$GITHUB_REPOSITORY"

‎pr-checks/post-comment.test.ts‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env npx tsx
2+
3+
/*
4+
Tests for post-comment.ts.
5+
*/
6+
7+
import * as assert from "node:assert/strict";
8+
import { describe, it } from "node:test";
9+
10+
import { type ApiClient } from "./api-client";
11+
import {
12+
findExistingComment,
13+
parseOptions,
14+
resolveToken,
15+
} from "./post-comment";
16+
17+
/** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */
18+
const validFlags = [
19+
"--body",
20+
"hello",
21+
"--marker",
22+
"<!-- marker -->",
23+
"--action-condition",
24+
"true",
25+
"--action-if-true",
26+
"insert",
27+
"--action-if-false",
28+
"delete",
29+
"--issue-id",
30+
"42",
31+
"--repository",
32+
"github/codeql-action",
33+
];
34+
35+
/** Returns a copy of `validFlags` with the value following `flag` replaced by `value`. */
36+
function withFlag(flag: string, value: string): string[] {
37+
const flags = [...validFlags];
38+
const index = flags.indexOf(flag);
39+
flags[index + 1] = value;
40+
return flags;
41+
}
42+
43+
/** Returns a copy of `validFlags` with `flag` (and its value) removed entirely. */
44+
function withoutFlag(flag: string): string[] {
45+
const flags = [...validFlags];
46+
const index = flags.indexOf(flag);
47+
flags.splice(index, 2);
48+
return flags;
49+
}
50+
51+
describe("parseOptions", async () => {
52+
await it("parses valid flags", () => {
53+
const options = parseOptions(validFlags);
54+
assert.deepEqual(options, {
55+
body: "hello",
56+
marker: "<!-- marker -->",
57+
actionCondition: true,
58+
actionIfTrue: "insert",
59+
actionIfFalse: "delete",
60+
issueId: 42,
61+
repository: { owner: "github", repo: "codeql-action" },
62+
});
63+
});
64+
65+
await it("parses action-condition=false", () => {
66+
const options = parseOptions(withFlag("--action-condition", "false"));
67+
assert.equal(options.actionCondition, false);
68+
});
69+
70+
await it("rejects a missing --body", () => {
71+
assert.throws(
72+
() => parseOptions(withoutFlag("--body")),
73+
/Missing required flag --body/,
74+
);
75+
});
76+
77+
await it("rejects a missing --marker", () => {
78+
assert.throws(
79+
() => parseOptions(withoutFlag("--marker")),
80+
/Missing required flag --marker/,
81+
);
82+
});
83+
84+
await it("rejects an invalid --action-condition value", () => {
85+
assert.throws(
86+
() => parseOptions(withFlag("--action-condition", "yes")),
87+
/Invalid value 'yes' for --action-condition/,
88+
);
89+
});
90+
91+
await it("rejects an invalid --action-if-true value", () => {
92+
assert.throws(
93+
() => parseOptions(withFlag("--action-if-true", "bogus")),
94+
/Invalid value 'bogus' for --action-if-true/,
95+
);
96+
});
97+
98+
await it("rejects an invalid --action-if-false value", () => {
99+
assert.throws(
100+
() => parseOptions(withFlag("--action-if-false", "bogus")),
101+
/Invalid value 'bogus' for --action-if-false/,
102+
);
103+
});
104+
105+
await it("rejects a missing --issue-id", () => {
106+
assert.throws(
107+
() => parseOptions(withoutFlag("--issue-id")),
108+
/Missing required flag --issue-id/,
109+
);
110+
});
111+
112+
await it("rejects a non-numeric --issue-id", () => {
113+
assert.throws(
114+
() => parseOptions(withFlag("--issue-id", "not-a-number")),
115+
/Invalid value 'not-a-number' for --issue-id/,
116+
);
117+
});
118+
119+
await it("rejects a missing --repository", () => {
120+
assert.throws(
121+
() => parseOptions(withoutFlag("--repository")),
122+
/Missing required flag --repository/,
123+
);
124+
});
125+
126+
await it("rejects a --repository that isn't in the form 'owner/repo'", () => {
127+
assert.throws(
128+
() => parseOptions(withFlag("--repository", "not-a-slug")),
129+
/Invalid value 'not-a-slug' for --repository/,
130+
);
131+
});
132+
});
133+
134+
describe("resolveToken", async () => {
135+
await it("reads the token from GH_TOKEN", () => {
136+
assert.equal(
137+
resolveToken({ GH_TOKEN: "gh-token", GITHUB_TOKEN: "gha-token" }),
138+
"gh-token",
139+
);
140+
});
141+
142+
await it("falls back to GITHUB_TOKEN", () => {
143+
assert.equal(resolveToken({ GITHUB_TOKEN: "gha-token" }), "gha-token");
144+
});
145+
146+
await it("throws when no token is set", () => {
147+
assert.throws(() => resolveToken({}), /Missing authentication token/);
148+
});
149+
});
150+
151+
/** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */
152+
function fakeClient(existingComments: Array<{ id: number; body?: string }>) {
153+
const calls: {
154+
createComment: unknown[];
155+
updateComment: unknown[];
156+
deleteComment: unknown[];
157+
} = { createComment: [], updateComment: [], deleteComment: [] };
158+
159+
const client = {
160+
paginate: async () => existingComments,
161+
rest: {
162+
issues: {
163+
listComments: () => {},
164+
createComment: async (params: unknown) => {
165+
calls.createComment.push(params);
166+
},
167+
updateComment: async (params: unknown) => {
168+
calls.updateComment.push(params);
169+
},
170+
deleteComment: async (params: unknown) => {
171+
calls.deleteComment.push(params);
172+
},
173+
},
174+
},
175+
};
176+
177+
return { client: client as unknown as ApiClient, calls };
178+
}
179+
180+
describe("findExistingComment", async () => {
181+
await it("finds the id of the first comment containing the marker", async () => {
182+
const { client } = fakeClient([
183+
{ id: 1, body: "unrelated" },
184+
{ id: 2, body: "<!-- marker -->\nhello" },
185+
{ id: 3, body: "<!-- marker -->\nanother" },
186+
]);
187+
const id = await findExistingComment(
188+
client,
189+
{ owner: "owner", repo: "repo" },
190+
1,
191+
"<!-- marker -->",
192+
);
193+
assert.equal(id, 2);
194+
});
195+
196+
await it("returns undefined when no comment matches", async () => {
197+
const { client } = fakeClient([{ id: 1, body: "unrelated" }]);
198+
const id = await findExistingComment(
199+
client,
200+
{ owner: "owner", repo: "repo" },
201+
1,
202+
"<!-- marker -->",
203+
);
204+
assert.equal(id, undefined);
205+
});
206+
});

0 commit comments

Comments
 (0)