Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ddf04ec2 | ||
|
|
15c1228316 | ||
|
|
1fd22993ab | ||
|
|
f5105d8a46 | ||
|
|
8f909e5397 | ||
|
|
268cd05211 |
@@ -1,49 +1,112 @@
|
||||
# 用途:CI workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
|
||||
# 更新時間:2026/07/11 19:00:45
|
||||
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
||||
name: CI
|
||||
# 定義此 workflow 的觸發事件。
|
||||
on:
|
||||
# 在 pull request 事件時執行。
|
||||
pull_request:
|
||||
# 只在建立與同步更新 PR 時觸發。
|
||||
types: [opened, synchronize]
|
||||
# workflow 內的工作列表。
|
||||
jobs:
|
||||
# 第一個 job:負責版本計算與 release 發佈。
|
||||
build:
|
||||
# job 顯示名稱,用來區分執行階段。
|
||||
name: 1. BUILD
|
||||
# 使用 Ubuntu runner 執行。
|
||||
runs-on: ubuntu
|
||||
# job 層級環境變數。
|
||||
env:
|
||||
VERSION: "0.0.0-beta.${{ gitea.run_number }}"
|
||||
# 目標分支為 develop 時視為 beta。
|
||||
IS_BETA: ${{ gitea.base_ref == 'develop' }}
|
||||
# 對外輸出供後續 job 使用。
|
||||
outputs:
|
||||
version: ${{ env.VERSION }}
|
||||
# 輸出版本字串。
|
||||
version: ${{ steps.calculate-version.outputs.version }}
|
||||
# 輸出是否為 beta。
|
||||
is_beta: ${{ env.IS_BETA }}
|
||||
# build job 的執行步驟。
|
||||
steps:
|
||||
# 先依 repo 狀態計算版本號。
|
||||
- name: Calculate Version
|
||||
# 供後續步驟讀取輸出用的 step id。
|
||||
id: calculate-version
|
||||
# 使用版本計算 action。
|
||||
uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }}
|
||||
# 傳入 action 參數。
|
||||
with:
|
||||
# 告知 action 是否為 beta 分支情境。
|
||||
is_beta: ${{ env.IS_BETA }}
|
||||
# 依計算出的版本建立 release。
|
||||
- name: Publishing Release
|
||||
# 使用 release action。
|
||||
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
|
||||
# 這個 step 的環境變數。
|
||||
env:
|
||||
# 取前一步算出的版本號。
|
||||
VERSION: ${{ steps.calculate-version.outputs.version }}
|
||||
# release action 的參數。
|
||||
with:
|
||||
# release 名稱。
|
||||
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
|
||||
# release tag 名稱。
|
||||
tag_name: "v${{ env.VERSION }}"
|
||||
# 指向目前提交。
|
||||
target_commitish: ${{ gitea.sha }}
|
||||
# beta 情境時標記為 prerelease。
|
||||
prerelease: ${{ env.IS_BETA }}
|
||||
# 第二個 job:在 beta 情境執行 AI Code Review。
|
||||
test:
|
||||
# job 顯示名稱。
|
||||
name: 2. TEST
|
||||
# 使用 Ubuntu runner。
|
||||
runs-on: ubuntu
|
||||
# 必須等 build job 完成。
|
||||
needs: [build]
|
||||
# 只有 beta 情境才執行。
|
||||
if: ${{ needs.build.outputs.is_beta == 'true' }}
|
||||
# job 層級環境變數。
|
||||
env:
|
||||
# 使用 build job 輸出的版本號。
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
# test job 的步驟。
|
||||
steps:
|
||||
# 安裝或設定 LLM CLI。
|
||||
- name: Setup LLM CLI
|
||||
# 使用對應的 setup action。
|
||||
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
|
||||
# 傳入設定。
|
||||
with:
|
||||
# LLM CLI 的 OAuth 憑證。
|
||||
oauth: ${{ secrets.LLM_OAUTH }}
|
||||
# 執行 AI Code Review action。
|
||||
- name: Run AI Code Review
|
||||
# step id,方便追蹤。
|
||||
id: ai-code-review
|
||||
# 使用本 repo 發佈的 action。
|
||||
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }}
|
||||
# action 參數。
|
||||
with:
|
||||
# 存取 Gitea API 的 token。
|
||||
token: ${{ secrets.TOKEN }}
|
||||
# 指定 LLM 模型名稱。
|
||||
model: ${{ vars.LLM_NAME }}
|
||||
# 第三個 job:輸出最終版本資訊。
|
||||
result:
|
||||
# job 顯示名稱。
|
||||
name: 3. RESULT
|
||||
# 使用 Ubuntu runner。
|
||||
runs-on: ubuntu
|
||||
# 需等待前兩個 job。
|
||||
needs: [build,test]
|
||||
# job 層級環境變數。
|
||||
env:
|
||||
# 延續 build 的版本號。
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
# result job 的步驟。
|
||||
steps:
|
||||
# 顯示版本號。
|
||||
- name: Show Version
|
||||
# 將版本輸出到 log。
|
||||
run: echo "$VERSION"
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
# 用途:master workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
|
||||
# 更新時間:2026/07/11 19:00:45
|
||||
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
||||
name: CD
|
||||
# 定義此 workflow 的觸發事件。
|
||||
on:
|
||||
# 在 push 事件時執行。
|
||||
push:
|
||||
# 限定觸發分支。
|
||||
branches:
|
||||
# 只有推送到 master 分支才執行。
|
||||
- master
|
||||
# workflow 內的工作列表。
|
||||
jobs:
|
||||
# 單一 job:輸出 context、檢查 commit tag。
|
||||
deploy:
|
||||
# job 顯示名稱。
|
||||
name: DEPLOY
|
||||
# 使用 Ubuntu runner。
|
||||
runs-on: ubuntu
|
||||
# job 層級環境變數。
|
||||
env:
|
||||
# 將完整 Gitea context 轉成 JSON 字串。
|
||||
GITEA_CONTEXT: ${{ toJSON(gitea) }}
|
||||
# 取第二筆 commit 的 id 作為查詢目標。
|
||||
COMMIT_SHA: ${{ gitea.event.commits[1].id }}
|
||||
# deploy job 的步驟。
|
||||
steps:
|
||||
# 顯示 Gitea context。
|
||||
- name: Show Gitea Context
|
||||
# 將 context 格式化輸出。
|
||||
run: echo "$GITEA_CONTEXT" | jq .
|
||||
# 取回完整原始碼與 tags。
|
||||
- name: Source Code Checkout
|
||||
# 使用 checkout action。
|
||||
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
|
||||
# checkout 參數。
|
||||
with:
|
||||
# 取得完整歷史。
|
||||
fetch-depth: 0
|
||||
# 一併抓取 tags。
|
||||
fetch-tags: true
|
||||
# 查詢指定 commit 對應的 tag。
|
||||
- name: Get Commit Tag
|
||||
# 供後續步驟讀取輸出。
|
||||
id: commit
|
||||
# 把查到的 tag 寫入 action 輸出。
|
||||
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
|
||||
# 顯示剛查到的 tag。
|
||||
- name: Show Tag
|
||||
# 將 tag 印到 log。
|
||||
run: echo "${{ steps.commit.outputs.tag }}"
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
# GITEA NODE ACTION 的工作流列表
|
||||
# GITEA NODE ACTION 工作流說明草稿
|
||||
|
||||
- CI
|
||||
- BUILD
|
||||
- TEST
|
||||
- RESULT
|
||||
- CD
|
||||
- BUILD
|
||||
- DEPLOY
|
||||
更新時間:2026/07/11 18:54:51
|
||||
|
||||
## 總覽
|
||||
|
||||
此專案目前包含兩個 workflow:
|
||||
|
||||
- `CI`:處理 pull request 期間的版本計算、釋出與 AI 程式碼審查。
|
||||
- `CD`:處理推送到 `master` 分支後的部署相關檢查與資訊輸出。
|
||||
|
||||
## Workflow 明細
|
||||
|
||||
### CI
|
||||
|
||||
- 檔案位置:`.gitea/workflows/ci.yaml`
|
||||
- 用途:在 pull request 事件中計算版本,必要時建立 release,並在 beta 情境下執行 AI 程式碼審查。
|
||||
- 觸發條件:`pull_request`,事件類型為 `opened` 與 `synchronize`。
|
||||
- 主要輸入 / 環境參數:
|
||||
- `gitea.base_ref`:用來判斷是否為 `develop`,進而決定 `IS_BETA`。
|
||||
- `vars.ACTION_CALCULATE_VERSION`:提供 `calculate-version` action 的版本。
|
||||
- `vars.ACTION_GITEA_RELEASE_VERSION`:提供 release action 的版本。
|
||||
- `secrets.LLM_OAUTH`:設定 LLM CLI 的 OAuth。
|
||||
- `secrets.TOKEN`:提供 AI 程式碼審查 action 存取 Gitea API。
|
||||
- `vars.LLM_NAME`:指定審查使用的模型名稱。
|
||||
- 重要注意事項:
|
||||
- `test` job 只會在 `IS_BETA == true` 時執行,也就是 pull request 目標分支為 `develop` 時。
|
||||
- `Publishing Release` 會使用 `VERSION` 與 `gitea.sha` 建立 release 與 tag。
|
||||
- 若變數或 secret 未設定,對應步驟會失敗,需人工確認部署前置條件。
|
||||
|
||||
### CD
|
||||
|
||||
- 檔案位置:`.gitea/workflows/master.yaml`
|
||||
- 用途:在 `master` 分支推送後輸出 Gitea context、檢查提交標籤,作為後續部署流程的基礎。
|
||||
- 觸發條件:`push` 到 `master` 分支。
|
||||
- 主要輸入 / 環境參數:
|
||||
- `gitea` 事件內容:轉成 `GITEA_CONTEXT` 後交給 `jq` 顯示。
|
||||
- `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢 commit tag。
|
||||
- `vars.ACTION_CHECKOUT_VERSION`:提供 `actions/checkout` 的版本。
|
||||
- `GITEA_OUTPUT`:寫入 `git describe --contains` 的結果。
|
||||
- 重要注意事項:
|
||||
- `COMMIT_SHA` 取用 commits 陣列的第 2 筆資料,若 push 事件實際只有 1 筆 commit,需人工確認是否會發生索引風險。
|
||||
- `Get Commit Tag` 依賴完整的 git 歷史與 tags,因此 checkout 已設定 `fetch-depth: 0` 與 `fetch-tags: true`。
|
||||
- `Show Gitea Context` 會輸出完整事件內容,若包含敏感資訊,需注意執行環境的日誌保存策略。
|
||||
|
||||
## 備註
|
||||
|
||||
- 本檔為草稿版本,僅整理 workflow 行為與設定重點,不修改任何 workflow 實際邏輯。
|
||||
- 若後續要覆蓋正式檔,請先確認 `ci.yaml` 與 `master.yaml` 的變數與 secret 已在目標環境中正確配置。
|
||||
|
||||
+18
@@ -1,16 +1,34 @@
|
||||
# 用途:AI Code Review Action 的設定檔草稿,保留原始輸入與 Node 入口,並補上逐行說明。
|
||||
# 更新時間:2026/07/11 18:54:51
|
||||
# Action 顯示名稱,讓工作流程與使用者介面可以辨識此動作。
|
||||
name: 'AI Code Review'
|
||||
# Action 的簡短用途說明。
|
||||
description: 'AI 程式碼審查'
|
||||
# 作者或維護者名稱。
|
||||
author: 'Jeffery'
|
||||
# 定義此 Action 對外提供的輸入參數。
|
||||
inputs:
|
||||
# 用於存取 Gitea API 的授權 Token。
|
||||
token:
|
||||
# token 參數的用途說明。
|
||||
description: '操作 Gitea API 的 Token'
|
||||
# 此參數為必要,執行時必須提供。
|
||||
required: true
|
||||
# 用於存取 Gitea Commit API 的授權 Token。
|
||||
comment_token:
|
||||
# comment_token 參數的用途說明。
|
||||
description: '操作 Gitea Commit API 的 Token'
|
||||
# 此參數為選填,沒有提供時不影響 Action 啟動。
|
||||
required: false
|
||||
# 指定執行 AI 程式碼審查時所使用的模型名稱。
|
||||
model:
|
||||
# model 參數的用途說明。
|
||||
description: '執行 AI 程式碼審查使用的 LLM 名稱'
|
||||
# 此參數為選填,未提供時由執行環境或預設值決定。
|
||||
required: false
|
||||
# 宣告 Action 的執行方式與主要進入點。
|
||||
runs:
|
||||
# 使用 Node.js 24 執行此 Action。
|
||||
using: 'node24'
|
||||
# Action 的主要進入檔,實際邏輯從此檔案開始。
|
||||
main: 'src/main.js'
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ export function parseLocation(location) {
|
||||
if (trimmed.includes(',')) return null;
|
||||
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
|
||||
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 內容:等級/審查員/建議 */
|
||||
|
||||
@@ -51,6 +51,12 @@ export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
||||
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
|
||||
*/
|
||||
let _insecureHttpsAgent = null;
|
||||
/**
|
||||
* 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。
|
||||
*
|
||||
* @remarks 只應在信任的內網或測試環境使用;若需要完整 TLS 安全性,應改用預設
|
||||
* `https.Agent`,不要調用這個函式。
|
||||
*/
|
||||
export function getInsecureHttpsAgent() {
|
||||
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
|
||||
}
|
||||
@@ -86,10 +92,22 @@ const CLI_CANDIDATES = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得目前支援的 LLM CLI 指令名稱清單。
|
||||
*
|
||||
* @remarks 內容直接取自 `CLI_CANDIDATES`,若日後候選清單增減,輸出會同步變動。
|
||||
*/
|
||||
export function getLLMCLICommands() {
|
||||
return CLI_CANDIDATES.map(c => c.command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查指定 CLI 指令是否可在目前環境中執行。
|
||||
*
|
||||
* @param {string} command - 要檢查的指令名稱。
|
||||
* @returns {boolean} 找得到指令時回傳 `true`,否則回傳 `false`。
|
||||
* @remarks 透過 `/bin/sh -lc "command -v <command>"` 檢查,屬於同步存在性檢查。
|
||||
*/
|
||||
function commandExists(command) {
|
||||
try {
|
||||
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
|
||||
|
||||
+7
-1
@@ -111,6 +111,12 @@ function cleanText(value) {
|
||||
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
|
||||
*/
|
||||
const _normalizeTextCache = new Map();
|
||||
/**
|
||||
* 將文字正規化成比對用形式。
|
||||
*
|
||||
* @param {*} value - 任意值。
|
||||
* @remarks 適合用於誤報過濾與排除條目比對。
|
||||
*/
|
||||
export function normalizeText(value) {
|
||||
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
|
||||
const result = cleanText(value)
|
||||
@@ -542,7 +548,7 @@ export function applyExclusions(findings, exclusions) {
|
||||
const fPath = String(f.location).split(':')[0];
|
||||
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
|
||||
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 roleMatches = (!ex.role || ex.role === f.role);
|
||||
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
|
||||
|
||||
+2
-2
@@ -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 } = {}) {
|
||||
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);
|
||||
if (branch && branchMessage.includes('[ai-review-bot]')) return true;
|
||||
if (branch && branchMessage.includes('[ai-review-bot]') && getBotReviewOutcome(branchMessage) !== 'failure') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
||||
const repaired = await repairer(fullPath, label, original);
|
||||
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
||||
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
||||
JSON.parse(normalized);
|
||||
const parsed = JSON.parse(normalized);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
||||
}
|
||||
fs.writeFileSync(fullPath, normalized, 'utf8');
|
||||
ok(`${label} 已由 AI 修正並通過再次驗證`);
|
||||
return { exists: true, valid: true, repaired: true };
|
||||
|
||||
+28
-1
@@ -56,6 +56,16 @@ function buildPrompt(systemPrompt, userContent) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 依不同 AI provider 產生 CLI 參數。
|
||||
*
|
||||
* @param {*} provider - AI provider 名稱。
|
||||
* @param {*} model - 模型名稱。
|
||||
* @param {*} promptFile - prompt 檔路徑,供 `opencode` 使用。
|
||||
* @param {*} prompt - 直接傳給 CLI 的 prompt 文字,供部分 provider 使用。
|
||||
* @remarks 適合把不同 CLI 的參數差異集中管理。
|
||||
* @remarks 目前支援的 provider 名稱是硬編碼的,新增 provider 時需人工確認是否同步更新所有呼叫端。
|
||||
*/
|
||||
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
|
||||
if (provider === 'codex') {
|
||||
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
|
||||
@@ -92,12 +102,30 @@ export function extractMeaningfulError(raw, limit = 1000) {
|
||||
return picked.length > limit ? picked.slice(-limit) : picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 CLI 例外整理成較精簡的錯誤摘要。
|
||||
*
|
||||
* @param {*} e - 被拋出的錯誤物件,可能含 `stderr`、`stdout`、`message`。
|
||||
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
|
||||
* @remarks 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。
|
||||
*/
|
||||
function summarizeCliError(e) {
|
||||
const stderr = String(e.stderr || '').trim();
|
||||
const stdout = String(e.stdout || '').trim();
|
||||
return extractMeaningfulError(stderr || stdout || e.message || String(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 AI 助理 CLI 並回傳純文字結果。
|
||||
*
|
||||
* @param {*} provider - CLI provider 名稱。
|
||||
* @param {*} command - 實際可執行指令。
|
||||
* @param {*} model - 要使用的模型名稱。
|
||||
* @param {*} prompt - 送給 CLI 的完整 prompt 內容。
|
||||
* @remarks 適合用在需呼叫外部 AI CLI 的情境。
|
||||
* @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。
|
||||
* @remarks 若子行程回傳非 0,錯誤訊息會由上層摘要處理。
|
||||
*/
|
||||
async function runAssistantCLI({ provider, command, model }, prompt) {
|
||||
let tempDir = null;
|
||||
let promptFile = null;
|
||||
@@ -120,7 +148,6 @@ async function runAssistantCLI({ provider, command, model }, prompt) {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
|
||||
}, timeout);
|
||||
|
||||
const append = (kind, chunk) => {
|
||||
if (kind === 'stdout') stdout += chunk;
|
||||
else stderr += chunk;
|
||||
|
||||
+19
-7
@@ -78,16 +78,18 @@ export function groupConversations(comments) {
|
||||
const lineNum = Number(c?.position) || Number(c?.new_position) || Number(c?.original_position) || 0;
|
||||
const key = `${filePath}|${lineNum}`;
|
||||
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);
|
||||
if (c?.id != null) g.commentIds.push(c.id);
|
||||
const body = typeof c?.body === 'string' ? c.body : '';
|
||||
if (body) g.bodies.push(body);
|
||||
if (c?.resolver) g.resolved = true;
|
||||
if (!g.botFinding) {
|
||||
const finding = parseBotReviewComment(body);
|
||||
if (finding) g.botFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath };
|
||||
if (finding) {
|
||||
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') }));
|
||||
@@ -146,8 +148,12 @@ export async function judgeConversations(items, chatFn = chatJSON) {
|
||||
* @returns {void}
|
||||
*/
|
||||
function pushCarried(target, conversation) {
|
||||
if (!conversation.botFinding) return;
|
||||
target.push({ ...conversation.botFinding, is_new: false });
|
||||
const findings = conversation.botFindings?.length
|
||||
? 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';
|
||||
if (verdict === 'resolved') {
|
||||
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') {
|
||||
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 {
|
||||
openCount += 1;
|
||||
pushCarried(carriedFindings, c);
|
||||
|
||||
@@ -87,6 +87,10 @@ describe('parseLocation', () => {
|
||||
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', () => {
|
||||
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
|
||||
});
|
||||
|
||||
@@ -144,6 +144,21 @@ describe('findings exclusions', () => {
|
||||
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', () => {
|
||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
+10
-2
@@ -197,17 +197,25 @@ describe('gitea', () => {
|
||||
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) => {
|
||||
if (url.includes('/git/commits/sha-bot')) {
|
||||
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')) {
|
||||
return { data: { commit: { id: 'sha-bot' } } };
|
||||
}
|
||||
if (url.includes('/branches/feat%2Fsuccess')) {
|
||||
return { data: { commit: { id: 'sha-success' } } };
|
||||
}
|
||||
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][success]'), 'success');
|
||||
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown');
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('json helpers', () => {
|
||||
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 () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
@@ -83,6 +83,17 @@ describe('groupConversations', () => {
|
||||
const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]);
|
||||
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', () => {
|
||||
@@ -207,6 +218,19 @@ describe('reconcileConversations', () => {
|
||||
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 () => {
|
||||
const deps = baseDeps();
|
||||
// a.js(id1) 關閉成功、b.js(id2) 關閉失敗(c.js 已 resolved 略過)
|
||||
|
||||
Reference in New Issue
Block a user