Skip to content

Commit 7390060

Browse files
authored
Merge pull request #119 from knowledge-work/feat/issue-101
feat: support `discussion_comment` event
2 parents f8c6ac0 + 4af516a commit 7390060

6 files changed

Lines changed: 216 additions & 75 deletions

File tree

README.md

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,13 @@ jobs:
6363
6464
# Only run if `steps.<id>.outputs.continue` is "true".
6565
# This indicates that `knowledge-work` successfully parsed the command and the subsequent steps should continue.
66+
# IssueOps params are passed through `env:` and referenced as shell variables to avoid
67+
# GitHub Actions expression injection from attacker-controlled comment content.
6668
- name: Greet
6769
if: ${{ steps.command.outputs.continue == 'true' }}
68-
run: echo "Hi ${{ fromJSON(steps.command.outputs.params).name }} !"
70+
env:
71+
NAME: ${{ fromJSON(steps.command.outputs.params).name }}
72+
run: echo "Hi $NAME !"
6973

7074
# Add other steps necessary for IssueOps...
7175
```
@@ -164,10 +168,10 @@ Supports null in JSON format.
164168

165169
<!-- gha-inputs-start -->
166170

167-
| ID | Required | Default | Description |
168-
| :----------------- | :----------------- | :------------------- | :------------------------------------------------------------------------------------------------ |
169-
| `command` | :white_check_mark: | n/a | The name of the command to be used in IssueOps, which can be specified as a comma-separated list. |
170-
| `allowed_contexts` | | `issue,pull_request` | The comment contexts that trigger the IssueOps command, specified as a comma-separated list. |
171+
| ID | Required | Default | Description |
172+
| :----------------- | :----------------- | :------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
173+
| `command` | :white_check_mark: | n/a | The name of the command to be used in IssueOps, which can be specified as a comma-separated list. |
174+
| `allowed_contexts` | | `issue,pull_request,discussion` | The comment contexts that trigger the IssueOps command, specified as a comma-separated list. Allowed values: `"issue"`, `"pull_request"`, `"discussion"`. |
171175

172176
<!-- gha-inputs-end -->
173177

@@ -182,8 +186,8 @@ Supports null in JSON format.
182186
| `comment_id` | The ID of the comment that triggered this action. |
183187
| `actor` | The GitHub handle of the actor who executed the IssueOps command. |
184188
| `issue_number` | [Deprecated] The issue number of the comment that triggered this action. Use `number` instead. This output will be removed in the next major release. |
185-
| `number` | The number of the issue or pull request that triggered this action. |
186-
| `context` | The context that triggered this action. One of `"issue"` or `"pull_request"`. |
189+
| `number` | The number of the issue, pull request, or discussion that triggered this action. |
190+
| `context` | The context that triggered this action. One of `"issue"`, `"pull_request"`, or `"discussion"`. |
187191
| `command` | The command of the triggered IssueOps command. |
188192

189193
<!-- gha-outputs-end -->
@@ -192,6 +196,40 @@ Supports null in JSON format.
192196

193197
A section introducing tips for implementing IssueOps commands.
194198

199+
### Triggering from GitHub Discussions
200+
201+
`command-action` also handles GitHub Discussions when the workflow is triggered by the `discussion_comment` event. The same parsing logic applies — the only differences are that `outputs.context` becomes `"discussion"` and `outputs.issue_number` is not emitted (use `outputs.number` instead).
202+
203+
```yaml
204+
name: 'Greet DEMO (Discussions)'
205+
206+
on:
207+
discussion_comment:
208+
types: [created]
209+
210+
permissions:
211+
contents: read
212+
discussions: write # only required if your follow-up step writes back to the discussion (e.g. reactions via GraphQL)
213+
214+
jobs:
215+
demo:
216+
runs-on: ubuntu-latest
217+
steps:
218+
- id: command
219+
uses: knowledge-work/command-action@v1
220+
with:
221+
command: 'greet'
222+
- if: ${{ steps.command.outputs.continue == 'true' }}
223+
env:
224+
NAME: ${{ fromJSON(steps.command.outputs.params).name }}
225+
run: echo "Hi $NAME !"
226+
```
227+
228+
> [!warning]
229+
> Discussion / Issue / PR comments are attacker-controlled. Pass IssueOps params via `env:` and reference them as shell variables (`"$NAME"`) instead of interpolating `${{ ... }}` directly into a `run:` script. See [GitHub Actions security hardening](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable) for details.
230+
231+
To restrict the action to discussions only (or to issues / pull requests only), use the [`allowed_contexts`](#inbox_tray-inputs) input — for example `allowed_contexts: 'discussion'`.
232+
195233
### Reacting to the comment
196234

197235
You can use [actions/github-script](https://github.com/actions/github-script) to add a reaction to the comment that triggered the IssueOps command.

action.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ inputs:
1010
description: 'The name of the command to be used in IssueOps, which can be specified as a comma-separated list.'
1111
required: true
1212
allowed_contexts:
13-
description: 'The comment contexts that trigger the IssueOps command, specified as a comma-separated list.'
14-
default: 'issue,pull_request'
13+
description: 'The comment contexts that trigger the IssueOps command, specified as a comma-separated list. Allowed values: `"issue"`, `"pull_request"`, `"discussion"`.'
14+
default: 'issue,pull_request,discussion'
1515

1616
outputs:
1717
continue:
@@ -25,9 +25,9 @@ outputs:
2525
issue_number:
2626
description: '[Deprecated] The issue number of the comment that triggered this action. Use `number` instead. This output will be removed in the next major release.'
2727
number:
28-
description: 'The number of the issue or pull request that triggered this action.'
28+
description: 'The number of the issue, pull request, or discussion that triggered this action.'
2929
context:
30-
description: 'The context that triggered this action. One of `"issue"` or `"pull_request"`.'
30+
description: 'The context that triggered this action. One of `"issue"`, `"pull_request"`, or `"discussion"`.'
3131
command:
3232
description: 'The command of the triggered IssueOps command.'
3333

dist/index.js

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38080,53 +38080,62 @@ const str2array = (input) => input
3808038080

3808138081

3808238082

38083-
const validContexts = new Set(['issue', 'pull_request']);
38084-
const isValidContext = (inputs, isPr) => {
38085-
if (github_context.eventName !== 'issue_comment') {
38086-
core/* warning */.$e(`This action only supports the "issue_comment" event, but received "${github_context.eventName}".`);
38087-
return false;
38083+
const validContexts = new Set(['issue', 'pull_request', 'discussion']);
38084+
const resolveCommentContext = () => {
38085+
if (github_context.eventName === 'issue_comment') {
38086+
const isPr = github_context.payload.issue?.['pull_request'] != null;
38087+
return {
38088+
kind: isPr ? 'pull_request' : 'issue',
38089+
number: github_context.payload.issue.number,
38090+
commentId: github_context.payload.comment.id,
38091+
actor: github_context.payload.comment['user'].login,
38092+
};
38093+
}
38094+
if (github_context.eventName === 'discussion_comment') {
38095+
const discussion = github_context.payload['discussion'];
38096+
return {
38097+
kind: 'discussion',
38098+
number: discussion.number,
38099+
commentId: github_context.payload.comment.id,
38100+
actor: github_context.payload.comment['user'].login,
38101+
};
3808838102
}
38103+
return null;
38104+
};
38105+
const isValidContext = (inputs, kind) => {
3808938106
const allowedContexts = str2array(inputs.allowed_contexts);
3809038107
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c));
3809138108
if (invalidContexts.length > 0) {
38092-
const list = [...validContexts].map((c) => `"${c}"`).join(' and ');
38109+
const list = [...validContexts].map((c) => `"${c}"`).join(', ');
3809338110
core/* warning */.$e(`The "allowed_contexts" must be a comma-separated string of ${list}, but received "${invalidContexts.join(',')}".`);
3809438111
return false;
3809538112
}
38096-
if (allowedContexts.length === 1) {
38097-
switch (allowedContexts[0]) {
38098-
case 'issue': {
38099-
if (isPr) {
38100-
core/* info */.pq(`💡The 'issue' context is not allowed for pull requests.`);
38101-
return false;
38102-
}
38103-
break;
38104-
}
38105-
case 'pull_request': {
38106-
if (!isPr) {
38107-
core/* info */.pq(`💡The 'pull_request' context is not allowed for issues.`);
38108-
return false;
38109-
}
38110-
break;
38111-
}
38112-
}
38113+
if (!allowedContexts.includes(kind)) {
38114+
core/* info */.pq(`💡The current context "${kind}" is not in allowed_contexts (${allowedContexts.join(',')}).`);
38115+
return false;
3811338116
}
3811438117
return true;
3811538118
};
3811638119
const run = async () => {
3811738120
const inputs = getInputs();
3811838121
core/* debug */.Yz(`inputs: ${JSON.stringify(inputs)}`);
38119-
const isPr = github_context?.payload?.issue?.['pull_request'] != null;
38120-
if (!isValidContext(inputs, isPr)) {
38122+
const ctx = resolveCommentContext();
38123+
if (ctx === null) {
38124+
core/* warning */.$e(`This action only supports the "issue_comment" or "discussion_comment" event, but received "${github_context.eventName}".`);
3812138125
core/* setOutput */.uH('continue', 'false');
3812238126
return 0;
3812338127
}
38124-
const issueNumber = github_context.payload.issue.number;
38125-
core/* setOutput */.uH('issue_number', issueNumber);
38126-
core/* setOutput */.uH('number', issueNumber);
38127-
core/* setOutput */.uH('context', isPr ? 'pull_request' : 'issue');
38128-
core/* setOutput */.uH('comment_id', github_context.payload.comment.id);
38129-
core/* setOutput */.uH('actor', github_context.payload.comment['user'].login);
38128+
if (!isValidContext(inputs, ctx.kind)) {
38129+
core/* setOutput */.uH('continue', 'false');
38130+
return 0;
38131+
}
38132+
core/* setOutput */.uH('number', ctx.number);
38133+
core/* setOutput */.uH('context', ctx.kind);
38134+
core/* setOutput */.uH('comment_id', ctx.commentId);
38135+
core/* setOutput */.uH('actor', ctx.actor);
38136+
if (ctx.kind !== 'discussion') {
38137+
core/* setOutput */.uH('issue_number', ctx.number);
38138+
}
3813038139
const commands = str2array(inputs.command);
3813138140
const body = (github_context.payload.comment?.['body'] ?? '');
3813238141
const result = parse_parse(body);

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.

src/main.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,78 @@ test('invalid context emits only continue=false and never emits number / context
9292
expect(outputFor('number')).toBeUndefined();
9393
expect(outputFor('context')).toBeUndefined();
9494
expect(outputFor('issue_number')).toBeUndefined();
95+
expect(mocks.warning).toHaveBeenCalled();
96+
const warningMessage = mocks.warning.mock.calls[0]?.[0] as string;
97+
expect(warningMessage).toContain('issue_comment');
98+
expect(warningMessage).toContain('discussion_comment');
99+
});
100+
101+
test('discussion context emits number and context="discussion" without issue_number', async () => {
102+
mocks.inputs['allowed_contexts'] = 'issue,pull_request,discussion';
103+
mocks.context.eventName = 'discussion_comment';
104+
mocks.context.payload = {
105+
discussion: { number: 7 },
106+
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
107+
};
108+
109+
await run();
110+
111+
expect(outputFor('number')).toBe(7);
112+
expect(outputFor('context')).toBe('discussion');
113+
expect(outputFor('comment_id')).toBe(4004);
114+
expect(outputFor('actor')).toBe('carol');
115+
expect(outputFor('issue_number')).toBeUndefined();
116+
expect(outputFor('continue')).toBe('true');
117+
});
118+
119+
test('allowed_contexts="issue" rejects a discussion_comment via the filter', async () => {
120+
mocks.inputs['allowed_contexts'] = 'issue';
121+
mocks.context.eventName = 'discussion_comment';
122+
mocks.context.payload = {
123+
discussion: { number: 7 },
124+
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
125+
};
126+
127+
await run();
128+
129+
expect(outputFor('continue')).toBe('false');
130+
expect(outputFor('number')).toBeUndefined();
131+
expect(outputFor('context')).toBeUndefined();
132+
expect(outputFor('issue_number')).toBeUndefined();
133+
expect(mocks.info).toHaveBeenCalled();
134+
});
135+
136+
test('allowed_contexts="discussion" accepts a discussion_comment (single-context positive)', async () => {
137+
mocks.inputs['allowed_contexts'] = 'discussion';
138+
mocks.context.eventName = 'discussion_comment';
139+
mocks.context.payload = {
140+
discussion: { number: 7 },
141+
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
142+
};
143+
144+
await run();
145+
146+
expect(outputFor('number')).toBe(7);
147+
expect(outputFor('context')).toBe('discussion');
148+
expect(outputFor('issue_number')).toBeUndefined();
149+
expect(outputFor('continue')).toBe('true');
150+
const rejectionInfoCalls = mocks.info.mock.calls.filter(([msg]) =>
151+
typeof msg === 'string' ? msg.includes('not in allowed_contexts') : false,
152+
);
153+
expect(rejectionInfoCalls).toHaveLength(0);
154+
});
155+
156+
test('allowed_contexts="discussion" rejects an issue_comment (heterogeneous single-context)', async () => {
157+
mocks.inputs['allowed_contexts'] = 'discussion';
158+
mocks.context.eventName = 'issue_comment';
159+
mocks.context.payload = {
160+
issue: { number: 42 },
161+
comment: { id: 1001, body: '.foo', user: { login: 'alice' } },
162+
};
163+
164+
await run();
165+
166+
expect(outputFor('continue')).toBe('false');
167+
expect(outputFor('context')).toBeUndefined();
168+
expect(outputFor('number')).toBeUndefined();
95169
});

src/main.ts

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,53 @@ import { type Inputs, getInputs } from './inputs.js';
44
import { parse } from './parse.js';
55
import { str2array } from './utils.js';
66

7-
const validContexts = new Set(['issue', 'pull_request']);
7+
type CommentKind = 'issue' | 'pull_request' | 'discussion';
88

9-
const isValidContext = (inputs: Inputs, isPr: boolean) => {
10-
if (context.eventName !== 'issue_comment') {
11-
core.warning(`This action only supports the "issue_comment" event, but received "${context.eventName}".`);
12-
return false;
9+
const validContexts = new Set<CommentKind>(['issue', 'pull_request', 'discussion']);
10+
11+
type CommentContext = {
12+
kind: CommentKind;
13+
number: number;
14+
commentId: number;
15+
actor: string;
16+
};
17+
18+
const resolveCommentContext = (): CommentContext | null => {
19+
if (context.eventName === 'issue_comment') {
20+
const isPr = context.payload.issue?.['pull_request'] != null;
21+
return {
22+
kind: isPr ? 'pull_request' : 'issue',
23+
number: context.payload.issue!.number!,
24+
commentId: context.payload.comment!.id,
25+
actor: context.payload.comment!['user'].login,
26+
};
1327
}
28+
if (context.eventName === 'discussion_comment') {
29+
const discussion = context.payload['discussion'] as { number: number } | undefined;
30+
return {
31+
kind: 'discussion',
32+
number: discussion!.number,
33+
commentId: context.payload.comment!.id,
34+
actor: context.payload.comment!['user'].login,
35+
};
36+
}
37+
return null;
38+
};
1439

40+
const isValidContext = (inputs: Inputs, kind: CommentKind) => {
1541
const allowedContexts = str2array(inputs.allowed_contexts);
16-
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c));
42+
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c as CommentKind));
1743
if (invalidContexts.length > 0) {
18-
const list = [...validContexts].map((c) => `"${c}"`).join(' and ');
44+
const list = [...validContexts].map((c) => `"${c}"`).join(', ');
1945
core.warning(
2046
`The "allowed_contexts" must be a comma-separated string of ${list}, but received "${invalidContexts.join(',')}".`,
2147
);
2248
return false;
2349
}
2450

25-
if (allowedContexts.length === 1) {
26-
switch (allowedContexts[0]) {
27-
case 'issue': {
28-
if (isPr) {
29-
core.info(`💡The 'issue' context is not allowed for pull requests.`);
30-
return false;
31-
}
32-
break;
33-
}
34-
case 'pull_request': {
35-
if (!isPr) {
36-
core.info(`💡The 'pull_request' context is not allowed for issues.`);
37-
return false;
38-
}
39-
break;
40-
}
41-
}
51+
if (!allowedContexts.includes(kind)) {
52+
core.info(`💡The current context "${kind}" is not in allowed_contexts (${allowedContexts.join(',')}).`);
53+
return false;
4254
}
4355

4456
return true;
@@ -48,19 +60,27 @@ export const run = async () => {
4860
const inputs = getInputs();
4961
core.debug(`inputs: ${JSON.stringify(inputs)}`);
5062

51-
const isPr = context?.payload?.issue?.['pull_request'] != null;
63+
const ctx = resolveCommentContext();
64+
if (ctx === null) {
65+
core.warning(
66+
`This action only supports the "issue_comment" or "discussion_comment" event, but received "${context.eventName}".`,
67+
);
68+
core.setOutput('continue', 'false');
69+
return 0;
70+
}
5271

53-
if (!isValidContext(inputs, isPr)) {
72+
if (!isValidContext(inputs, ctx.kind)) {
5473
core.setOutput('continue', 'false');
5574
return 0;
5675
}
5776

58-
const issueNumber = context.payload.issue!.number!;
59-
core.setOutput('issue_number', issueNumber);
60-
core.setOutput('number', issueNumber);
61-
core.setOutput('context', isPr ? 'pull_request' : 'issue');
62-
core.setOutput('comment_id', context.payload.comment!.id);
63-
core.setOutput('actor', context.payload.comment!['user'].login);
77+
core.setOutput('number', ctx.number);
78+
core.setOutput('context', ctx.kind);
79+
core.setOutput('comment_id', ctx.commentId);
80+
core.setOutput('actor', ctx.actor);
81+
if (ctx.kind !== 'discussion') {
82+
core.setOutput('issue_number', ctx.number);
83+
}
6484

6585
const commands = str2array(inputs.command);
6686
const body = (context.payload.comment?.['body'] ?? '') as string;

0 commit comments

Comments
 (0)