diff --git a/app/gitea.test.js b/app/gitea.test.js index de76d51..46b67cd 100644 --- a/app/gitea.test.js +++ b/app/gitea.test.js @@ -1,7 +1,7 @@ import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; -import { getPRDiff, filterDiff, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -134,6 +134,69 @@ describe('gitea', () => { assert.ok(message.includes('[ai-review-bot]')); }); + it('listPullReviews returns review array from the pulls reviews API', async () => { + let capturedUrl; + mock.method(axios, 'get', async (url) => { + capturedUrl = url; + return { data: [{ id: 1 }, { id: 2 }] }; + }); + const reviews = await listPullReviews(); + assert.equal(reviews.length, 2); + assert.ok(capturedUrl.endsWith('/reviews')); + }); + + it('getPullReviewComments fetches comments of a specific review', async () => { + let capturedUrl; + mock.method(axios, 'get', async (url) => { + capturedUrl = url; + return { data: [{ id: 11, body: 'x' }] }; + }); + const comments = await getPullReviewComments(7); + assert.equal(comments.length, 1); + assert.ok(capturedUrl.includes('/reviews/7/comments')); + }); + + it('listAllReviewComments flattens comments across reviews and skips failing ones', async () => { + mock.method(axios, 'get', async (url) => { + if (url.endsWith('/reviews')) return { data: [{ id: 1 }, { id: 2 }] }; + if (url.includes('/reviews/1/comments')) return { data: [{ id: 11 }, { id: 12 }] }; + throw new Error('boom'); + }); + const comments = await listAllReviewComments(); + assert.equal(comments.length, 2); + assert.deepEqual(comments.map(c => c.id), [11, 12]); + }); + + it('resolvePullReviewComment posts to the official resolve endpoint', async () => { + let capturedUrl, capturedOpts; + mock.method(axios, 'post', async (url, _body, opts) => { + capturedUrl = url; + capturedOpts = opts; + return { data: { ok: true } }; + }); + await resolvePullReviewComment(42); + assert.ok(capturedUrl.endsWith('/pulls/comments/42/resolve')); + assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); + }); + + it('getFileContentAtRef decodes base64 file content and passes ref param', async () => { + let capturedUrl, capturedOpts; + mock.method(axios, 'get', async (url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return { data: { content: Buffer.from('hello\nworld', 'utf8').toString('base64'), encoding: 'base64' } }; + }); + const content = await getFileContentAtRef('app/x.js', 'abc123'); + assert.equal(content, 'hello\nworld'); + assert.ok(capturedUrl.includes('/contents/app/x.js')); + assert.equal(capturedOpts.params.ref, 'abc123'); + }); + + it('getFileContentAtRef returns empty string on error', async () => { + mock.method(axios, 'get', async () => { throw new Error('404'); }); + assert.equal(await getFileContentAtRef('missing.js', 'ref'), ''); + }); + it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => { mock.method(axios, 'get', async (url) => { if (url.includes('/git/commits/sha-bot')) { diff --git a/app/resolve.test.js b/app/resolve.test.js new file mode 100644 index 0000000..e9a1d19 --- /dev/null +++ b/app/resolve.test.js @@ -0,0 +1,185 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseBotReviewComment, + groupConversations, + codeWindow, + judgeConversationsResolved, + reconcileConversations, + dropResolvedFindings, + addCarriedFindings, +} from './resolve.js'; + +const reviewBody = (level, role, problem, suggestion) => + `**嚴重等級**:${level}\n**審查員**:${role}\n**問題**:${problem}\n**建議**:${suggestion}`; + +describe('parseBotReviewComment', () => { + it('parses a standard review comment back into a finding', () => { + const f = parseBotReviewComment(reviewBody('🔴 嚴重', 'Assassin', '可能空指標', '加上 null 檢查')); + assert.deepEqual(f, { level: 'critical', role: 'Assassin', problem: '可能空指標', suggestion: '加上 null 檢查' }); + }); + + it('maps 警告/建議 labels to warning/info', () => { + assert.equal(parseBotReviewComment(reviewBody('🟡 警告', 'Mage', 'p', 's')).level, 'warning'); + assert.equal(parseBotReviewComment(reviewBody('🔵 建議', 'Bard', 'p', 's')).level, 'info'); + }); + + it('parses inline critical comment format (等級/審查員/建議, no 問題)', () => { + const body = '**等級**:🔴 嚴重\n**審查員**:Rogue\n**建議**:移除硬編碼密鑰'; + const f = parseBotReviewComment(body); + assert.equal(f.level, 'critical'); + assert.equal(f.role, 'Rogue'); + assert.equal(f.suggestion, '移除硬編碼密鑰'); + }); + + it('returns null for free-form human comments', () => { + assert.equal(parseBotReviewComment('我覺得這段可以再想想'), null); + assert.equal(parseBotReviewComment(''), null); + assert.equal(parseBotReviewComment(null), null); + }); +}); + +describe('groupConversations', () => { + it('groups by path+line, detects resolved, and extracts bot finding', () => { + const comments = [ + { id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 's1') }, + { id: 2, path: 'a.js', position: 10, body: '補充:請看這裡', resolver: { login: 'dev' } }, + { id: 3, path: 'b.js', position: 5, body: reviewBody('🟡 警告', 'Mage', 'p2', 's2') }, + ]; + const convos = groupConversations(comments); + assert.equal(convos.length, 2); + const a = convos.find(c => c.path === 'a.js'); + assert.equal(a.resolved, true); + assert.deepEqual(a.commentIds, [1, 2]); + assert.equal(a.botFinding.level, 'critical'); + assert.equal(a.botFinding.location, 'a.js:10'); + const b = convos.find(c => c.path === 'b.js'); + assert.equal(b.resolved, false); + assert.equal(b.botFinding.suggestion, 's2'); + }); + + it('falls back to original_position when position is missing', () => { + const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]); + assert.equal(convos[0].line, 7); + }); +}); + +describe('codeWindow', () => { + it('returns a numbered window around the target line', () => { + const content = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n'); + const win = codeWindow(content, 25, 2); + assert.equal(win, '23: line23\n24: line24\n25: line25\n26: line26\n27: line27'); + }); + + it('returns empty string for empty content', () => { + assert.equal(codeWindow('', 10), ''); + }); +}); + +describe('judgeConversationsResolved', () => { + it('aligns verdicts by idx and defaults missing/non-true to false', async () => { + const items = [{ idx: 0 }, { idx: 1 }, { idx: 2 }]; + const chatFn = async () => [{ idx: 0, resolved: true }, { idx: 1, resolved: false }]; + const verdicts = await judgeConversationsResolved(items, chatFn); + assert.deepEqual(verdicts, [ + { idx: 0, resolved: true }, + { idx: 1, resolved: false }, + { idx: 2, resolved: false }, + ]); + }); + + it('treats non-array AI output as all unresolved', async () => { + const verdicts = await judgeConversationsResolved([{ idx: 0 }], async () => ({})); + assert.deepEqual(verdicts, [{ idx: 0, resolved: false }]); + }); + + it('returns [] for no items', async () => { + assert.deepEqual(await judgeConversationsResolved([]), []); + }); +}); + +describe('reconcileConversations', () => { + const baseDeps = () => ({ + listComments: async () => [ + { id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 'fix one') }, + { id: 2, path: 'b.js', position: 20, body: reviewBody('🟡 警告', 'Mage', 'p2', 'fix two') }, + { id: 3, path: 'c.js', position: 30, body: reviewBody('🔵 建議', 'Bard', 'p3', 'fix three'), resolver: { login: 'dev' } }, + ], + getFileContent: async () => 'some code', + judge: async (items) => items.map(it => ({ idx: it.idx, resolved: it.path === 'a.js' })), + resolveComment: async () => ({ ok: true }), + }); + + it('resolves AI-confirmed conversations and carries unresolved ones back', async () => { + const resolvedIds = []; + const deps = baseDeps(); + deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; }; + + const result = await reconcileConversations(deps); + + assert.deepEqual(resolvedIds, [1]); // a.js resolved, c.js already resolved (skipped) + assert.equal(result.resolvedCount, 1); + assert.equal(result.unresolvedCount, 1); // only b.js (c.js was skipped as already resolved) + assert.deepEqual(result.resolvedFindings.map(f => f.location), ['a.js:10']); + assert.deepEqual(result.carriedFindings.map(f => f.suggestion), ['fix two']); + }); + + it('carries the conversation back when the resolve API call fails', async () => { + const deps = baseDeps(); + deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: true })); + deps.resolveComment = async () => { throw new Error('403'); }; + + const result = await reconcileConversations(deps); + assert.equal(result.resolvedCount, 0); + assert.equal(result.carriedFindings.length, 2); + }); + + it('returns empty result and does not throw when listing comments fails', async () => { + const result = await reconcileConversations({ listComments: async () => { throw new Error('boom'); } }); + assert.deepEqual(result, { resolvedFindings: [], carriedFindings: [], resolvedCount: 0, unresolvedCount: 0 }); + }); + + it('returns empty result when there are no open conversations', async () => { + const result = await reconcileConversations({ + listComments: async () => [{ id: 1, path: 'a.js', position: 1, body: 'x', resolver: { login: 'd' } }], + }); + assert.equal(result.resolvedCount, 0); + assert.equal(result.carriedFindings.length, 0); + }); +}); + +describe('dropResolvedFindings', () => { + it('removes findings matching resolved ones by file + suggestion, ignoring line drift', () => { + const findings = [ + { role: 'Assassin', location: 'a.js:19', suggestion: '加上 null 檢查' }, + { role: 'Mage', location: 'b.js:5', suggestion: '保留這個' }, + ]; + const resolved = [{ location: 'a.js:42', suggestion: '加上 null 檢查!' }]; + const result = dropResolvedFindings(findings, resolved); + assert.equal(result.length, 1); + assert.equal(result[0].location, 'b.js:5'); + }); + + it('returns input unchanged when no resolved findings', () => { + const findings = [{ location: 'a.js:1', suggestion: 's' }]; + assert.equal(dropResolvedFindings(findings, []), findings); + }); +}); + +describe('addCarriedFindings', () => { + it('adds carried findings missing from the list, deduping by file + suggestion', () => { + const findings = [{ role: 'Mage', location: 'b.js:5', suggestion: '保留' }]; + const carried = [ + { role: 'Mage', location: 'b.js:9', suggestion: '保留', is_new: false }, // dup -> skip + { role: 'Assassin', location: 'a.js:10', suggestion: '加回我', is_new: false }, // new -> add + ]; + const result = addCarriedFindings(findings, carried); + assert.equal(result.length, 2); + assert.equal(result[1].suggestion, '加回我'); + }); + + it('returns input unchanged when no carried findings', () => { + const findings = [{ location: 'a.js:1', suggestion: 's' }]; + assert.equal(addCarriedFindings(findings, []), findings); + }); +});