Files
magpie/tests/github/commenter.test.ts
Li Liu 8a8dab1ec4 fix: improve inline PR comment placement with content-based matching and full diff fallback
- Add extractDiffLineRanges() to include valid line ranges in structurizer prompt
- Add content-based matching (extractCodeFromBody + findLineByContent) as fallback
- Add full diff fallback via gh api when per-file patches are null
- Widen nearest-line threshold from 20 to 50 for better coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:12:23 +08:00

317 lines
10 KiB
TypeScript

// tests/github/commenter.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getPRHeadSha, classifyComments, postReview, parseDiffLines, findNearestLine, extractCodeFromBody, findLineByContent } from '../../src/github/commenter'
// Mock child_process — all exported functions use execSync
vi.mock('child_process', () => ({
execSync: vi.fn()
}))
import { execSync } from 'child_process'
beforeEach(() => {
vi.resetAllMocks()
// Default: getRepo returns a repo string
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
return ''
})
})
describe('getPRHeadSha', () => {
it('returns head SHA for valid PR number', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('headRefOid')) return 'abc123\n'
return 'https://github.com/owner/repo.git'
})
expect(getPRHeadSha('42')).toBe('abc123')
})
it('rejects non-numeric PR numbers', () => {
expect(() => getPRHeadSha('123; rm -rf /')).toThrow('Invalid PR number')
})
it('rejects alphabetic PR numbers', () => {
expect(() => getPRHeadSha('abc')).toThrow('Invalid PR number')
})
})
describe('classifyComments', () => {
it('classifies inline comments when line is in diff', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/files')) {
return JSON.stringify([{
filename: 'src/auth.ts',
patch: '@@ -1,3 +1,5 @@\n context\n+added line 2\n+added line 3\n context\n context'
}])
}
return ''
})
const result = classifyComments('1', [
{ path: 'src/auth.ts', line: 2, body: 'issue here' }
])
expect(result).toHaveLength(1)
expect(result[0].mode).toBe('inline')
})
it('classifies file-level when line not in diff', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/files')) {
return JSON.stringify([{
filename: 'src/auth.ts',
patch: '@@ -1,3 +1,3 @@\n context\n-old\n+new\n context'
}])
}
return ''
})
const result = classifyComments('1', [
{ path: 'src/auth.ts', line: 999, body: 'not on diff' }
])
expect(result).toHaveLength(1)
expect(result[0].mode).toBe('file')
})
it('classifies global when file not in diff', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/files')) {
return JSON.stringify([{
filename: 'src/other.ts',
patch: '@@ -1,3 +1,3 @@\n context'
}])
}
return ''
})
const result = classifyComments('1', [
{ path: 'src/missing.ts', line: 10, body: 'file not in PR' }
])
expect(result).toHaveLength(1)
expect(result[0].mode).toBe('global')
})
it('reclassifies as inline with nearest diff line when exact line not found', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/files')) {
// Diff hunk covers lines 1-5
return JSON.stringify([{
filename: 'src/auth.ts',
patch: '@@ -1,5 +1,5 @@\n context\n-old\n+new\n context\n context\n context'
}])
}
return ''
})
const result = classifyComments('1', [
{ path: 'src/auth.ts', line: 7, body: 'issue near diff' }
])
expect(result).toHaveLength(1)
expect(result[0].mode).toBe('inline')
// Line adjusted to nearest diff line, body prefixed with original line reference
expect(result[0].input.body).toContain('**Line 7:**')
expect(result[0].input.body).toContain('issue near diff')
})
it('falls back to file mode when line is too far from diff', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/files')) {
// Diff hunk covers lines 1-3
return JSON.stringify([{
filename: 'src/auth.ts',
patch: '@@ -1,3 +1,3 @@\n context\n-old\n+new\n context'
}])
}
return ''
})
const result = classifyComments('1', [
{ path: 'src/auth.ts', line: 100, body: 'way too far' }
])
expect(result).toHaveLength(1)
expect(result[0].mode).toBe('file')
})
it('rejects non-numeric PR numbers', () => {
expect(() => classifyComments('abc', [])).toThrow('Invalid PR number')
})
})
describe('parseDiffLines', () => {
it('extracts right-side line numbers from a unified diff', () => {
const patch = '@@ -1,3 +1,4 @@\n context\n+added\n context\n context'
const lines = parseDiffLines(patch)
// Line 1 = context, line 2 = added, line 3 = context, line 4 = context
expect(lines.has(1)).toBe(true)
expect(lines.has(2)).toBe(true)
expect(lines.has(3)).toBe(true)
expect(lines.has(4)).toBe(true)
})
it('skips removed lines', () => {
const patch = '@@ -1,3 +1,2 @@\n context\n-removed\n context'
const lines = parseDiffLines(patch)
expect(lines.has(1)).toBe(true)
expect(lines.has(2)).toBe(true)
expect(lines.size).toBe(2)
})
})
describe('findNearestLine', () => {
it('returns exact match', () => {
expect(findNearestLine(new Set([5, 10, 15]), 10)).toBe(10)
})
it('returns closest line within threshold', () => {
expect(findNearestLine(new Set([5, 10, 15]), 12)).toBe(10)
})
it('returns null when empty set', () => {
expect(findNearestLine(new Set(), 10)).toBeNull()
})
it('returns null when all lines are too far away', () => {
expect(findNearestLine(new Set([1, 2, 3]), 100)).toBeNull()
})
it('returns line within default 50 distance', () => {
expect(findNearestLine(new Set([30]), 10)).toBe(30)
})
it('returns line at exactly 50 distance (new default)', () => {
expect(findNearestLine(new Set([60]), 10)).toBe(60)
})
it('returns null at 51 distance', () => {
expect(findNearestLine(new Set([61]), 10)).toBeNull()
})
it('respects custom maxDistance', () => {
expect(findNearestLine(new Set([31]), 10, 20)).toBeNull()
expect(findNearestLine(new Set([31]), 10, 25)).toBe(31)
})
})
describe('extractCodeFromBody', () => {
it('extracts code from fenced code block', () => {
const body = 'The issue is here:\n```typescript\nfunction processOrder(data) {\n return data\n}\n```'
expect(extractCodeFromBody(body)).toBe('function processOrder(data) {')
})
it('extracts inline code', () => {
const body = 'The variable `processOrderData` should be validated'
expect(extractCodeFromBody(body)).toBe('processOrderData')
})
it('returns null when no code found', () => {
expect(extractCodeFromBody('No code here')).toBeNull()
})
it('skips very short code snippets', () => {
expect(extractCodeFromBody('Use `x = 1` here')).toBeNull()
})
})
describe('findLineByContent', () => {
const patch = '@@ -1,3 +1,5 @@\n context line one\n+function processOrder(data) {\n+ return data\n context three\n context four'
it('finds line by matching code content', () => {
expect(findLineByContent(patch, 'processOrder(data)')).toBe(2)
})
it('returns null when content not found', () => {
expect(findLineByContent(patch, 'nonexistentFunction()')).toBeNull()
})
it('returns null for short snippets', () => {
expect(findLineByContent(patch, 'data')).toBeNull()
})
it('returns null for empty inputs', () => {
expect(findLineByContent('', 'test')).toBeNull()
expect(findLineByContent(patch, '')).toBeNull()
})
})
describe('postReview', () => {
it('rejects non-numeric PR numbers', () => {
expect(() => postReview('abc', [], 'SHA')).toThrow('Invalid PR number')
})
it('posts inline comments via reviews API', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/comments --paginate')) {
return ''
}
if (typeof cmd === 'string' && cmd.includes('/reviews')) {
return '{}'
}
return ''
})
const classified = [
{ input: { path: 'src/auth.ts', line: 10, body: 'fix this' }, mode: 'inline' as const }
]
const result = postReview('1', classified, 'SHA123')
expect(result.posted).toBe(1)
expect(result.inline).toBe(1)
expect(result.failed).toBe(0)
})
it('skips duplicate comments', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/comments --paginate')) {
return '{"path":"src/auth.ts","line":10,"body":"fix this already posted"}'
}
return ''
})
const classified = [
{ input: { path: 'src/auth.ts', line: 10, body: 'fix this already posted' }, mode: 'inline' as const }
]
const result = postReview('1', classified, 'SHA123')
expect(result.skipped).toBe(1)
expect(result.posted).toBe(0)
})
it('reports empty result when no comments', () => {
vi.mocked(execSync).mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('git remote get-url')) {
return 'https://github.com/owner/repo.git'
}
if (typeof cmd === 'string' && cmd.includes('/comments --paginate')) {
return ''
}
return ''
})
const result = postReview('1', [], 'SHA')
expect(result.posted).toBe(0)
expect(result.failed).toBe(0)
expect(result.details).toHaveLength(0)
})
})