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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ This file provides guidance to AI coding agents (Claude Code, Codex CLI, etc.) w

`IssueOps Command Action` — a JavaScript GitHub Action that parses `.command key=value, ...` comments on issues / PRs and emits structured outputs (`continue`, `params`, `command`, `actor`, `comment_id`, `issue_number`). See `action.yaml` for the full I/O contract.

- Entrypoint: `src/main.ts` → bundled to `dist/index.js` via `@vercel/ncc`
- Entrypoint: `src/index.ts` → bundled to `dist/index.js` via `@vercel/ncc`. `src/index.ts` is the thin runner that calls `run` from `src/main.ts`; keep `src/main.ts` side-effect-free for testability.
- Runtime: Node 24 (`runs.using: node24` in `action.yaml`)
- Core parser: `src/parse.ts`, built on `parjs` combinators

## Source layout

- `src/main.ts` — action entrypoint
- `src/index.ts` — thin runner: invokes `run()` from `src/main.ts` and bridges to `process.exit`
- `src/main.ts` — action logic (exports `run`); kept side-effect-free for testability
- `src/parse.ts` — IssueOps command parser
- `src/utils.ts` — small helpers
- `src/*.test.ts` — vitest tests, colocated with source
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ Supports null in JSON format.
| `params` | The parameters of the triggered IssueOps command, provided as a JSON string. |
| `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` | The issue number of the comment that triggered this action. |
| `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"`. |
| `command` | The command of the triggered IssueOps command. |

<!-- gha-outputs-end -->
Expand Down
6 changes: 5 additions & 1 deletion action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ outputs:
actor:
description: 'The GitHub handle of the actor who executed the IssueOps command.'
issue_number:
description: 'The issue number of the comment that triggered this action.'
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.'
context:
description: 'The context that triggered this action. One of `"issue"` or `"pull_request"`.'
command:
description: 'The command of the triggered IssueOps command.'

Expand Down
9,863 changes: 4,919 additions & 4,944 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "ncc build src/main.ts --source-map --license licenses.txt",
"build": "ncc build src/index.ts --source-map --license licenses.txt",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"generate": "pnpm generate:docs && pnpm generate:inputs && pnpm generate:unicode-regex && pnpm format",
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as core from '@actions/core';
import { run } from './main.js';

try {
process.exit(await run());
} catch (e) {
core.setFailed(e instanceof Error ? e : new Error(String(e)));
process.exit(1);
}
95 changes: 95 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, expect, test, vi } from 'vitest';
import type { Inputs } from './inputs.js';

const mocks = vi.hoisted(() => ({
setOutput: vi.fn(),
setFailed: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
inputs: { command: 'foo', allowed_contexts: 'issue,pull_request' } satisfies Inputs,
context: {
eventName: 'issue_comment' as string,
payload: {} as Record<string, unknown>,
},
}));

vi.mock('@actions/core', () => ({
setOutput: mocks.setOutput,
setFailed: mocks.setFailed,
warning: mocks.warning,
info: mocks.info,
debug: mocks.debug,
getInput: (key: string) => mocks.inputs[key as keyof Inputs] ?? '',
}));

vi.mock('@actions/github', () => ({
context: mocks.context,
}));

const { run } = await import('./main.js');

const outputFor = (key: string) => mocks.setOutput.mock.calls.find(([k]) => k === key)?.[1];

beforeEach(() => {
mocks.setOutput.mockReset();
mocks.setFailed.mockReset();
mocks.warning.mockReset();
mocks.info.mockReset();
mocks.debug.mockReset();
mocks.inputs['command'] = 'foo';
mocks.inputs['allowed_contexts'] = 'issue,pull_request';
mocks.context.eventName = 'issue_comment';
mocks.context.payload = {
issue: { number: 0 },
comment: { id: 0, body: '.foo', user: { login: 'alice' } },
};
});

test('issue context emits number and context="issue" alongside issue_number', async () => {
mocks.context.payload = {
issue: { number: 42 },
comment: { id: 1001, body: '.foo', user: { login: 'alice' } },
};

await run();

expect(outputFor('issue_number')).toBe(42);
expect(outputFor('number')).toBe(42);
expect(outputFor('context')).toBe('issue');
expect(outputFor('number')).toBe(outputFor('issue_number'));
expect(outputFor('continue')).toBe('true');
});

test('pull_request context emits number and context="pull_request" alongside issue_number', async () => {
mocks.context.payload = {
issue: {
number: 99,
pull_request: { url: 'https://api.github.com/repos/o/r/pulls/99' },
},
comment: { id: 2002, body: '.foo', user: { login: 'bob' } },
};

await run();

expect(outputFor('issue_number')).toBe(99);
expect(outputFor('number')).toBe(99);
expect(outputFor('context')).toBe('pull_request');
expect(outputFor('number')).toBe(outputFor('issue_number'));
expect(outputFor('continue')).toBe('true');
});

test('invalid context emits only continue=false and never emits number / context / issue_number', async () => {
mocks.context.eventName = 'push';
mocks.context.payload = {
issue: { number: 7 },
comment: { id: 3003, body: '.foo', user: { login: 'eve' } },
};

await run();

expect(outputFor('continue')).toBe('false');
expect(outputFor('number')).toBeUndefined();
expect(outputFor('context')).toBeUndefined();
expect(outputFor('issue_number')).toBeUndefined();
});
21 changes: 9 additions & 12 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { str2array } from './utils.js';

const validContexts = new Set(['issue', 'pull_request']);

const isValidContext = (inputs: Inputs) => {
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;
Expand All @@ -22,7 +22,6 @@ const isValidContext = (inputs: Inputs) => {
return false;
}

const isPr = context?.payload?.issue?.['pull_request'] != null;
if (allowedContexts.length === 1) {
switch (allowedContexts[0]) {
case 'issue': {
Expand All @@ -45,16 +44,21 @@ const isValidContext = (inputs: Inputs) => {
return true;
};

const run = async () => {
export const run = async () => {
const inputs = getInputs();
core.debug(`inputs: ${JSON.stringify(inputs)}`);

if (!isValidContext(inputs)) {
const isPr = context?.payload?.issue?.['pull_request'] != null;

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

core.setOutput('issue_number', context.payload.issue!.number!);
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);

Expand Down Expand Up @@ -92,10 +96,3 @@ const run = async () => {

return 0;
};

try {
process.exit(await run());
} catch (e) {
core.setFailed(e as Error);
process.exit(1);
}