test(ai-review 對話收斂): 補對話收斂與 Gitea review comment API 測試
This commit is contained in:
+64
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
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());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -134,6 +134,69 @@ describe('gitea', () => {
|
|||||||
assert.ok(message.includes('[ai-review-bot]'));
|
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 () => {
|
it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => {
|
||||||
mock.method(axios, 'get', async (url) => {
|
mock.method(axios, 'get', async (url) => {
|
||||||
if (url.includes('/git/commits/sha-bot')) {
|
if (url.includes('/git/commits/sha-bot')) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user