mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
buildPullRequestFieldsPrompt,
|
|
GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS,
|
|
parseGeneratedPullRequestFields,
|
|
type PullRequestDraftContext
|
|
} from './pull-request-generation'
|
|
|
|
const context: PullRequestDraftContext = {
|
|
branch: 'feature/pr-details',
|
|
base: 'main',
|
|
branchChangedByPreparation: false,
|
|
currentTitle: 'Feature pr details',
|
|
currentBody: '- Add form',
|
|
currentDraft: false,
|
|
commitSummary: '- feat: add generated PR details',
|
|
changeSummary: 'M\tsrc/file.ts',
|
|
patch: 'diff --git a/src/file.ts b/src/file.ts\n+export const value = true'
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('buildPullRequestFieldsPrompt', () => {
|
|
it('asks for compact JSON and includes PR context', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(context, 'Use conventional PR titles.')
|
|
|
|
expect(prompt).toContain('Return ONLY compact JSON')
|
|
expect(prompt).toContain('Head branch: feature/pr-details')
|
|
expect(prompt).toContain('Current base: main')
|
|
expect(prompt).toContain('Additional user prompt:')
|
|
expect(prompt).toContain('Use conventional PR titles.')
|
|
})
|
|
|
|
it('requires ELI5 problem and solution sections before implementation details', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(context, '')
|
|
|
|
expect(prompt).toContain('start with `## Problem`, then `## Solution`')
|
|
expect(prompt).toContain('simple ELI5 language before details')
|
|
expect(prompt).toContain('Reuse equivalent existing sections instead of duplicating them')
|
|
})
|
|
|
|
it('includes GitHub issue details and complete or partial reference guidance', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(
|
|
{
|
|
...context,
|
|
provider: 'github',
|
|
linkedIssueDetails: {
|
|
provider: 'github',
|
|
number: 12398,
|
|
title: 'Stop phantom polling',
|
|
description: 'Helpers repeatedly stat Linux-only PATH entries.'
|
|
}
|
|
},
|
|
''
|
|
)
|
|
|
|
expect(prompt).toContain('Linked GitHub issue: #12398 Stop phantom polling')
|
|
expect(prompt).toContain('Issue description:\nHelpers repeatedly stat Linux-only PATH entries.')
|
|
expect(prompt).toContain('`Fixes #12398` only for a complete fix')
|
|
expect(prompt).toContain('use `Refs #12398`')
|
|
})
|
|
|
|
it('uses GitLab-specific issue references', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(
|
|
{
|
|
...context,
|
|
provider: 'gitlab',
|
|
linkedIssueDetails: {
|
|
provider: 'gitlab',
|
|
number: 42,
|
|
title: 'Fix runner polling',
|
|
description: 'The runner checks paths that cannot exist.'
|
|
}
|
|
},
|
|
''
|
|
)
|
|
|
|
expect(prompt).toContain('Linked GitLab issue: #42 Fix runner polling')
|
|
expect(prompt).toContain('`Closes #42` only for a complete fix')
|
|
expect(prompt).toContain('use `Related to #42`')
|
|
expect(prompt).not.toContain('GitHub issue')
|
|
})
|
|
|
|
it('uses the active provider when no issue is linked', () => {
|
|
const prompt = buildPullRequestFieldsPrompt({ ...context, provider: 'bitbucket' }, '')
|
|
|
|
expect(prompt).toContain('Linked Bitbucket issue: (none)')
|
|
expect(prompt).toContain('No Bitbucket issue is linked; do not invent one')
|
|
expect(prompt).not.toContain('GitHub issue')
|
|
})
|
|
|
|
it('uses Azure DevOps work-item syntax', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(
|
|
{
|
|
...context,
|
|
provider: 'azure-devops',
|
|
linkedIssueDetails: {
|
|
provider: 'azure-devops',
|
|
number: 99,
|
|
title: 'Stop unnecessary polling',
|
|
description: 'Avoid checks for unavailable tools.'
|
|
}
|
|
},
|
|
''
|
|
)
|
|
|
|
expect(prompt).toContain('Linked Azure DevOps issue: AB#99 Stop unnecessary polling')
|
|
expect(prompt).toContain('`Fixes AB#99` only for a complete fix')
|
|
expect(prompt).toContain('use `AB#99`')
|
|
})
|
|
|
|
it('tells the agent to preserve existing review templates', () => {
|
|
const prompt = buildPullRequestFieldsPrompt(
|
|
{
|
|
...context,
|
|
currentBody: '## Summary\n\n## Testing\n\n- [ ] Required checks'
|
|
},
|
|
''
|
|
)
|
|
|
|
expect(prompt).toContain('Retain every heading, required section, and checklist')
|
|
expect(prompt).toContain('Leave genuinely unknown template items as TODO or unchecked')
|
|
})
|
|
})
|
|
|
|
describe('parseGeneratedPullRequestFields', () => {
|
|
it('parses fenced JSON output', () => {
|
|
const fields = parseGeneratedPullRequestFields(
|
|
'```json\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\n```',
|
|
context
|
|
)
|
|
|
|
expect(fields).toEqual({
|
|
base: 'main',
|
|
title: 'fix: add details',
|
|
body: 'Summary',
|
|
draft: true
|
|
})
|
|
})
|
|
|
|
it('parses CRLF fenced JSON output without full-string fence matching', () => {
|
|
const matchSpy = vi.spyOn(String.prototype, 'match')
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
const fields = parseGeneratedPullRequestFields(
|
|
'```JSON\r\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\r\n```',
|
|
context
|
|
)
|
|
|
|
expect(fields.title).toBe('fix: add details')
|
|
const usedFenceMatch = matchSpy.mock.calls.some(
|
|
([pattern]) =>
|
|
pattern instanceof RegExp &&
|
|
pattern.source.startsWith('^```') &&
|
|
pattern.source.includes('[\\s\\S]')
|
|
)
|
|
const usedCrlfReplace = replaceSpy.mock.calls.some(
|
|
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n' && pattern.global
|
|
)
|
|
expect(usedFenceMatch).toBe(false)
|
|
expect(usedCrlfReplace).toBe(false)
|
|
})
|
|
|
|
it('falls back for missing optional values', () => {
|
|
const fields = parseGeneratedPullRequestFields('{"title":""}', context)
|
|
|
|
expect(fields).toEqual({
|
|
base: 'main',
|
|
title: 'Feature pr details',
|
|
body: '- Add form',
|
|
draft: false
|
|
})
|
|
})
|
|
|
|
it('keeps an explicitly empty body instead of falling back', () => {
|
|
const fields = parseGeneratedPullRequestFields(
|
|
'{"base":"main","title":"chore: add new app config","body":"","draft":false}',
|
|
context
|
|
)
|
|
|
|
expect(fields).toEqual({
|
|
base: 'main',
|
|
title: 'chore: add new app config',
|
|
body: '',
|
|
draft: false
|
|
})
|
|
})
|
|
|
|
it('rejects excessive nesting before JSON.parse', () => {
|
|
const parseSpy = vi.spyOn(JSON, 'parse')
|
|
const depth = GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS.nestingDepth + 1
|
|
try {
|
|
expect(() =>
|
|
parseGeneratedPullRequestFields(`${'['.repeat(depth)}0${']'.repeat(depth)}`, context)
|
|
).toThrow(/JSON nesting exceeds/)
|
|
expect(parseSpy).not.toHaveBeenCalled()
|
|
} finally {
|
|
parseSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|