feat(ai-code-review): 建問題模式導向 issue,並整併流程調整、文件與 CI #6
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"description": "AI 多角色 code review:攻擊方找問題、防守方裁決誤報,結果留言到 PR 並保存 findings",
|
"description": "AI 多角色 code review:攻擊方找問題、防守方裁決誤報,結果留言到 PR 並保存 findings",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "ncc build src/index.js -o dist"
|
"build": "ncc build src/index.js -o dist",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"author": "Jeffery",
|
"author": "Jeffery",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const gitea = require('../src/lib/gitea');
|
||||||
|
|
||||||
|
function withFetchStub(handler, fn) {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const calls = [];
|
||||||
|
global.fetch = async (url, options = {}) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return handler(url, options);
|
||||||
|
};
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => fn(calls))
|
||||||
|
.finally(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(data, ok = true, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify(data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('addIssueDependency 使用正確 endpoint、method 與 IssueMeta body', async () => {
|
||||||
|
const ctx = {
|
||||||
|
apiBase: 'https://gitea.example.test/api/v1',
|
||||||
|
token: 'hidden',
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
};
|
||||||
|
|
||||||
|
await withFetchStub(() => jsonResponse({ ok: true }), async (calls) => {
|
||||||
|
await gitea.addIssueDependency(ctx, 12, 34);
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, 'https://gitea.example.test/api/v1/repos/owner/repo/issues/12/dependencies');
|
||||||
|
assert.equal(calls[0].options.method, 'POST');
|
||||||
|
assert.equal(calls[0].options.headers.Authorization, 'token hidden');
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
||||||
|
index: 34,
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createIssue 空 labels 不送出 labels 欄位', async () => {
|
||||||
|
const ctx = {
|
||||||
|
apiBase: 'https://gitea.example.test/api/v1',
|
||||||
|
token: 'hidden',
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
};
|
||||||
|
|
||||||
|
await withFetchStub(() => jsonResponse({ number: 5 }), async (calls) => {
|
||||||
|
await gitea.createIssue(ctx, { title: 'title', body: 'body', labels: [] });
|
||||||
|
|
||||||
|
assert.equal(calls[0].url, 'https://gitea.example.test/api/v1/repos/owner/repo/issues');
|
||||||
|
assert.equal(calls[0].options.method, 'POST');
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
||||||
|
title: 'title',
|
||||||
|
body: 'body',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const gitrepo = require('../src/lib/gitrepo');
|
||||||
|
|
||||||
|
test('assertSafeBranchRef 接受一般分支名稱', () => {
|
||||||
|
assert.equal(gitrepo.__test.assertSafeBranchRef('feature/review-123', 'baseRef'), 'feature/review-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertSafeBranchRef 拒絕路徑穿越分支名稱', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => gitrepo.__test.assertSafeBranchRef('../../hooks/pre-push', 'baseRef'),
|
||||||
|
/不是安全的分支名稱/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMergeBase 會在 git fetch 前拒絕不安全 baseRef', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => gitrepo.resolveMergeBase(process.cwd(), '../../hooks/pre-push'),
|
||||||
|
/不是安全的分支名稱/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const review = require('../src/lib/review');
|
||||||
|
|
||||||
|
test('agentFailureDetail 預設不輸出 stderr/stdout 片段', () => {
|
||||||
|
const oldDebug = process.env.ACTIONS_STEP_DEBUG;
|
||||||
|
delete process.env.ACTIONS_STEP_DEBUG;
|
||||||
|
try {
|
||||||
|
const detail = review.__test.agentFailureDetail({
|
||||||
|
ok: false,
|
||||||
|
error: Object.assign(new Error('boom'), { code: 1 }),
|
||||||
|
stderr: 'token=super-secret-value',
|
||||||
|
output: 'stdout with password=hidden',
|
||||||
|
});
|
||||||
|
assert.match(detail, /exit 1/);
|
||||||
|
assert.doesNotMatch(detail, /super-secret-value|password|stdout|stderr/);
|
||||||
|
} finally {
|
||||||
|
if (oldDebug === undefined) delete process.env.ACTIONS_STEP_DEBUG;
|
||||||
|
else process.env.ACTIONS_STEP_DEBUG = oldDebug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agentFailureDetail 在 debug 模式輸出遮罩後片段', () => {
|
||||||
|
const oldDebug = process.env.ACTIONS_STEP_DEBUG;
|
||||||
|
process.env.ACTIONS_STEP_DEBUG = 'true';
|
||||||
|
try {
|
||||||
|
const detail = review.__test.agentFailureDetail({
|
||||||
|
ok: false,
|
||||||
|
error: Object.assign(new Error('boom'), { code: 2 }),
|
||||||
|
stderr: 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890',
|
||||||
|
output: 'token=abcdefghijklmnopqrstuvwxyz1234567890TOKEN',
|
||||||
|
});
|
||||||
|
assert.match(detail, /exit 2/);
|
||||||
|
assert.match(detail, /stderr:Authorization: \*\*\*/);
|
||||||
|
assert.match(detail, /stdout:token=\*\*\*/);
|
||||||
|
assert.doesNotMatch(detail, /abcdefghijklmnopqrstuvwxyz/);
|
||||||
|
} finally {
|
||||||
|
if (oldDebug === undefined) delete process.env.ACTIONS_STEP_DEBUG;
|
||||||
|
else process.env.ACTIONS_STEP_DEBUG = oldDebug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('postOthersToIssue 批次送出 issue 留言', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
const fakeGitea = {
|
||||||
|
async createCommentOnIssue(ctx, issueNumber, body) {
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
calls.push({ ctx, issueNumber, body });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
active -= 1;
|
||||||
|
return { id: calls.length };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await review.postOthersToIssue({
|
||||||
|
ctx: { token: 'hidden' },
|
||||||
|
gitea: fakeGitea,
|
||||||
|
issueNumber: 7,
|
||||||
|
others: [
|
||||||
|
{ severity: '警告', reviewer: 'Maya', file: 'a.js', startLine: 1, endLine: 1, problem: 'p1', suggestion: 's1' },
|
||||||
|
{ severity: '建議', reviewer: 'Bard', file: 'b.js', startLine: 2, endLine: 2, problem: 'p2', suggestion: 's2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].issueNumber, 7);
|
||||||
|
assert.ok(maxActive > 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user