38 Commits

Author SHA1 Message Date
Sean Goedecke
02c6cc30ae Merge pull request #150 from actions/sgoedecke/mock-inference-in-ci
Mock inference in CI
2025-11-28 08:17:33 +11:00
Sean Goedecke
18d468666d fix: keep response-file temp file for downstream steps
The temporary file created for response-file was being cleaned up
before downstream steps could access it. Now using keep: true to
ensure the file persists until the job completes.

Also added script/ to eslint ignores for the mock server.
2025-11-27 21:06:42 +00:00
Sean Goedecke
fd73d0264c Mock inference in CI 2025-11-27 20:59:41 +00:00
Sean Goedecke
5022b33bc1 Merge pull request #148 from dsanders11/feat/prompt-yaml-model-parameters
feat: support modelParameters in prompt.yaml files
2025-11-24 11:27:47 +11:00
David Sanders
c9e14713bc chore: update dist 2025-11-23 16:19:48 -08:00
David Sanders
39308142df chore: apply code review comment
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 16:19:12 -08:00
David Sanders
48f0edec4d feat: support modelParameters in prompt.yaml files 2025-11-23 16:07:11 -08:00
Sean Goedecke
36ea1371dc Merge pull request #136 from dsanders11/fix/template-substition
fix: do template substition after parsing prompt YAML
2025-11-24 10:22:42 +11:00
Sean Goedecke
de16a30c20 Merge branch 'main' into fix/template-substition 2025-11-24 10:21:49 +11:00
Sean Goedecke
dd3dff10ba Merge pull request #147 from srt32/patch-1
Clarify PAT requirement for github-mcp-token
2025-11-24 10:18:50 +11:00
Simon Taranto
4bb01ee5ee Clarify PAT requirement for github-mcp-token
I mistakenly read the description of the mcp-token field to mean I needed a "PAT for MCP" as if there were a PAT permission for MCP. This change clarifies the language.
2025-11-21 13:36:45 -05:00
David Sanders
af1c1c29a3 fix: do template substition after parsing prompt YAML 2025-10-20 21:32:06 -07:00
Sean Goedecke
83bb5ca3e8 Merge pull request #93 from FidelusAleksander/main
docs: update documentation on mcp usage
2025-08-26 18:13:39 +10:00
Aleksander Fidelus
4d2337d006 Merge branch 'actions:main' into main 2025-08-25 11:08:41 +02:00
Yuzuki
7ba7530ad4 Merge pull request #94 from actions/dependabot/github_actions/actions/checkout-5
chore(deps): bump actions/checkout from 4 to 5
2025-08-25 14:00:39 +10:00
Yuzuki
4d7d83c494 Merge branch 'main' into dependabot/github_actions/actions/checkout-5 2025-08-25 13:55:57 +10:00
Sean Goedecke
a1c1182922 Merge pull request #97 from actions/sgoedecke/defensive-parsing
Parse inference response format defensively
2025-08-25 08:47:18 +10:00
Sean Goedecke
dfaa426c29 Parse inference response format defensively 2025-08-22 22:34:18 +00:00
FidelusAleksander
7fa0024f13 docs: run prettier 2025-08-18 14:42:29 +02:00
dependabot[bot]
fc6f9a0800 chore(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 03:58:02 +00:00
FidelusAleksander
a1d07305b7 docs: update github-mcp-token description 2025-08-15 08:22:55 +02:00
FidelusAleksander
6e0d8949d8 docs: update documentation on mcp usage 2025-08-15 07:52:22 +02:00
Sean Goedecke
f347eae8eb Merge pull request #91 from JessRudder/secure-tmp-files
Uses tmp library to ensure more secure tmp file creation
2025-08-14 07:15:18 +10:00
Jess Rudder
07fe2f30ad Merge branch 'main' into secure-tmp-files 2025-08-13 14:11:23 -07:00
Jess Rudder
1843310df4 Add license info 2025-08-13 21:07:21 +00:00
Sean Goedecke
c72cb2ef9c Merge pull request #90 from garman/pin-to-sha
Pin two imported actions to a set sha
2025-08-14 06:58:03 +10:00
Jessica Rudder
a2fd223fcf Properly clean up tmp files 2025-08-12 14:31:05 -07:00
Jessica Rudder
3ba8e1b39d Replace manual tmp file creation with tmp library which uses security best practices 2025-08-12 13:49:47 -07:00
Daniel Garman
52e5222a82 pin to a sha 2025-08-12 15:04:16 -04:00
Sean Goedecke
a62dfeda7b Merge pull request #79 from salmanmkc/node24
Node 24
2025-08-11 21:13:39 +10:00
Salman Chishti
48235f7026 Merge branch 'main' into node24 2025-08-11 11:52:36 +01:00
Sean Goedecke
b81b2afb83 Merge pull request #88 from actions/sgoedecke/force-exit-once-inference-finishes
Force exit once inference finishes
2025-08-06 11:01:14 +10:00
Sean Goedecke
9133f81330 package 2025-08-06 00:54:19 +00:00
Sean Goedecke
7923b92ef8 Merge pull request #89 from actions/sgoedecke/ensure-mcp-loops-output-desired-response-format
Ensure MCP loops output the right response format
2025-08-06 10:41:02 +10:00
Sean Goedecke
e44da102bf fixup format parsing 2025-08-05 22:21:28 +00:00
Sean Goedecke
866ae2b5d7 Ensure MCP loops output the right response format
In a tool loop, you can't set response_format because the model needs to
be able to think in plain English. But you still need the final response
to be in the desired format, so we add response_format only on the last
iteration.
2025-08-05 22:06:49 +00:00
Sean Goedecke
4685e0dcd4 Force exit once inference finishes in case we are holding any connections open 2025-08-05 21:42:07 +00:00
Salman Muin Kayser Chishti
9bbcef8fa4 node 24 2025-08-01 12:13:15 +01:00
24 changed files with 1634 additions and 153 deletions

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node
@@ -54,22 +54,53 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Start Mock Inference Server
id: mock-server
run: |
node script/mock-inference-server.mjs &
echo "pid=$!" >> $GITHUB_OUTPUT
# Wait for server to be ready
for i in {1..10}; do
if curl -s http://localhost:3456/health > /dev/null; then
echo "Mock server is ready"
break
fi
sleep 1
done
- name: Test Local Action
id: test-action
continue-on-error: true
uses: ./
with:
prompt: hello
endpoint: http://localhost:3456
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Print Output
id: output
continue-on-error: true
run: echo "${{ steps.test-action.outputs.response }}"
- name: Verify Output
run: |
response="${{ steps.test-action.outputs.response }}"
if [[ -z "$response" ]]; then
echo "Error: No response received"
exit 1
fi
echo "Response received: $response"
- name: Stop Mock Server
if: always()
run: kill ${{ steps.mock-server.outputs.pid }} || true
test-action-prompt-file:
name: GitHub Actions Test with Prompt File
runs-on: ubuntu-latest
@@ -77,7 +108,26 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Start Mock Inference Server
id: mock-server
run: |
node script/mock-inference-server.mjs &
echo "pid=$!" >> $GITHUB_OUTPUT
# Wait for server to be ready
for i in {1..10}; do
if curl -s http://localhost:3456/health > /dev/null; then
echo "Mock server is ready"
break
fi
sleep 1
done
- name: Create Prompt File
run: echo "hello" > prompt.txt
@@ -87,16 +137,33 @@ jobs:
- name: Test Local Action with Prompt File
id: test-action-prompt-file
continue-on-error: true
uses: ./
with:
prompt-file: prompt.txt
system-prompt-file: system-prompt.txt
endpoint: http://localhost:3456
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Print Output
continue-on-error: true
run: |
echo "Response saved to: ${{ steps.test-action-prompt-file.outputs.response-file }}"
cat "${{ steps.test-action-prompt-file.outputs.response-file }}"
- name: Verify Output
run: |
response_file="${{ steps.test-action-prompt-file.outputs.response-file }}"
if [[ ! -f "$response_file" ]]; then
echo "Error: Response file not found"
exit 1
fi
content=$(cat "$response_file")
if [[ -z "$content" ]]; then
echo "Error: Response file is empty"
exit 1
fi
echo "Response file content: $content"
- name: Stop Mock Server
if: always()
run: kill ${{ steps.mock-server.outputs.pid }} || true

View File

@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Initialize CodeQL
id: initialize

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node
@@ -42,11 +42,11 @@ jobs:
- name: Setup Ruby
id: setup-ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4
with:
ruby-version: ruby
- uses: licensee/setup-licensed@v1.3.2
- uses: licensee/setup-licensed@0d52e575b3258417672be0dff2f115d7db8771d8
with:
version: 4.x
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -0,0 +1,32 @@
---
name: "@types/tmp"
version: 0.2.6
type: npm
summary: TypeScript definitions for tmp
homepage: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/tmp
license: mit
licenses:
- sources: LICENSE
text: |2
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
notices: []

32
.licenses/npm/tmp.dep.yml Normal file
View File

@@ -0,0 +1,32 @@
---
name: tmp
version: 0.2.5
type: npm
summary: Temporary file and directory creator
homepage: http://github.com/raszi/node-tmp
license: mit
licenses:
- sources: LICENSE
text: |
The MIT License (MIT)
Copyright (c) 2014 KARASZI István
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
notices: []

View File

@@ -1 +1 @@
20.9.0
24.4.0

View File

@@ -162,6 +162,9 @@ This action now supports **read-only** integration with the GitHub-hosted Model
Context Protocol (MCP) server, which provides access to GitHub tools like
repository management, issue tracking, and pull request operations.
> [!NOTE]
> The GitHub MCP integration requires a Personal Access Token (PAT) and cannot use the built-in `GITHUB_TOKEN`.
```yaml
steps:
- name: AI Inference with GitHub Tools
@@ -209,7 +212,7 @@ the action:
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
| `max-tokens` | The max number of tokens to generate | 200 |
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). Use a separate PAT for tighter security | `""` |
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). This must be a PAT in order for MCP to work | `""` |
## Outputs

View File

@@ -106,6 +106,8 @@ describe('helpers.ts - inference request building', () => {
undefined,
undefined,
'gpt-4',
undefined,
undefined,
100,
'https://api.test.com',
'test-token',
@@ -117,6 +119,8 @@ describe('helpers.ts - inference request building', () => {
{role: 'user', content: 'User message'},
],
modelName: 'gpt-4',
temperature: undefined,
topP: undefined,
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',
@@ -136,6 +140,8 @@ describe('helpers.ts - inference request building', () => {
'System prompt',
'User prompt',
'gpt-4',
undefined,
undefined,
100,
'https://api.test.com',
'test-token',
@@ -147,6 +153,8 @@ describe('helpers.ts - inference request building', () => {
{role: 'user', content: 'User prompt'},
],
modelName: 'gpt-4',
temperature: undefined,
topP: undefined,
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',

View File

@@ -95,6 +95,49 @@ describe('inference.ts', () => {
expect(result).toBeNull()
expect(core.info).toHaveBeenCalledWith('Model response: No response content')
})
it('includes response format when specified', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
const mockResponse = {
choices: [
{
message: {
content: '{"result": "success"}',
},
},
],
}
mockCreate.mockResolvedValue(mockResponse)
const result = await simpleInference(requestWithResponseFormat)
expect(result).toBe('{"result": "success"}')
// Verify response format was included in the request
expect(mockCreate).toHaveBeenCalledWith({
messages: [
{
role: 'system',
content: 'You are a test assistant',
},
{
role: 'user',
content: 'Hello, AI!',
},
],
max_tokens: 100,
model: 'gpt-4',
response_format: requestWithResponseFormat.responseFormat,
})
})
})
describe('mcpInference', () => {
@@ -140,6 +183,7 @@ describe('inference.ts', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs = mockCreate.mock.calls[0][0] as any
expect(callArgs.tools).toEqual(mockMcpClient.tools)
expect(callArgs.response_format).toBeUndefined()
expect(callArgs.model).toBe('gpt-4')
expect(callArgs.max_tokens).toBe(100)
})
@@ -315,5 +359,191 @@ describe('inference.ts', () => {
expect(result).toBe('Second message')
})
it('makes additional loop with response format when no tool calls are made', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
// First response without tool calls
const firstResponse = {
choices: [
{
message: {
content: 'First response',
tool_calls: null,
},
},
],
}
// Second response with response format applied
const secondResponse = {
choices: [
{
message: {
content: '{"result": "formatted response"}',
tool_calls: null,
},
},
],
}
mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse)
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
expect(result).toBe('{"result": "formatted response"}')
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(core.info).toHaveBeenCalledWith('Making one more MCP loop with the requested response format...')
// First call should have tools but no response format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const firstCall = mockCreate.mock.calls[0][0] as any
expect(firstCall.tools).toEqual(mockMcpClient.tools)
expect(firstCall.response_format).toBeUndefined()
// Second call should have response format but no tools
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockCreate.mock.calls[1][0] as any
expect(secondCall.tools).toBeUndefined()
expect(secondCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
// Second call should include the user message requesting JSON format
expect(secondCall.messages).toHaveLength(5) // system, user, assistant, user, assistant
expect(secondCall.messages[3].role).toBe('user')
expect(secondCall.messages[3].content).toContain('Please provide your response in the exact')
})
it('uses response format only on final iteration after tool calls', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
const toolCalls = [
{
id: 'call-123',
function: {
name: 'test-tool',
arguments: '{"param": "value"}',
},
},
]
const toolResults = [
{
tool_call_id: 'call-123',
role: 'tool',
name: 'test-tool',
content: 'Tool result',
},
]
// First response with tool calls
const firstResponse = {
choices: [
{
message: {
content: 'Using tool',
tool_calls: toolCalls,
},
},
],
}
// Second response without tool calls, but should trigger final message loop
const secondResponse = {
choices: [
{
message: {
content: 'Intermediate result',
tool_calls: null,
},
},
],
}
// Third response with response format
const thirdResponse = {
choices: [
{
message: {
content: '{"final": "result"}',
tool_calls: null,
},
},
],
}
mockCreate
.mockResolvedValueOnce(firstResponse)
.mockResolvedValueOnce(secondResponse)
.mockResolvedValueOnce(thirdResponse)
mockExecuteToolCalls.mockResolvedValue(toolResults)
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
expect(result).toBe('{"final": "result"}')
expect(mockCreate).toHaveBeenCalledTimes(3)
// First call: tools but no response format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const firstCall = mockCreate.mock.calls[0][0] as any
expect(firstCall.tools).toEqual(mockMcpClient.tools)
expect(firstCall.response_format).toBeUndefined()
// Second call: tools but no response format (after tool execution)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockCreate.mock.calls[1][0] as any
expect(secondCall.tools).toEqual(mockMcpClient.tools)
expect(secondCall.response_format).toBeUndefined()
// Third call: response format but no tools (final message)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const thirdCall = mockCreate.mock.calls[2][0] as any
expect(thirdCall.tools).toBeUndefined()
expect(thirdCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
})
it('returns immediately when response format is set and finalMessage is already true', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
// Response without tool calls on what would be the final message iteration
const mockResponse = {
choices: [
{
message: {
content: '{"immediate": "result"}',
tool_calls: null,
},
},
],
}
mockCreate.mockResolvedValue(mockResponse)
// We need to test a scenario where finalMessage would already be true
// This happens when we're already in the final iteration
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
// The function should make two calls: one normal, then one with response format
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(result).toBe('{"immediate": "result"}')
})
})
})

View File

@@ -34,6 +34,12 @@ vi.mock('../src/mcp.js', () => ({
vi.mock('@actions/core', () => core)
// Mock process.exit to prevent it from actually exiting during tests
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
// Prevent actual exit, but don't throw - just return
return undefined as never
})
// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
const {run} = await import('../src/main.js')
@@ -41,6 +47,7 @@ const {run} = await import('../src/main.js')
describe('main.ts - prompt.yml integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProcessExit.mockClear()
// Mock environment variables
process.env['GITHUB_TOKEN'] = 'test-token'
@@ -103,8 +110,12 @@ model: openai/gpt-4o
}
})
// Expect the run function to complete successfully
await run()
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
// Verify simpleInference was called with the correct message structure
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
@@ -171,6 +182,9 @@ model: openai/gpt-4o
messages: [{role: 'user', content: 'Here is the data: FILE_CONTENTS'}],
}),
)
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('should fall back to legacy format when not using prompt YAML', async () => {
@@ -215,5 +229,8 @@ model: openai/gpt-4o
token: 'test-token',
}),
)
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
})

View File

@@ -66,7 +66,7 @@ function mockInputs(inputs: Record<string, string> = {}): void {
*/
function verifyStandardResponse(): void {
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'response', 'Hello, user!')
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', expect.stringContaining('modelResponse.txt'))
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', expect.stringContaining('modelResponse-'))
}
vi.mock('fs', () => ({
@@ -75,6 +75,15 @@ vi.mock('fs', () => ({
writeFileSync: mockWriteFileSync,
}))
// Mocks for tmp module to control temporary file creation
const mockFileSync = vi.fn().mockReturnValue({
name: '/secure/temp/dir/modelResponse-abc123.txt',
})
vi.mock('tmp', () => ({
fileSync: mockFileSync,
}))
// Mock MCP and inference modules
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockConnectToGitHubMCP = vi.fn() as MockedFunction<any>
@@ -96,7 +105,8 @@ vi.mock('@actions/core', () => core)
// Mock process.exit to prevent it from actually exiting during tests
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called')
// Prevent actual exit, but don't throw - just return
return undefined as never
})
// The module being tested should be imported dynamically. This ensures that the
@@ -127,6 +137,7 @@ describe('main.ts', () => {
expect(core.setOutput).toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('Sets a failed status when no prompt is set', async () => {
@@ -135,8 +146,7 @@ describe('main.ts', () => {
'prompt-file': '',
})
// Expect the run function to throw due to process.exit being mocked
await expect(run()).rejects.toThrow('process.exit called')
await run()
expect(core.setFailed).toHaveBeenCalledWith('Neither prompt-file nor prompt was set')
expect(mockProcessExit).toHaveBeenCalledWith(1)
@@ -165,6 +175,7 @@ describe('main.ts', () => {
expect(mockConnectToGitHubMCP).not.toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('uses MCP inference when enabled and connection succeeds', async () => {
@@ -197,6 +208,7 @@ describe('main.ts', () => {
)
expect(mockSimpleInference).not.toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('falls back to simple inference when MCP connection fails', async () => {
@@ -215,6 +227,7 @@ describe('main.ts', () => {
expect(mockMcpInference).not.toHaveBeenCalled()
expect(core.warning).toHaveBeenCalledWith('MCP connection failed, falling back to simple inference')
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('properly integrates with loadContentFromFileOrInput', async () => {
@@ -248,6 +261,7 @@ describe('main.ts', () => {
responseFormat: undefined,
})
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('handles non-existent prompt-file with an error', async () => {
@@ -259,10 +273,30 @@ describe('main.ts', () => {
'prompt-file': promptFile,
})
// Expect the run function to throw due to process.exit being mocked
await expect(run()).rejects.toThrow('process.exit called')
await run()
expect(core.setFailed).toHaveBeenCalledWith(`File for prompt-file was not found: ${promptFile}`)
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
it('creates temporary files that persist for downstream steps', async () => {
mockInputs({
prompt: 'Test prompt',
'system-prompt': 'You are a test assistant.',
})
await run()
// Verify temp file is created with keep: true so it persists
expect(mockFileSync).toHaveBeenCalledWith({
prefix: 'modelResponse-',
postfix: '.txt',
keep: true,
})
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', '/secure/temp/dir/modelResponse-abc123.txt')
expect(mockWriteFileSync).toHaveBeenCalledWith('/secure/temp/dir/modelResponse-abc123.txt', 'Hello, user!', 'utf-8')
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
})

View File

@@ -55,7 +55,7 @@ inputs:
required: false
default: 'false'
github-mcp-token:
description: The token to use for GitHub MCP server (defaults to GITHUB_TOKEN if not specified)
description: The token to use for GitHub MCP server (defaults to the main token if not specified). This must be a PAT for MCP to work.
required: false
default: ''
@@ -67,5 +67,5 @@ outputs:
description: The file path where the response is saved
runs:
using: node20
using: node24
main: dist/index.js

1041
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ const compat = new FlatCompat({
export default [
{
ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules'],
ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules', 'script/**'],
},
...compat.extends(
'eslint:recommended',

37
package-lock.json generated
View File

@@ -11,9 +11,11 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/tmp": "^0.2.6",
"js-yaml": "^4.1.0",
"openai": "^5.11.0",
"pkce-challenge": "^5.0.0"
"pkce-challenge": "^5.0.0",
"tmp": "^0.2.4"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
@@ -24,7 +26,7 @@
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.31",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.29.0",
@@ -39,7 +41,7 @@
"vitest": "^3"
},
"engines": {
"node": ">=20"
"node": ">=24"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "*"
@@ -2476,13 +2478,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz",
"integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.8.0"
}
},
"node_modules/@types/resolve": {
@@ -2492,6 +2494,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/tmp": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz",
"integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz",
@@ -8943,6 +8951,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9207,9 +9224,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},

View File

@@ -6,7 +6,7 @@
".": "./dist/index.js"
},
"engines": {
"node": ">=20"
"node": ">=24"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
@@ -25,9 +25,11 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/tmp": "^0.2.6",
"js-yaml": "^4.1.0",
"openai": "^5.11.0",
"pkce-challenge": "^5.0.0"
"pkce-challenge": "^5.0.0",
"tmp": "^0.2.4"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
@@ -38,7 +40,7 @@
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.31",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.29.0",

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* A simple mock OpenAI-compatible inference server for CI testing.
* This returns predictable responses without needing real API credentials.
*/
import http from 'http'
const PORT = process.env.MOCK_SERVER_PORT || 3456
const server = http.createServer((req, res) => {
let body = ''
req.on('data', chunk => {
body += chunk.toString()
})
req.on('end', () => {
console.log(`[Mock Server] ${req.method} ${req.url}`)
// Handle chat completions endpoint
if (req.url === '/chat/completions' && req.method === 'POST') {
const request = JSON.parse(body)
const userMessage = request.messages?.find(m => m.role === 'user')?.content || 'No prompt'
const response = {
id: 'mock-completion-id',
object: 'chat.completion',
created: Date.now(),
model: request.model || 'mock-model',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: `Mock response to: "${userMessage.slice(0, 50)}..."`,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify(response))
return
}
// Health check endpoint
if (req.url === '/health' || req.url === '/') {
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({status: 'ok'}))
return
}
// 404 for unknown routes
res.writeHead(404, {'Content-Type': 'application/json'})
res.end(JSON.stringify({error: 'Not found'}))
})
})
server.listen(PORT, () => {
console.log(`[Mock Server] Listening on http://localhost:${PORT}`)
console.log('[Mock Server] Endpoints:')
console.log(' POST /chat/completions - Mock chat completion')
console.log(' GET /health - Health check')
})

View File

@@ -82,6 +82,8 @@ export function buildInferenceRequest(
systemPrompt: string | undefined,
prompt: string | undefined,
modelName: string,
temperature: number | undefined,
topP: number | undefined,
maxTokens: number,
endpoint: string,
token: string,
@@ -92,6 +94,8 @@ export function buildInferenceRequest(
return {
messages,
modelName,
temperature,
topP,
maxTokens,
endpoint,
token,

View File

@@ -15,6 +15,8 @@ export interface InferenceRequest {
maxTokens: number
endpoint: string
token: string
temperature?: number
topP?: number
responseFormat?: {type: 'json_schema'; json_schema: unknown} // Processed response format for the API
}
@@ -45,6 +47,8 @@ export async function simpleInference(request: InferenceRequest): Promise<string
messages: request.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
max_tokens: request.maxTokens,
model: request.modelName,
temperature: request.temperature,
top_p: request.topP,
}
// Add response format if specified
@@ -53,21 +57,10 @@ export async function simpleInference(request: InferenceRequest): Promise<string
chatCompletionRequest.response_format = request.responseFormat as any
}
try {
const response = await client.chat.completions.create(chatCompletionRequest)
if ('choices' in response) {
const modelResponse = response.choices[0]?.message?.content
core.info(`Model response: ${modelResponse || 'No response content'}`)
return modelResponse || null
} else {
core.error(`Unexpected response format from API: ${JSON.stringify(response)}`)
return null
}
} catch (error) {
core.error(`API error: ${error}`)
throw error
}
const response = await chatCompletion(client, chatCompletionRequest, 'simpleInference')
const modelResponse = response.choices[0]?.message?.content
core.info(`Model response: ${modelResponse || 'No response content'}`)
return modelResponse || null
}
/**
@@ -89,6 +82,9 @@ export async function mcpInference(
let iterationCount = 0
const maxIterations = 5 // Prevent infinite loops
// We want to use response_format (e.g. JSON) on the last iteration only, so the model can output
// the final result in the expected format without interfering with tool calls
let finalMessage = false
while (iterationCount < maxIterations) {
iterationCount++
@@ -98,21 +94,20 @@ export async function mcpInference(
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
max_tokens: request.maxTokens,
model: request.modelName,
tools: githubMcpClient.tools as OpenAI.Chat.Completions.ChatCompletionTool[],
temperature: request.temperature,
top_p: request.topP,
}
// Add response format if specified (only on first iteration to avoid conflicts)
if (iterationCount === 1 && request.responseFormat) {
// Add response format if specified (only on final iteration to avoid conflicts with tool calls)
if (finalMessage && request.responseFormat) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chatCompletionRequest.response_format = request.responseFormat as any
} else {
chatCompletionRequest.tools = githubMcpClient.tools as OpenAI.Chat.Completions.ChatCompletionTool[]
}
try {
const response = await client.chat.completions.create(chatCompletionRequest)
if (!('choices' in response)) {
throw new Error(`Unexpected response format from API: ${JSON.stringify(response)}`)
}
const response = await chatCompletion(client, chatCompletionRequest, `mcpInference iteration ${iterationCount}`)
const assistantMessage = response.choices[0]?.message
const modelResponse = assistantMessage?.content
@@ -128,17 +123,23 @@ export async function mcpInference(
if (!toolCalls || toolCalls.length === 0) {
core.info('No tool calls requested, ending GitHub MCP inference loop')
return modelResponse || null
if (request.responseFormat && !finalMessage) {
core.info('Making one more MCP loop with the requested response format...')
messages.push({
role: 'user',
content: `Please provide your response in the exact ${request.responseFormat.type} format specified.`,
})
finalMessage = true
continue
} else {
return modelResponse || null
}
}
core.info(`Model requested ${toolCalls.length} tool calls`)
// Execute all tool calls via GitHub MCP
const toolResults = await executeToolCalls(githubMcpClient.client, toolCalls as ToolCall[])
// Add tool results to the conversation
messages.push(...toolResults)
core.info('Tool results added, continuing conversation...')
} catch (error) {
core.error(`OpenAI API error: ${error}`)
@@ -156,3 +157,43 @@ export async function mcpInference(
return lastAssistantMessage?.content || null
}
/**
* Wrapper around OpenAI chat.completions.create with defensive handling for cases where
* the SDK returns a raw string (e.g., unexpected content-type or streaming body) instead of
* a parsed object. Ensures an object with a 'choices' array is returned or throws a descriptive error.
*/
async function chatCompletion(
client: OpenAI,
params: OpenAI.Chat.Completions.ChatCompletionCreateParams,
context: string,
): Promise<OpenAI.Chat.Completions.ChatCompletion> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any = await client.chat.completions.create(params)
core.debug(`${context}: raw response typeof=${typeof response}`)
if (typeof response === 'string') {
// Attempt to parse if we unexpectedly received a string
try {
response = JSON.parse(response)
} catch (e) {
const preview = response.slice(0, 400)
throw new Error(
`${context}: Chat completion response was a string and not valid JSON (${(e as Error).message}). Preview: ${preview}`,
)
}
}
if (!response || typeof response !== 'object' || !('choices' in response)) {
const preview = JSON.stringify(response)?.slice(0, 800)
throw new Error(`${context}: Unexpected response shape (no choices). Preview: ${preview}`)
}
return response as OpenAI.Chat.Completions.ChatCompletion
} catch (err) {
// Re-throw after logging for upstream handling
core.error(`${context}: chatCompletion failed: ${err}`)
throw err
}
}

View File

@@ -1,7 +1,6 @@
import * as core from '@actions/core'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as tmp from 'tmp'
import {connectToGitHubMCP} from './mcp.js'
import {simpleInference, mcpInference} from './inference.js'
import {loadContentFromFileOrInput, buildInferenceRequest} from './helpers.js'
@@ -13,8 +12,6 @@ import {
parseFileTemplateVariables,
} from './prompt.js'
const RESPONSE_FILE = 'modelResponse.txt'
/**
* The main function for the action.
*
@@ -51,7 +48,11 @@ export async function run(): Promise<void> {
// Get common parameters
const modelName = promptConfig?.model || core.getInput('model')
const maxTokens = parseInt(core.getInput('max-tokens'), 10)
let maxTokens = promptConfig?.modelParameters?.maxTokens ?? core.getInput('max-tokens')
if (typeof maxTokens === 'string') {
maxTokens = parseInt(maxTokens, 10)
}
const token = process.env['GITHUB_TOKEN'] || core.getInput('token')
if (token === undefined) {
@@ -69,6 +70,8 @@ export async function run(): Promise<void> {
systemPrompt,
prompt,
modelName,
promptConfig?.modelParameters?.temperature,
promptConfig?.modelParameters?.topP,
maxTokens,
endpoint,
token,
@@ -93,11 +96,19 @@ export async function run(): Promise<void> {
core.setOutput('response', modelResponse || '')
const responseFilePath = path.join(tempDir(), RESPONSE_FILE)
core.setOutput('response-file', responseFilePath)
// Create a temporary file for the response that persists for downstream steps.
// We use keep: true to prevent automatic cleanup - the file will be cleaned up
// by the runner when the job completes.
const responseFile = tmp.fileSync({
prefix: 'modelResponse-',
postfix: '.txt',
keep: true,
})
core.setOutput('response-file', responseFile.name)
if (modelResponse && modelResponse !== '') {
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8')
fs.writeFileSync(responseFile.name, modelResponse, 'utf-8')
}
} catch (error) {
if (error instanceof Error) {
@@ -105,13 +116,10 @@ export async function run(): Promise<void> {
} else {
core.setFailed(`An unexpected error occurred: ${JSON.stringify(error, null, 2)}`)
}
// Force exit to prevent hanging on open connections
process.exit(1)
}
}
function tempDir(): string {
const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir()
return tempDirectory
// Force exit to prevent hanging on open connections
process.exit(0)
}

View File

@@ -7,9 +7,16 @@ export interface PromptMessage {
content: string
}
export interface ModelParameters {
maxTokens?: number
temperature?: number
topP?: number
}
export interface PromptConfig {
messages: PromptMessage[]
model?: string
modelParameters?: ModelParameters
responseFormat?: 'text' | 'json_schema'
jsonSchema?: string
}
@@ -101,11 +108,8 @@ export function loadPromptFile(filePath: string, templateVariables: TemplateVari
const fileContent = fs.readFileSync(filePath, 'utf-8')
// Apply template variable substitution
const processedContent = replaceTemplateVariables(fileContent, templateVariables)
try {
const config = yaml.load(processedContent) as PromptConfig
const config = yaml.load(fileContent) as PromptConfig
if (!config.messages || !Array.isArray(config.messages)) {
throw new Error('Prompt file must contain a "messages" array')
@@ -121,6 +125,14 @@ export function loadPromptFile(filePath: string, templateVariables: TemplateVari
}
}
// Prepare messages by replacing template variables with actual content
config.messages = config.messages.map(msg => {
return {
...msg,
content: replaceTemplateVariables(msg.content, templateVariables),
}
})
return config
} catch (error) {
throw new Error(`Failed to parse prompt file: ${error instanceof Error ? error.message : 'Unknown error'}`)