Compare commits

..
5 Commits
Author SHA1 Message Date
jiantw83 27ddf04ec2 fix(.gitea/workflows/ci): 修正版本輸出來源
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 13:25:21 +00:00
jiantw83 15c1228316 style(.gitea/workflows/master): 對齊工作流程註解與縮排
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 12:58:45 +00:00
jiantw83 1fd22993ab style(.gitea/workflows/ci): 對齊工作流程註解與縮排
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 12:58:07 +00:00
jiantw83 f5105d8a46 docs(README): 重建專案文件與功能索引
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 12:36:16 +00:00
jiantw83 8f909e5397 fix(審查流程): 修正 bot 跳過、JSON 驗證與排除比對邊界
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 12:18:24 +00:00
13 changed files with 1638 additions and 92 deletions
+53 -53
View File
@@ -23,39 +23,39 @@ jobs:
# 對外輸出供後續 job 使用。 # 對外輸出供後續 job 使用。
outputs: outputs:
# 輸出版本字串。 # 輸出版本字串。
version: ${{ env.VERSION }} version: ${{ steps.calculate-version.outputs.version }}
# 輸出是否為 beta。 # 輸出是否為 beta。
is_beta: ${{ env.IS_BETA }} is_beta: ${{ env.IS_BETA }}
# build job 的執行步驟。 # build job 的執行步驟。
steps: steps:
# 先依 repo 狀態計算版本號。 # 先依 repo 狀態計算版本號。
- name: Calculate Version - name: Calculate Version
# 供後續步驟讀取輸出用的 step id。 # 供後續步驟讀取輸出用的 step id。
id: calculate-version id: calculate-version
# 使用版本計算 action。 # 使用版本計算 action。
uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }} uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }}
# 傳入 action 參數。 # 傳入 action 參數。
with: with:
# 告知 action 是否為 beta 分支情境。 # 告知 action 是否為 beta 分支情境。
is_beta: ${{ env.IS_BETA }} is_beta: ${{ env.IS_BETA }}
# 依計算出的版本建立 release。 # 依計算出的版本建立 release。
- name: Publishing Release - name: Publishing Release
# 使用 release action。 # 使用 release action。
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
# 這個 step 的環境變數。 # 這個 step 的環境變數。
env: env:
# 取前一步算出的版本號。 # 取前一步算出的版本號。
VERSION: ${{ steps.calculate-version.outputs.version }} VERSION: ${{ steps.calculate-version.outputs.version }}
# release action 的參數。 # release action 的參數。
with: with:
# release 名稱。 # release 名稱。
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}" name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
# release tag 名稱。 # release tag 名稱。
tag_name: "v${{ env.VERSION }}" tag_name: "v${{ env.VERSION }}"
# 指向目前提交。 # 指向目前提交。
target_commitish: ${{ gitea.sha }} target_commitish: ${{ gitea.sha }}
# beta 情境時標記為 prerelease。 # beta 情境時標記為 prerelease。
prerelease: ${{ env.IS_BETA }} prerelease: ${{ env.IS_BETA }}
# 第二個 job:在 beta 情境執行 AI Code Review。 # 第二個 job:在 beta 情境執行 AI Code Review。
test: test:
# job 顯示名稱。 # job 顯示名稱。
@@ -72,26 +72,26 @@ jobs:
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
# test job 的步驟。 # test job 的步驟。
steps: steps:
# 安裝或設定 LLM CLI。 # 安裝或設定 LLM CLI。
- name: Setup LLM CLI - name: Setup LLM CLI
# 使用對應的 setup action。 # 使用對應的 setup action。
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
# 傳入設定。 # 傳入設定。
with: with:
# LLM CLI 的 OAuth 憑證。 # LLM CLI 的 OAuth 憑證。
oauth: ${{ secrets.LLM_OAUTH }} oauth: ${{ secrets.LLM_OAUTH }}
# 執行 AI Code Review action。 # 執行 AI Code Review action。
- name: Run AI Code Review - name: Run AI Code Review
# step id,方便追蹤。 # step id,方便追蹤。
id: ai-code-review id: ai-code-review
# 使用本 repo 發佈的 action。 # 使用本 repo 發佈的 action。
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }} uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }}
# action 參數。 # action 參數。
with: with:
# 存取 Gitea API 的 token。 # 存取 Gitea API 的 token。
token: ${{ secrets.TOKEN }} token: ${{ secrets.TOKEN }}
# 指定 LLM 模型名稱。 # 指定 LLM 模型名稱。
model: ${{ vars.LLM_NAME }} model: ${{ vars.LLM_NAME }}
# 第三個 job:輸出最終版本資訊。 # 第三個 job:輸出最終版本資訊。
result: result:
# job 顯示名稱。 # job 顯示名稱。
@@ -106,7 +106,7 @@ jobs:
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
# result job 的步驟。 # result job 的步驟。
steps: steps:
# 顯示版本號。 # 顯示版本號。
- name: Show Version - name: Show Version
# 將版本輸出到 log。 # 將版本輸出到 log。
run: echo "$VERSION" run: echo "$VERSION"
+24 -24
View File
@@ -26,27 +26,27 @@ jobs:
COMMIT_SHA: ${{ gitea.event.commits[1].id }} COMMIT_SHA: ${{ gitea.event.commits[1].id }}
# deploy job 的步驟。 # deploy job 的步驟。
steps: steps:
# 顯示 Gitea context。 # 顯示 Gitea context。
- name: Show Gitea Context - name: Show Gitea Context
# 將 context 格式化輸出。 # 將 context 格式化輸出。
run: echo "$GITEA_CONTEXT" | jq . run: echo "$GITEA_CONTEXT" | jq .
# 取回完整原始碼與 tags。 # 取回完整原始碼與 tags。
- name: Source Code Checkout - name: Source Code Checkout
# 使用 checkout action。 # 使用 checkout action。
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
# checkout 參數。 # checkout 參數。
with: with:
# 取得完整歷史。 # 取得完整歷史。
fetch-depth: 0 fetch-depth: 0
# 一併抓取 tags。 # 一併抓取 tags。
fetch-tags: true fetch-tags: true
# 查詢指定 commit 對應的 tag。 # 查詢指定 commit 對應的 tag。
- name: Get Commit Tag - name: Get Commit Tag
# 供後續步驟讀取輸出。 # 供後續步驟讀取輸出。
id: commit id: commit
# 把查到的 tag 寫入 action 輸出。 # 把查到的 tag 寫入 action 輸出。
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
# 顯示剛查到的 tag。 # 顯示剛查到的 tag。
- name: Show Tag - name: Show Tag
# 將 tag 印到 log。 # 將 tag 印到 log。
run: echo "${{ steps.commit.outputs.tag }}" run: echo "${{ steps.commit.outputs.tag }}"
+1467
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -69,7 +69,8 @@ export function parseLocation(location) {
if (trimmed.includes(',')) return null; if (trimmed.includes(',')) return null;
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/); const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
if (!match) return null; if (!match) return null;
return { file: match[1], line: Number(match[2]) }; const line = Number(match[2]);
return line > 0 ? { file: match[1], line } : null;
} }
/** 行內 comment 內容:等級/審查員/建議 */ /** 行內 comment 內容:等級/審查員/建議 */
+1 -1
View File
@@ -548,7 +548,7 @@ export function applyExclusions(findings, exclusions) {
const fPath = String(f.location).split(':')[0]; const fPath = String(f.location).split(':')[0];
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null); const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
const findingText = normalizeText(f.suggestion || f.title || ''); const findingText = normalizeText(f.suggestion || f.title || '');
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || ''); const exclusionText = normalizeText(ex.text || ex.original_finding || ex.suggestion || ex.title || ex.textKey || '');
const locationMatches = (!exPath || fPath === exPath); const locationMatches = (!exPath || fPath === exPath);
const roleMatches = (!ex.role || ex.role === f.role); const roleMatches = (!ex.role || ex.role === f.role);
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText); const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
+2 -2
View File
@@ -146,10 +146,10 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
*/ */
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
const shaMessage = await getCommitMessageBySha(sha); const shaMessage = await getCommitMessageBySha(sha);
if (sha && shaMessage.includes('[ai-review-bot]')) return true; if (sha && shaMessage.includes('[ai-review-bot]') && getBotReviewOutcome(shaMessage) !== 'failure') return true;
const branchMessage = await getBranchHeadCommitMessage(branch); const branchMessage = await getBranchHeadCommitMessage(branch);
if (branch && branchMessage.includes('[ai-review-bot]')) return true; if (branch && branchMessage.includes('[ai-review-bot]') && getBotReviewOutcome(branchMessage) !== 'failure') return true;
return false; return false;
} }
+4 -1
View File
@@ -109,7 +109,10 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
const repaired = await repairer(fullPath, label, original); const repaired = await repairer(fullPath, label, original);
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`; const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。 // 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
JSON.parse(normalized); const parsed = JSON.parse(normalized);
if (!Array.isArray(parsed)) {
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
}
fs.writeFileSync(fullPath, normalized, 'utf8'); fs.writeFileSync(fullPath, normalized, 'utf8');
ok(`${label} 已由 AI 修正並通過再次驗證`); ok(`${label} 已由 AI 修正並通過再次驗證`);
return { exists: true, valid: true, repaired: true }; return { exists: true, valid: true, repaired: true };
+20 -8
View File
@@ -78,16 +78,18 @@ export function groupConversations(comments) {
const lineNum = Number(c?.position) || Number(c?.new_position) || Number(c?.original_position) || 0; const lineNum = Number(c?.position) || Number(c?.new_position) || Number(c?.original_position) || 0;
const key = `${filePath}|${lineNum}`; const key = `${filePath}|${lineNum}`;
if (!groups.has(key)) { if (!groups.has(key)) {
groups.set(key, { key, path: filePath, line: lineNum, commentIds: [], bodies: [], resolved: false, botFinding: null }); groups.set(key, { key, path: filePath, line: lineNum, commentIds: [], bodies: [], resolved: false, botFinding: null, botFindings: [] });
} }
const g = groups.get(key); const g = groups.get(key);
if (c?.id != null) g.commentIds.push(c.id); if (c?.id != null) g.commentIds.push(c.id);
const body = typeof c?.body === 'string' ? c.body : ''; const body = typeof c?.body === 'string' ? c.body : '';
if (body) g.bodies.push(body); if (body) g.bodies.push(body);
if (c?.resolver) g.resolved = true; if (c?.resolver) g.resolved = true;
if (!g.botFinding) { const finding = parseBotReviewComment(body);
const finding = parseBotReviewComment(body); if (finding) {
if (finding) g.botFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath }; const normalizedFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath };
g.botFindings.push(normalizedFinding);
if (!g.botFinding) g.botFinding = normalizedFinding;
} }
} }
return [...groups.values()].map(g => ({ ...g, thread: g.bodies.join('\n---\n') })); return [...groups.values()].map(g => ({ ...g, thread: g.bodies.join('\n---\n') }));
@@ -146,8 +148,12 @@ export async function judgeConversations(items, chatFn = chatJSON) {
* @returns {void} * @returns {void}
*/ */
function pushCarried(target, conversation) { function pushCarried(target, conversation) {
if (!conversation.botFinding) return; const findings = conversation.botFindings?.length
target.push({ ...conversation.botFinding, is_new: false }); ? conversation.botFindings
: (conversation.botFinding ? [conversation.botFinding] : []);
for (const finding of findings) {
target.push({ ...finding, is_new: false });
}
} }
/** /**
@@ -273,10 +279,16 @@ export async function reconcileConversations(deps = {}) {
const verdict = verdictByIdx.get(i) || 'open'; const verdict = verdictByIdx.get(i) || 'open';
if (verdict === 'resolved') { if (verdict === 'resolved') {
resolvedCount += 1; resolvedCount += 1;
if (c.botFinding) resolvedFindings.push({ ...c.botFinding, is_new: false }); const findings = c.botFindings?.length ? c.botFindings : (c.botFinding ? [c.botFinding] : []);
for (const finding of findings) {
resolvedFindings.push({ ...finding, is_new: false });
}
} else if (verdict === 'false_positive') { } else if (verdict === 'false_positive') {
falsePositiveCount += 1; falsePositiveCount += 1;
if (c.botFinding) excludedFindings.push(toExclusion(c.botFinding)); const findings = c.botFindings?.length ? c.botFindings : (c.botFinding ? [c.botFinding] : []);
for (const finding of findings) {
excludedFindings.push(toExclusion(finding));
}
} else { } else {
openCount += 1; openCount += 1;
pushCarried(carriedFindings, c); pushCarried(carriedFindings, c);
+4
View File
@@ -87,6 +87,10 @@ describe('parseLocation', () => {
assert.equal(parseLocation('app/preflight.test.js'), null); assert.equal(parseLocation('app/preflight.test.js'), null);
}); });
it('returns null when the parsed line number is zero', () => {
assert.equal(parseLocation('app/preflight.js:0'), null);
});
it('returns null when multiple files are listed', () => { it('returns null when multiple files are listed', () => {
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null); assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
}); });
+15
View File
@@ -144,6 +144,21 @@ describe('findings exclusions', () => {
assert.equal(filtered[0].location, 'README.md:12'); assert.equal(filtered[0].location, 'README.md:12');
}); });
it('applies pure text exclusions using the original finding text', () => {
const findings = [
{ location: 'src/app.ts:10', role: 'Maya', suggestion: 'Update tests' },
{ location: 'src/app.ts:11', role: 'Maya', suggestion: 'Keep this' },
];
const exclusions = [
{ original_finding: 'update tests' },
];
const filtered = applyExclusions(findings, exclusions);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].suggestion, 'Keep this');
});
it('dedupes repeated exclusions when loading exclusions', () => { it('dedupes repeated exclusions when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+10 -2
View File
@@ -197,17 +197,25 @@ describe('gitea', () => {
assert.equal(await getFileContentAtRef('missing.js', 'ref'), ''); 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 a bot success commit, but not failure', 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')) {
return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } }; return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } };
} }
if (url.includes('/git/commits/sha-success')) {
return { data: { message: 'chore: update ai-review findings [ai-review-bot][success]' } };
}
if (url.includes('/branches/feat%2Ftest')) { if (url.includes('/branches/feat%2Ftest')) {
return { data: { commit: { id: 'sha-bot' } } }; return { data: { commit: { id: 'sha-bot' } } };
} }
if (url.includes('/branches/feat%2Fsuccess')) {
return { data: { commit: { id: 'sha-success' } } };
}
return { data: { message: 'regular commit' } }; return { data: { message: 'regular commit' } };
}); });
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true); await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), false);
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-success', branch: 'feat/success' }), true);
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-success', 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][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][success]'), 'success');
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown');
+12
View File
@@ -77,6 +77,18 @@ describe('json helpers', () => {
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
}); });
it('rejects repaired JSON that is not an array', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, '{broken', 'utf8');
await assert.rejects(
() => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'),
/不是 JSON 陣列/,
);
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken');
});
it('reads a valid JSON file whose size equals the maximum limit', async () => { it('reads a valid JSON file whose size equals the maximum limit', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+24
View File
@@ -83,6 +83,17 @@ describe('groupConversations', () => {
const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]); const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]);
assert.equal(convos[0].line, 7); assert.equal(convos[0].line, 7);
}); });
it('keeps multiple bot findings on the same path and line', () => {
const comments = [
{ id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 's1') },
{ id: 2, path: 'a.js', position: 10, body: reviewBody('🟡 警告', 'Mage', 'p2', 's2') },
];
const convos = groupConversations(comments);
assert.equal(convos.length, 1);
assert.equal(convos[0].botFindings.length, 2);
assert.deepEqual(convos[0].botFindings.map(f => f.role), ['Assassin', 'Mage']);
});
}); });
describe('codeWindow', () => { describe('codeWindow', () => {
@@ -207,6 +218,19 @@ describe('reconcileConversations', () => {
assert.equal(result.closedCount, 3); assert.equal(result.closedCount, 3);
}); });
it('preserves multiple bot findings when a grouped conversation is still open', async () => {
const deps = baseDeps();
deps.listComments = async () => [
{ id: 10, path: 'a.js', position: 5, body: reviewBody('🔴 嚴重', 'Assassin', 'p', 's10') },
{ id: 11, path: 'a.js', position: 5, body: reviewBody('🟡 警告', 'Mage', 'p', 's11') },
];
deps.judge = async (items) => items.map(it => ({ idx: it.idx, verdict: 'open' }));
const result = await reconcileConversations(deps);
assert.deepEqual(result.carriedFindings.map(f => f.suggestion).sort(), ['s10', 's11']);
});
it('counts only successful closes when some resolve calls fail', async () => { it('counts only successful closes when some resolve calls fail', async () => {
const deps = baseDeps(); const deps = baseDeps();
// a.js(id1) 關閉成功、b.js(id2) 關閉失敗(c.js 已 resolved 略過) // a.js(id1) 關閉成功、b.js(id2) 關閉失敗(c.js 已 resolved 略過)