Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ jobs:

# Only run if `steps.<id>.outputs.continue` is "true".
# This indicates that `knowledge-work` successfully parsed the command and the subsequent steps should continue.
# IssueOps params are passed through `env:` and referenced as shell variables to avoid
# GitHub Actions expression injection from attacker-controlled comment content.
- name: Greet
if: ${{ steps.command.outputs.continue == 'true' }}
run: echo "Hi ${{ fromJSON(steps.command.outputs.params).name }} !"
env:
NAME: ${{ fromJSON(steps.command.outputs.params).name }}
run: echo "Hi $NAME !"

# Add other steps necessary for IssueOps...
```
Expand Down Expand Up @@ -164,10 +168,10 @@ Supports null in JSON format.

<!-- gha-inputs-start -->

| ID | Required | Default | Description |
| :----------------- | :----------------- | :------------------- | :------------------------------------------------------------------------------------------------ |
| `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. |
| `allowed_contexts` | | `issue,pull_request` | The comment contexts that trigger the IssueOps command, specified as a comma-separated list. |
| ID | Required | Default | Description |
| :----------------- | :----------------- | :------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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"`. |

<!-- gha-inputs-end -->

Expand All @@ -182,8 +186,8 @@ Supports null in JSON format.
| `comment_id` | The ID of the comment that triggered this action. |
| `actor` | The GitHub handle of the actor who executed the IssueOps command. |
| `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. |
| `number` | The number of the issue or pull request that triggered this action. |
| `context` | The context that triggered this action. One of `"issue"` or `"pull_request"`. |
| `number` | The number of the issue, pull request, or discussion that triggered this action. |
| `context` | The context that triggered this action. One of `"issue"`, `"pull_request"`, or `"discussion"`. |
| `command` | The command of the triggered IssueOps command. |

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

A section introducing tips for implementing IssueOps commands.

### Triggering from GitHub Discussions

`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).

```yaml
name: 'Greet DEMO (Discussions)'

on:
discussion_comment:
types: [created]

permissions:
contents: read
discussions: write # only required if your follow-up step writes back to the discussion (e.g. reactions via GraphQL)

jobs:
demo:
runs-on: ubuntu-latest
steps:
- id: command
uses: knowledge-work/command-action@v1
with:
command: 'greet'
- if: ${{ steps.command.outputs.continue == 'true' }}
env:
NAME: ${{ fromJSON(steps.command.outputs.params).name }}
run: echo "Hi $NAME !"
```

> [!warning]
> 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.

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'`.

### Reacting to the comment

You can use [actions/github-script](https://github.com/actions/github-script) to add a reaction to the comment that triggered the IssueOps command.
Expand Down
8 changes: 4 additions & 4 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ inputs:
description: 'The name of the command to be used in IssueOps, which can be specified as a comma-separated list.'
required: true
allowed_contexts:
description: 'The comment contexts that trigger the IssueOps command, specified as a comma-separated list.'
default: 'issue,pull_request'
description: 'The comment contexts that trigger the IssueOps command, specified as a comma-separated list. Allowed values: `"issue"`, `"pull_request"`, `"discussion"`.'
default: 'issue,pull_request,discussion'

outputs:
continue:
Expand All @@ -25,9 +25,9 @@ outputs:
issue_number:
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.'
number:
description: 'The number of the issue or pull request that triggered this action.'
description: 'The number of the issue, pull request, or discussion that triggered this action.'
context:
description: 'The context that triggered this action. One of `"issue"` or `"pull_request"`.'
description: 'The context that triggered this action. One of `"issue"`, `"pull_request"`, or `"discussion"`.'
command:
description: 'The command of the triggered IssueOps command.'

Expand Down
71 changes: 40 additions & 31 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38080,53 +38080,62 @@ const str2array = (input) => input



const validContexts = new Set(['issue', 'pull_request']);
const isValidContext = (inputs, isPr) => {
if (github_context.eventName !== 'issue_comment') {
core/* warning */.$e(`This action only supports the "issue_comment" event, but received "${github_context.eventName}".`);
return false;
const validContexts = new Set(['issue', 'pull_request', 'discussion']);
const resolveCommentContext = () => {
if (github_context.eventName === 'issue_comment') {
const isPr = github_context.payload.issue?.['pull_request'] != null;
return {
kind: isPr ? 'pull_request' : 'issue',
number: github_context.payload.issue.number,
commentId: github_context.payload.comment.id,
actor: github_context.payload.comment['user'].login,
};
}
if (github_context.eventName === 'discussion_comment') {
const discussion = github_context.payload['discussion'];
return {
kind: 'discussion',
number: discussion.number,
commentId: github_context.payload.comment.id,
actor: github_context.payload.comment['user'].login,
};
}
return null;
};
const isValidContext = (inputs, kind) => {
const allowedContexts = str2array(inputs.allowed_contexts);
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c));
if (invalidContexts.length > 0) {
const list = [...validContexts].map((c) => `"${c}"`).join(' and ');
const list = [...validContexts].map((c) => `"${c}"`).join(', ');
core/* warning */.$e(`The "allowed_contexts" must be a comma-separated string of ${list}, but received "${invalidContexts.join(',')}".`);
return false;
}
if (allowedContexts.length === 1) {
switch (allowedContexts[0]) {
case 'issue': {
if (isPr) {
core/* info */.pq(`💡The 'issue' context is not allowed for pull requests.`);
return false;
}
break;
}
case 'pull_request': {
if (!isPr) {
core/* info */.pq(`💡The 'pull_request' context is not allowed for issues.`);
return false;
}
break;
}
}
if (!allowedContexts.includes(kind)) {
core/* info */.pq(`💡The current context "${kind}" is not in allowed_contexts (${allowedContexts.join(',')}).`);
return false;
}
return true;
};
const run = async () => {
const inputs = getInputs();
core/* debug */.Yz(`inputs: ${JSON.stringify(inputs)}`);
const isPr = github_context?.payload?.issue?.['pull_request'] != null;
if (!isValidContext(inputs, isPr)) {
const ctx = resolveCommentContext();
if (ctx === null) {
core/* warning */.$e(`This action only supports the "issue_comment" or "discussion_comment" event, but received "${github_context.eventName}".`);
core/* setOutput */.uH('continue', 'false');
return 0;
}
const issueNumber = github_context.payload.issue.number;
core/* setOutput */.uH('issue_number', issueNumber);
core/* setOutput */.uH('number', issueNumber);
core/* setOutput */.uH('context', isPr ? 'pull_request' : 'issue');
core/* setOutput */.uH('comment_id', github_context.payload.comment.id);
core/* setOutput */.uH('actor', github_context.payload.comment['user'].login);
if (!isValidContext(inputs, ctx.kind)) {
core/* setOutput */.uH('continue', 'false');
return 0;
}
core/* setOutput */.uH('number', ctx.number);
core/* setOutput */.uH('context', ctx.kind);
core/* setOutput */.uH('comment_id', ctx.commentId);
core/* setOutput */.uH('actor', ctx.actor);
if (ctx.kind !== 'discussion') {
core/* setOutput */.uH('issue_number', ctx.number);
}
const commands = str2array(inputs.command);
const body = (github_context.payload.comment?.['body'] ?? '');
const result = parse_parse(body);
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,78 @@ test('invalid context emits only continue=false and never emits number / context
expect(outputFor('number')).toBeUndefined();
expect(outputFor('context')).toBeUndefined();
expect(outputFor('issue_number')).toBeUndefined();
expect(mocks.warning).toHaveBeenCalled();
const warningMessage = mocks.warning.mock.calls[0]?.[0] as string;
expect(warningMessage).toContain('issue_comment');
expect(warningMessage).toContain('discussion_comment');
});

test('discussion context emits number and context="discussion" without issue_number', async () => {
mocks.inputs['allowed_contexts'] = 'issue,pull_request,discussion';
mocks.context.eventName = 'discussion_comment';
mocks.context.payload = {
discussion: { number: 7 },
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
};

await run();

expect(outputFor('number')).toBe(7);
expect(outputFor('context')).toBe('discussion');
expect(outputFor('comment_id')).toBe(4004);
expect(outputFor('actor')).toBe('carol');
expect(outputFor('issue_number')).toBeUndefined();
expect(outputFor('continue')).toBe('true');
});

test('allowed_contexts="issue" rejects a discussion_comment via the filter', async () => {
mocks.inputs['allowed_contexts'] = 'issue';
mocks.context.eventName = 'discussion_comment';
mocks.context.payload = {
discussion: { number: 7 },
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
};

await run();

expect(outputFor('continue')).toBe('false');
expect(outputFor('number')).toBeUndefined();
expect(outputFor('context')).toBeUndefined();
expect(outputFor('issue_number')).toBeUndefined();
expect(mocks.info).toHaveBeenCalled();
});

test('allowed_contexts="discussion" accepts a discussion_comment (single-context positive)', async () => {
mocks.inputs['allowed_contexts'] = 'discussion';
mocks.context.eventName = 'discussion_comment';
mocks.context.payload = {
discussion: { number: 7 },
comment: { id: 4004, body: '.foo', user: { login: 'carol' } },
};

await run();

expect(outputFor('number')).toBe(7);
expect(outputFor('context')).toBe('discussion');
expect(outputFor('issue_number')).toBeUndefined();
expect(outputFor('continue')).toBe('true');
const rejectionInfoCalls = mocks.info.mock.calls.filter(([msg]) =>
typeof msg === 'string' ? msg.includes('not in allowed_contexts') : false,
);
expect(rejectionInfoCalls).toHaveLength(0);
});

test('allowed_contexts="discussion" rejects an issue_comment (heterogeneous single-context)', async () => {
mocks.inputs['allowed_contexts'] = 'discussion';
mocks.context.eventName = 'issue_comment';
mocks.context.payload = {
issue: { number: 42 },
comment: { id: 1001, body: '.foo', user: { login: 'alice' } },
};

await run();

expect(outputFor('continue')).toBe('false');
expect(outputFor('context')).toBeUndefined();
expect(outputFor('number')).toBeUndefined();
});
84 changes: 52 additions & 32 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,53 @@ import { type Inputs, getInputs } from './inputs.js';
import { parse } from './parse.js';
import { str2array } from './utils.js';

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

const isValidContext = (inputs: Inputs, isPr: boolean) => {
if (context.eventName !== 'issue_comment') {
core.warning(`This action only supports the "issue_comment" event, but received "${context.eventName}".`);
return false;
const validContexts = new Set<CommentKind>(['issue', 'pull_request', 'discussion']);

type CommentContext = {
kind: CommentKind;
number: number;
commentId: number;
actor: string;
};

const resolveCommentContext = (): CommentContext | null => {
if (context.eventName === 'issue_comment') {
const isPr = context.payload.issue?.['pull_request'] != null;
return {
kind: isPr ? 'pull_request' : 'issue',
number: context.payload.issue!.number!,
commentId: context.payload.comment!.id,
actor: context.payload.comment!['user'].login,
};
}
if (context.eventName === 'discussion_comment') {
const discussion = context.payload['discussion'] as { number: number } | undefined;
return {
kind: 'discussion',
number: discussion!.number,
commentId: context.payload.comment!.id,
actor: context.payload.comment!['user'].login,
};
}
return null;
};

const isValidContext = (inputs: Inputs, kind: CommentKind) => {
const allowedContexts = str2array(inputs.allowed_contexts);
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c));
const invalidContexts = allowedContexts.filter((c) => !validContexts.has(c as CommentKind));
if (invalidContexts.length > 0) {
const list = [...validContexts].map((c) => `"${c}"`).join(' and ');
const list = [...validContexts].map((c) => `"${c}"`).join(', ');
core.warning(
`The "allowed_contexts" must be a comma-separated string of ${list}, but received "${invalidContexts.join(',')}".`,
);
return false;
}

if (allowedContexts.length === 1) {
switch (allowedContexts[0]) {
case 'issue': {
if (isPr) {
core.info(`💡The 'issue' context is not allowed for pull requests.`);
return false;
}
break;
}
case 'pull_request': {
if (!isPr) {
core.info(`💡The 'pull_request' context is not allowed for issues.`);
return false;
}
break;
}
}
if (!allowedContexts.includes(kind)) {
core.info(`💡The current context "${kind}" is not in allowed_contexts (${allowedContexts.join(',')}).`);
return false;
}

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

const isPr = context?.payload?.issue?.['pull_request'] != null;
const ctx = resolveCommentContext();
if (ctx === null) {
core.warning(
`This action only supports the "issue_comment" or "discussion_comment" event, but received "${context.eventName}".`,
);
core.setOutput('continue', 'false');
return 0;
}

if (!isValidContext(inputs, isPr)) {
if (!isValidContext(inputs, ctx.kind)) {
core.setOutput('continue', 'false');
return 0;
}

const issueNumber = context.payload.issue!.number!;
core.setOutput('issue_number', issueNumber);
core.setOutput('number', issueNumber);
core.setOutput('context', isPr ? 'pull_request' : 'issue');
core.setOutput('comment_id', context.payload.comment!.id);
core.setOutput('actor', context.payload.comment!['user'].login);
core.setOutput('number', ctx.number);
core.setOutput('context', ctx.kind);
core.setOutput('comment_id', ctx.commentId);
core.setOutput('actor', ctx.actor);
if (ctx.kind !== 'discussion') {
core.setOutput('issue_number', ctx.number);
}

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