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, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from './gitea.js'; afterEach(() => mock.restoreAll()); describe('gitea', () => { it('getPRDiff calls Gitea diff API with Authorization header', async () => { let capturedUrl, capturedOpts; mock.method(axios, 'get', async (url, opts) => { capturedUrl = url; capturedOpts = opts; return { data: 'diff content' }; }); const result = await getPRDiff(); assert.equal(result, 'diff content'); assert.ok(capturedUrl.includes('/api/v1/repos/')); assert.ok(capturedUrl.endsWith('.diff')); assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); assert.equal(capturedOpts.headers['Content-Type'], 'application/json'); }); it('postComment calls Gitea issues comments API with body', async () => { let capturedUrl, capturedBody, capturedOpts; mock.method(axios, 'post', async (url, body, opts) => { capturedUrl = url; capturedBody = body; capturedOpts = opts; return { data: { id: 1 } }; }); const result = await postComment('hello world'); assert.deepEqual(result, { id: 1 }); assert.ok(capturedUrl.includes('/api/v1/repos/')); assert.ok(capturedUrl.endsWith('/comments')); assert.equal(capturedBody.body, 'hello world'); assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); }); it('sets an insecure httpsAgent by default', async () => { let capturedOpts; mock.method(axios, 'get', async (_url, opts) => { capturedOpts = opts; return { data: '' }; }); await getPRDiff(); assert.equal(capturedOpts.httpsAgent.options.rejectUnauthorized, false); }); it('getPRDiff propagates axios errors', async () => { mock.method(axios, 'get', async () => { throw new Error('network error'); }); await assert.rejects(() => getPRDiff(), /network error/); }); it('postComment propagates axios errors', async () => { mock.method(axios, 'post', async () => { throw new Error('api error'); }); await assert.rejects(() => postComment('test'), /api error/); }); it('postPullReviewComment posts an inline review comment to the pulls reviews API', async () => { let capturedUrl, capturedBody, capturedOpts; mock.method(axios, 'post', async (url, body, opts) => { capturedUrl = url; capturedBody = body; capturedOpts = opts; return { data: { id: 7 } }; }); const result = await postPullReviewComment({ path: 'app/preflight.js', line: 19, body: 'inline body' }); assert.deepEqual(result, { id: 7 }); assert.ok(capturedUrl.includes('/api/v1/repos/')); assert.ok(capturedUrl.endsWith('/reviews')); assert.equal(capturedBody.event, 'COMMENT'); assert.equal(capturedBody.comments.length, 1); assert.equal(capturedBody.comments[0].path, 'app/preflight.js'); assert.equal(capturedBody.comments[0].new_position, 19); assert.equal(capturedBody.comments[0].body, 'inline body'); assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); }); it('postPullReviewComment propagates axios errors', async () => { mock.method(axios, 'post', async () => { throw new Error('not in diff'); }); await assert.rejects(() => postPullReviewComment({ path: 'a.js', line: 1, body: 'x' }), /not in diff/); }); it('postPullReview posts one review with multiple comments', async () => { let capturedUrl, capturedBody, capturedOpts; mock.method(axios, 'post', async (url, body, opts) => { capturedUrl = url; capturedBody = body; capturedOpts = opts; return { data: { id: 9 } }; }); const result = await postPullReview({ body: 'summary', comments: [{ path: 'app/a.js', new_position: 10, body: 'comment' }], }); assert.deepEqual(result, { id: 9 }); assert.ok(capturedUrl.includes('/api/v1/repos/')); assert.ok(capturedUrl.endsWith('/reviews')); assert.equal(capturedBody.event, 'COMMENT'); assert.equal(capturedBody.body, 'summary'); assert.equal(capturedBody.comments.length, 1); assert.equal(capturedBody.comments[0].path, 'app/a.js'); assert.equal(capturedBody.comments[0].new_position, 10); assert.equal(capturedBody.comments[0].body, 'comment'); assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); }); it('getCommitMessageBySha reads commit message from Gitea API', async () => { let capturedUrl; mock.method(axios, 'get', async (url) => { capturedUrl = url; return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } }; }); const message = await getCommitMessageBySha('abc123'); assert.ok(capturedUrl.includes('/git/commits/abc123')); assert.ok(message.includes('[ai-review-bot]')); }); it('getBranchHeadCommitMessage reads branch head commit message from Gitea API', async () => { const urls = []; mock.method(axios, 'get', async (url) => { urls.push(url); if (url.includes('/branches/feat%2Ftest')) { return { data: { commit: { id: 'abc123' } } }; } return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } }; }); const message = await getBranchHeadCommitMessage('feat/test'); assert.ok(urls.some(url => url.includes('/branches/feat%2Ftest'))); assert.ok(urls.some(url => url.includes('/git/commits/abc123'))); 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')) { return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } }; } if (url.includes('/branches/feat%2Ftest')) { return { data: { commit: { id: 'sha-bot' } } }; } return { data: { message: 'regular commit' } }; }); await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'), 'failure'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][success]'), 'success'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown'); }); }); describe('filterDiff', () => { const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`; it('filters out configured folder blocks', () => { const diff = block('.gitea/workflows/review.yaml') + block('.github/workflows/review.yaml') + block('src/index.js'); const result = filterDiff(diff, ['.gitea/', '.github/']); assert.ok(!result.includes('.gitea/')); assert.ok(!result.includes('.github/')); assert.ok(result.includes('src/index.js')); }); it('filters out configured top-level file blocks', () => { const diff = block('README.md') + block('src/index.js'); const result = filterDiff(diff, ['README.md', 'TODO.md']); assert.ok(!result.includes('README.md')); assert.ok(result.includes('src/index.js')); }); it('returns empty string when all blocks are excluded', () => { const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json'); const result = filterDiff(diff, ['.gitea/']); assert.equal(result, ''); }); it('returns empty string for empty diff', () => { assert.equal(filterDiff('', ['.gitea/']), ''); }); });