Compare commits

...

10 Commits

Author SHA1 Message Date
Sean Goedecke 3c6ec33d64 Merge pull request #85 from actions/sgoedecke/file-inputs
Allow templating variables from files
2025-08-05 12:19:39 +10:00
Sean Goedecke ea4e7d8bb9 package 2025-08-05 01:52:46 +00:00
Sean Goedecke aaf9c5af33 Update src/prompt.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 11:51:50 +10:00
Sean Goedecke 15868b88f4 Allow templating variables from files 2025-08-05 01:32:32 +00:00
Sean Goedecke c37f296c98 Merge pull request #84 from actions/sgoedecke/better-error-logging
Log specific error even if it is not an Error
2025-08-05 09:28:24 +10:00
Sean Goedecke e7ddc840ba npm run package 2025-08-04 23:00:34 +00:00
Sean Goedecke fa321d4c78 Update src/main.ts
Co-authored-by: Marais Rossouw <me@marais.co>
2025-08-05 08:59:43 +10:00
Sean Goedecke 3b5da63917 update tests 2025-08-04 22:44:17 +00:00
Sean Goedecke a620b9fa98 Force exit on error 2025-08-04 22:40:30 +00:00
Sean Goedecke a6d2a86ab3 Log specific error even if it is not an Error 2025-08-04 22:28:10 +00:00
9 changed files with 194 additions and 19 deletions
+7 -1
View File
@@ -65,6 +65,9 @@ steps:
var3: |
Lorem Ipsum
Hello World
file_input: |
var4: ./path/to/long-text.txt
var5: ./path/to/config.json
```
#### Simple prompt.yml example
@@ -116,7 +119,9 @@ jsonSchema: |-
```
Variables in prompt.yml files are templated using `{{variable}}` format and are
supplied via the `input` parameter in YAML format.
supplied via the `input` parameter in YAML format. Additionally, you can
provide file-based variables via `file_input`, where each key maps to a file
path.
### Using a system prompt file
@@ -197,6 +202,7 @@ the action:
| `prompt` | The prompt to send to the model | N/A |
| `prompt-file` | Path to a file containing the prompt (supports .txt and .prompt.yml formats). If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
| `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` |
| `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` |
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` |
+43
View File
@@ -130,6 +130,49 @@ model: openai/gpt-4o
expect(core.setOutput).toHaveBeenCalledWith('response-file', expect.any(String))
})
it('supports file_input variables to load file contents', async () => {
mockExistsSync.mockReturnValue(true)
// First call: reading the prompt file. Second call: reading file_input referenced file contents.
const externalFilePath = 'vars.txt'
mockReadFileSync.mockImplementation((path: string) => {
if (path === 'test.prompt.yml') {
return `messages:\n - role: user\n content: 'Here is the data: {{blob}}'\nmodel: openai/gpt-4o\n`
}
if (path === externalFilePath) {
return 'FILE_CONTENTS'
}
return ''
})
core.getInput.mockImplementation((name: string) => {
switch (name) {
case 'prompt-file':
return 'test.prompt.yml'
case 'file_input':
return `blob: ${externalFilePath}`
case 'model':
return 'openai/gpt-4o'
case 'max-tokens':
return '200'
case 'endpoint':
return 'https://models.github.ai/inference'
case 'enable-github-mcp':
return 'false'
default:
return ''
}
})
await run()
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{role: 'user', content: 'Here is the data: FILE_CONTENTS'}],
}),
)
})
it('should fall back to legacy format when not using prompt YAML', async () => {
mockExistsSync.mockReturnValue(false)
core.getInput.mockImplementation((name: string) => {
+13 -3
View File
@@ -94,6 +94,11 @@ vi.mock('../src/inference.js', () => ({
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')
})
// 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')
@@ -102,6 +107,7 @@ describe('main.ts', () => {
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks()
mockProcessExit.mockClear()
// Remove any existing GITHUB_TOKEN
delete process.env.GITHUB_TOKEN
@@ -129,9 +135,11 @@ describe('main.ts', () => {
'prompt-file': '',
})
await run()
// Expect the run function to throw due to process.exit being mocked
await expect(run()).rejects.toThrow('process.exit called')
expect(core.setFailed).toHaveBeenNthCalledWith(1, 'Neither prompt-file nor prompt was set')
expect(core.setFailed).toHaveBeenCalledWith('Neither prompt-file nor prompt was set')
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
it('uses simple inference when MCP is disabled', async () => {
@@ -251,8 +259,10 @@ describe('main.ts', () => {
'prompt-file': promptFile,
})
await run()
// Expect the run function to throw due to process.exit being mocked
await expect(run()).rejects.toThrow('process.exit called')
expect(core.setFailed).toHaveBeenCalledWith(`File for prompt-file was not found: ${promptFile}`)
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
})
+26 -7
View File
@@ -1,7 +1,13 @@
import {describe, it, expect} from 'vitest'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {parseTemplateVariables, replaceTemplateVariables, loadPromptFile, isPromptYamlFile} from '../src/prompt'
import {
parseTemplateVariables,
replaceTemplateVariables,
loadPromptFile,
isPromptYamlFile,
parseFileTemplateVariables,
} from '../src/prompt'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -10,8 +16,8 @@ describe('prompt.ts', () => {
describe('parseTemplateVariables', () => {
it('should parse simple YAML variables', () => {
const input = `
a: hello
b: world
a: hello
b: world
`
const result = parseTemplateVariables(input)
expect(result).toEqual({a: 'hello', b: 'world'})
@@ -19,10 +25,10 @@ b: world
it('should parse multiline variables', () => {
const input = `
var1: hello
var2: |
This is a
multiline string
var1: hello
var2: |
This is a
multiline string
`
const result = parseTemplateVariables(input)
expect(result.var1).toBe('hello')
@@ -117,4 +123,17 @@ var2: |
expect(() => loadPromptFile('non-existent.prompt.yml')).toThrow('Prompt file not found')
})
})
describe('parseFileTemplateVariables', () => {
it('reads file contents for variables', () => {
const configPath = path.join(__dirname, '../__fixtures__/prompts/json-schema.prompt.yml')
const data = parseFileTemplateVariables(`sample: ${configPath}`)
expect(data.sample).toContain('messages:')
expect(data.sample).toContain('responseFormat:')
})
it('errors on missing files', () => {
expect(() => parseFileTemplateVariables('x: ./does-not-exist.txt')).toThrow('was not found')
})
})
})
+4
View File
@@ -22,6 +22,10 @@ inputs:
description: Template variables in YAML format for .prompt.yml files
required: false
default: ''
file_input:
description: Template variables in YAML format mapping variable names to file paths. The file contents will be used for templating.
required: false
default: ''
model:
description: The model to use
required: false
Generated Vendored
+43 -3
View File
@@ -52047,6 +52047,41 @@ function parseTemplateVariables(input) {
throw new Error(`Failed to parse template variables: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Parse file-based template variables from YAML input string. The YAML should map
* variable names to file paths. File contents are read and returned as variables.
*/
function parseFileTemplateVariables(fileInput) {
if (!fileInput.trim()) {
return {};
}
try {
const parsed = load(fileInput);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('File template variables must be a YAML object');
}
const result = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`File template variable '${key}' must be a string file path`);
}
const filePath = value;
if (!fs.existsSync(filePath)) {
throw new Error(`File for template variable '${key}' was not found: ${filePath}`);
}
try {
result[key] = fs.readFileSync(filePath, 'utf-8');
}
catch (err) {
throw new Error(`Failed to read file for template variable '${key}' at path '${filePath}': ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return result;
}
catch (error) {
throw new Error(`Failed to parse file template variables: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Replace template variables in text using {{variable}} syntax
*/
@@ -52106,14 +52141,17 @@ async function run() {
try {
const promptFilePath = coreExports.getInput('prompt-file');
const inputVariables = coreExports.getInput('input');
const fileInputVariables = coreExports.getInput('file_input');
let promptConfig = undefined;
let systemPrompt = undefined;
let prompt = undefined;
// Check if we're using a prompt YAML file
if (promptFilePath && isPromptYamlFile(promptFilePath)) {
coreExports.info('Using prompt YAML file format');
// Parse template variables
const templateVariables = parseTemplateVariables(inputVariables);
// Parse template variables from both string inputs and file-based inputs
const stringVars = parseTemplateVariables(inputVariables);
const fileVars = parseFileTemplateVariables(fileInputVariables);
const templateVariables = { ...stringVars, ...fileVars };
// Load and process prompt file
promptConfig = loadPromptFile(promptFilePath, templateVariables);
}
@@ -52162,8 +52200,10 @@ async function run() {
coreExports.setFailed(error.message);
}
else {
coreExports.setFailed('An unexpected error occurred');
coreExports.setFailed(`An unexpected error occurred: ${JSON.stringify(error, null, 2)}`);
}
// Force exit to prevent hanging on open connections
process.exit(1);
}
}
function tempDir() {
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+16 -4
View File
@@ -5,7 +5,13 @@ import * as path from 'path'
import {connectToGitHubMCP} from './mcp.js'
import {simpleInference, mcpInference} from './inference.js'
import {loadContentFromFileOrInput, buildInferenceRequest} from './helpers.js'
import {loadPromptFile, parseTemplateVariables, isPromptYamlFile, PromptConfig} from './prompt.js'
import {
loadPromptFile,
parseTemplateVariables,
isPromptYamlFile,
PromptConfig,
parseFileTemplateVariables,
} from './prompt.js'
const RESPONSE_FILE = 'modelResponse.txt'
@@ -18,6 +24,7 @@ export async function run(): Promise<void> {
try {
const promptFilePath = core.getInput('prompt-file')
const inputVariables = core.getInput('input')
const fileInputVariables = core.getInput('file_input')
let promptConfig: PromptConfig | undefined = undefined
let systemPrompt: string | undefined = undefined
@@ -27,8 +34,10 @@ export async function run(): Promise<void> {
if (promptFilePath && isPromptYamlFile(promptFilePath)) {
core.info('Using prompt YAML file format')
// Parse template variables
const templateVariables = parseTemplateVariables(inputVariables)
// Parse template variables from both string inputs and file-based inputs
const stringVars = parseTemplateVariables(inputVariables)
const fileVars = parseFileTemplateVariables(fileInputVariables)
const templateVariables = {...stringVars, ...fileVars}
// Load and process prompt file
promptConfig = loadPromptFile(promptFilePath, templateVariables)
@@ -94,8 +103,11 @@ export async function run(): Promise<void> {
if (error instanceof Error) {
core.setFailed(error.message)
} else {
core.setFailed('An unexpected error occurred')
core.setFailed(`An unexpected error occurred: ${JSON.stringify(error, null, 2)}`)
}
// Force exit to prevent hanging on open connections
process.exit(1)
}
}
+41
View File
@@ -37,6 +37,47 @@ export function parseTemplateVariables(input: string): TemplateVariables {
}
}
/**
* Parse file-based template variables from YAML input string. The YAML should map
* variable names to file paths. File contents are read and returned as variables.
*/
export function parseFileTemplateVariables(fileInput: string): TemplateVariables {
if (!fileInput.trim()) {
return {}
}
try {
const parsed = yaml.load(fileInput) as Record<string, unknown>
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('File template variables must be a YAML object')
}
const result: TemplateVariables = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`File template variable '${key}' must be a string file path`)
}
const filePath = value
if (!fs.existsSync(filePath)) {
throw new Error(`File for template variable '${key}' was not found: ${filePath}`)
}
try {
result[key] = fs.readFileSync(filePath, 'utf-8')
} catch (err) {
throw new Error(
`Failed to read file for template variable '${key}' at path '${filePath}': ${err instanceof Error ? err.message : 'Unknown error'}`,
)
}
}
return result
} catch (error) {
throw new Error(
`Failed to parse file template variables: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}
/**
* Replace template variables in text using {{variable}} syntax
*/