Skip to content
30 changes: 14 additions & 16 deletions __tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ X-Custom-Header: custom-value`
header2: 'value2',
'X-Custom-Header': 'custom-value',
})
expect(core.info).toHaveBeenCalledWith('Custom header added: header1: value1')
expect(core.info).toHaveBeenCalledWith('Custom header added: header2: value2')
expect(core.info).toHaveBeenCalledWith('Custom header added: X-Custom-Header: custom-value')
expect(core.debug).toHaveBeenCalledWith('Custom header added: header1: value1')
expect(core.debug).toHaveBeenCalledWith('Custom header added: header2: value2')
expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Custom-Header: custom-value')
})

it('parses JSON format headers correctly', () => {
Expand All @@ -165,9 +165,9 @@ X-Custom-Header: custom-value`
header2: 'value2',
'X-Team': 'engineering',
})
expect(core.info).toHaveBeenCalledWith('Custom header added: header1: value1')
expect(core.info).toHaveBeenCalledWith('Custom header added: header2: value2')
expect(core.info).toHaveBeenCalledWith('Custom header added: X-Team: engineering')
expect(core.debug).toHaveBeenCalledWith('Custom header added: header1: value1')
expect(core.debug).toHaveBeenCalledWith('Custom header added: header2: value2')
expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Team: engineering')
})

it('returns empty object for empty input', () => {
Expand All @@ -194,13 +194,13 @@ password: pass123`
})

// Sensitive headers should be masked
expect(core.info).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***')
expect(core.info).toHaveBeenCalledWith('Custom header added: X-Api-Token: ***MASKED***')
expect(core.info).toHaveBeenCalledWith('Custom header added: Authorization: ***MASKED***')
expect(core.info).toHaveBeenCalledWith('Custom header added: password: ***MASKED***')
expect(core.debug).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***')
expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Api-Token: ***MASKED***')
expect(core.debug).toHaveBeenCalledWith('Custom header added: Authorization: ***MASKED***')
expect(core.debug).toHaveBeenCalledWith('Custom header added: password: ***MASKED***')

// Non-sensitive headers should not be masked
expect(core.info).toHaveBeenCalledWith('Custom header added: serviceName: my-service')
expect(core.debug).toHaveBeenCalledWith('Custom header added: serviceName: my-service')
})

it('validates header names and skips invalid ones', () => {
Expand All @@ -214,13 +214,11 @@ valid123: value5`

expect(result).toEqual({
'valid-header': 'value1',
invalid_underscore: 'value3',
valid123: 'value5',
})

expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid header name: invalid header'))
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('Skipping invalid header name: invalid_underscore'),
)
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid header name: invalid@header'))
})

Expand Down Expand Up @@ -367,8 +365,8 @@ systemID: terraform-ci`
})

// Only the subscription key should be masked
expect(core.info).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***')
expect(core.info).toHaveBeenCalledWith('Custom header added: serviceName: terraform-plan-workflow')
expect(core.debug).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***')
expect(core.debug).toHaveBeenCalledWith('Custom header added: serviceName: terraform-plan-workflow')
})
})
})
4 changes: 2 additions & 2 deletions __tests__/inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('inference.ts', () => {

expect(result).toBe('Hello, user!')
expect(core.info).toHaveBeenCalledWith('Running simple inference without tools')
expect(core.info).toHaveBeenCalledWith('Model response: Hello, user!')
expect(core.debug).toHaveBeenCalledWith('Model response: Hello, user!')

// Verify the request structure
expect(mockCreate).toHaveBeenCalledWith({
Expand Down Expand Up @@ -136,7 +136,7 @@ describe('inference.ts', () => {
const result = await simpleInference(mockRequest)

expect(result).toBeNull()
expect(core.info).toHaveBeenCalledWith('Model response: No response content')
expect(core.debug).toHaveBeenCalledWith('Model response: No response content')
})

it('includes response format when specified', async () => {
Expand Down
4 changes: 2 additions & 2 deletions __tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ describe('mcp.ts', () => {
name: 'test-tool',
content: JSON.stringify(toolResult.content),
})
expect(core.info).toHaveBeenCalledWith('Executing GitHub MCP tool: test-tool with args: {"param": "value"}')
expect(core.info).toHaveBeenCalledWith('GitHub MCP tool test-tool executed successfully')
expect(core.debug).toHaveBeenCalledWith('Executing GitHub MCP tool: test-tool with args: {"param": "value"}')
expect(core.debug).toHaveBeenCalledWith('GitHub MCP tool test-tool executed successfully')
})

it('handles tool execution errors gracefully', async () => {
Expand Down
12 changes: 12 additions & 0 deletions __tests__/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,17 @@ describe('prompt.ts', () => {
it('errors on missing files', () => {
expect(() => parseFileTemplateVariables('x: ./does-not-exist.txt')).toThrow('was not found')
})

it('errors on non-string file paths', () => {
expect(() => parseFileTemplateVariables('x: 123')).toThrow(
"File template variable 'x' must be a string file path",
)
expect(() => parseFileTemplateVariables('x: true')).toThrow(
"File template variable 'x' must be a string file path",
)
expect(() => parseFileTemplateVariables('x: { nested: "object" }')).toThrow(
"File template variable 'x' must be a string file path",
)
})
})
})
11 changes: 6 additions & 5 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ function validateAndMaskHeaders(headers: Record<string, unknown>): Record<string
const sensitivePatterns = ['key', 'token', 'secret', 'password', 'authorization']

for (const [name, value] of Object.entries(headers)) {
// Validate header name (basic HTTP header name validation, RFC 7230: letters, digits, and hyphens)
if (!/^[A-Za-z0-9-]+$/.test(name)) {
core.warning(`Skipping invalid header name: ${name} (only alphanumeric characters and hyphens allowed)`)
// Validate header name (RFC 7230: token = 1*tchar)
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
if (!/^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(name)) {
core.warning(`Skipping invalid header name: ${name} (contains invalid characters)`)
continue
}

Expand All @@ -143,9 +144,9 @@ function validateAndMaskHeaders(headers: Record<string, unknown>): Record<string
const lowerName = name.toLowerCase()
const isSensitive = sensitivePatterns.some(pattern => lowerName.includes(pattern))
if (isSensitive) {
core.info(`Custom header added: ${name}: ***MASKED***`)
core.debug(`Custom header added: ${name}: ***MASKED***`)
} else {
core.info(`Custom header added: ${name}: ${stringValue}`)
core.debug(`Custom header added: ${name}: ${stringValue}`)
}
}

Expand Down
10 changes: 4 additions & 6 deletions src/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function simpleInference(request: InferenceRequest): Promise<string

const response = await chatCompletion(client, chatCompletionRequest, 'simpleInference')
const modelResponse = response.choices[0]?.message?.content
core.info(`Model response: ${modelResponse || 'No response content'}`)
core.debug(`Model response: ${modelResponse || 'No response content'}`)
return modelResponse || null
}

Expand Down Expand Up @@ -131,7 +131,7 @@ export async function mcpInference(
const modelResponse = assistantMessage?.content
const toolCalls = assistantMessage?.tool_calls

core.info(`Model response: ${modelResponse || 'No response content'}`)
core.debug(`Model response: ${modelResponse || 'No response content'}`)

messages.push({
role: 'assistant',
Expand Down Expand Up @@ -196,16 +196,14 @@ async function chatCompletion(
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}`,
`${context}: Chat completion response was a string and not valid JSON (${(e as Error).message})`,
)
}
}

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}`)
throw new Error(`${context}: Unexpected response shape (no choices)`)
}

return response as OpenAI.Chat.Completions.ChatCompletion
Expand Down
13 changes: 3 additions & 10 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function connectToGitHubMCP(token: string, toolsets?: string): Prom
* Execute a single tool call via GitHub MCP
*/
export async function executeToolCall(githubMcpClient: Client, toolCall: ToolCall): Promise<ToolResult> {
core.info(`Executing GitHub MCP tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`)
core.debug(`Executing GitHub MCP tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`)

try {
const args = JSON.parse(toolCall.function.arguments)
Expand All @@ -106,7 +106,7 @@ export async function executeToolCall(githubMcpClient: Client, toolCall: ToolCal
arguments: args,
})

core.info(`GitHub MCP tool ${toolCall.function.name} executed successfully`)
core.debug(`GitHub MCP tool ${toolCall.function.name} executed successfully`)

return {
tool_call_id: toolCall.id,
Expand All @@ -130,12 +130,5 @@ export async function executeToolCall(githubMcpClient: Client, toolCall: ToolCal
* Execute all tool calls from a response via GitHub MCP
*/
export async function executeToolCalls(githubMcpClient: Client, toolCalls: ToolCall[]): Promise<ToolResult[]> {
const toolResults: ToolResult[] = []

for (const toolCall of toolCalls) {
const result = await executeToolCall(githubMcpClient, toolCall)
toolResults.push(result)
}

return toolResults
return Promise.all(toolCalls.map(toolCall => executeToolCall(githubMcpClient, toolCall)))
}