fix(review-resolve): 支援 wrapper findings 並修正輸出格式
This commit is contained in:
+2
-2
@@ -9,11 +9,11 @@ inputs:
|
|||||||
description: '操作 Gitea Commit API 的 Token'
|
description: '操作 Gitea Commit API 的 Token'
|
||||||
required: false
|
required: false
|
||||||
model:
|
model:
|
||||||
description: '使用的 AI 模型,僅允許英數字、點、底線與連字號'
|
description: '使用的 AI 模型,僅允許英數字、點、底線、連字號與斜線'
|
||||||
required: false
|
required: false
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'dockerfile'
|
image: 'Dockerfile'
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }}
|
GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }}
|
||||||
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
|
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
|
||||||
|
|||||||
+18
-25
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
import { buildFindingsWrapper } from './json.js';
|
||||||
import { ok, line, warn } from './log.js';
|
import { ok, line, warn } from './log.js';
|
||||||
|
|
||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
@@ -35,7 +36,8 @@ function findingRow(f) {
|
|||||||
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
|
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
|
||||||
*/
|
*/
|
||||||
function buildTable(findings) {
|
function buildTable(findings) {
|
||||||
const rows = findings.map(findingRow).join('\n');
|
const list = Array.isArray(findings) ? findings : [];
|
||||||
|
const rows = list.map(findingRow).join('\n');
|
||||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ export function parseLocation(location) {
|
|||||||
* `location` 能被解析出具體行號時。
|
* `location` 能被解析出具體行號時。
|
||||||
*/
|
*/
|
||||||
function inlineCommentBody(f) {
|
function inlineCommentBody(f) {
|
||||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
return `**等級**:${levelText(f)}\n**審查員**:${f?.role || 'AI Review'}\n**建議**:${f?.suggestion || ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,9 +125,9 @@ function problemText(f) {
|
|||||||
function reviewCommentBody(f) {
|
function reviewCommentBody(f) {
|
||||||
return [
|
return [
|
||||||
`**嚴重等級**:${levelText(f)}`,
|
`**嚴重等級**:${levelText(f)}`,
|
||||||
`**審查員**:${f.role}`,
|
`**審查員**:${f?.role || 'AI Review'}`,
|
||||||
`**問題**:${problemText(f)}`,
|
`**問題**:${problemText(f)}`,
|
||||||
`**建議**:${f.suggestion}`,
|
`**建議**:${f?.suggestion || ''}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,15 +237,10 @@ function toReviewComment(f) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 發布單一 Gitea review,必要時會先降級成 summary review,再降級成一般 comment。
|
* 發布單一 Gitea review,必要時降級成 summary review 或一般 comment。
|
||||||
|
*
|
||||||
* @param {Array<object>} findings 審查 findings。
|
* @param {Array<object>} findings 審查 findings。
|
||||||
* @param {object} [deps={}] 可注入的相依物件。
|
* @param {object} [deps={}] 可注入的相依物件。
|
||||||
* @param {Function} [deps.postReview=postPullReview] 發布整批 review 的函式。
|
|
||||||
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 comment 的函式。
|
|
||||||
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
|
||||||
* @param {Array<object>} [deps.summaryFindings=findings] 用於統計的 findings 子集合。
|
|
||||||
* @param {Array<object>} [deps.commentFindings=findings] 用於建立 review comments 的 findings 子集合。
|
|
||||||
* @param {string} [deps.usageSection=''] 附加的使用量區塊。
|
|
||||||
* @returns {Promise<void>} 無回傳值。
|
* @returns {Promise<void>} 無回傳值。
|
||||||
*/
|
*/
|
||||||
export async function postFindingsReview(findings, deps = {}) {
|
export async function postFindingsReview(findings, deps = {}) {
|
||||||
@@ -282,11 +279,11 @@ export async function postFindingsReview(findings, deps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將 findings 寫入 `findings.json`(同步阻塞 I/O)。
|
* 將 findings 寫入新版 wrapper 格式的 `findings.json`(同步阻塞 I/O)。
|
||||||
*
|
*
|
||||||
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
|
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
|
||||||
* @param {Array<object>} findings 要寫入的 findings 陣列;會以 `JSON.stringify(findings, null, 2)` 序列化,
|
* @param {Array<object>} findings 要寫入的 findings 陣列;會包成包含 `generatedAt`/`commitSha`/
|
||||||
* 並在檔尾補一個換行字元。
|
* `prNumber`/`tool`/`findings`/`excluded` 的 wrapper,再以 2 空白縮排 JSON 序列化並補換行。
|
||||||
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
|
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
|
||||||
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
|
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
|
||||||
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
|
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
|
||||||
@@ -297,14 +294,15 @@ export async function postFindingsReview(findings, deps = {}) {
|
|||||||
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
|
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
|
||||||
*/
|
*/
|
||||||
export function saveFindings(workspace, findings, mirrorDir = null) {
|
export function saveFindings(workspace, findings, mirrorDir = null) {
|
||||||
|
const wrapper = buildFindingsWrapper(findings, []);
|
||||||
const targets = [workspace];
|
const targets = [workspace];
|
||||||
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
||||||
|
|
||||||
for (const targetDir of targets) {
|
for (const targetDir of targets) {
|
||||||
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(fullPath, JSON.stringify(wrapper, null, 2) + '\n', 'utf8');
|
||||||
ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
ok(`findings 寫入: ${fullPath} (${wrapper.findings.length} 筆)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,12 +310,9 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
|
|||||||
* 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
|
* 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
|
||||||
*
|
*
|
||||||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題
|
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題。
|
||||||
* (注意:此判定與 {@link newFindingsOnly} 的 `is_new !== false` 不同,`undefined` 在此處被視為
|
|
||||||
* 「舊」而非「新」,是否為預期設計需人工確認)。
|
|
||||||
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
|
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||||
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出(與 {@link postFindingsReview}
|
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出。
|
||||||
* 內部先用 `bySeverity` 排序的行為不同,請勿假設本函式輸出已排序)。
|
|
||||||
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
|
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
|
||||||
*/
|
*/
|
||||||
export async function postOldFindingsComment(findings) {
|
export async function postOldFindingsComment(findings) {
|
||||||
@@ -336,8 +331,7 @@ export async function postOldFindingsComment(findings) {
|
|||||||
*
|
*
|
||||||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
* 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題,
|
* 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題,
|
||||||
* `undefined`/`false` 皆會被排除(注意:此判定比 {@link newFindingsOnly} 的
|
* `undefined`/`false` 皆會被排除。
|
||||||
* `is_new !== false` 更嚴格,兩者對 `undefined` 的處理方向相反,是否為預期設計需人工確認)。
|
|
||||||
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
|
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
|
||||||
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
|
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||||
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
|
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
|
||||||
@@ -360,8 +354,7 @@ export async function postNewNonCriticalComment(findings) {
|
|||||||
* (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
|
* (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
|
||||||
*
|
*
|
||||||
* @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings
|
* @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings
|
||||||
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題
|
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題。
|
||||||
* (與 {@link newFindingsOnly} 的寬鬆判定不同,`undefined` 會被排除,需人工確認是否為預期設計)。
|
|
||||||
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
|
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
|
||||||
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
|
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
|
||||||
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
||||||
|
|||||||
+2
-2
@@ -42,13 +42,13 @@ export const LLM_PROVIDER = 'cliproxyapi';
|
|||||||
|
|
||||||
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
||||||
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
||||||
const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
const MODEL_NAME_RE = /^[A-Za-z0-9._/-]+$/;
|
||||||
|
|
||||||
function normalizeModelName(raw) {
|
function normalizeModelName(raw) {
|
||||||
const model = String(raw || '').trim();
|
const model = String(raw || '').trim();
|
||||||
if (!model) return { model: null, modelError: null };
|
if (!model) return { model: null, modelError: null };
|
||||||
if (!MODEL_NAME_RE.test(model)) {
|
if (!MODEL_NAME_RE.test(model)) {
|
||||||
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' };
|
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線、連字號與斜線' };
|
||||||
}
|
}
|
||||||
return { model, modelError: null };
|
return { model, modelError: null };
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-31
@@ -26,27 +26,6 @@ export async function analyzeWithRole(role, diff) {
|
|||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 讀取 JSON 陣列檔案;檔案不存在、讀取失敗或內容非陣列時,皆視為空並回傳 []。
|
|
||||||
*
|
|
||||||
* @param {string} fullPath - 欲讀取的 JSON 檔案完整路徑。
|
|
||||||
* @param {string} label - 用於警告訊息中識別此次讀取對象的標籤文字(例如「舊 findings 」)。
|
|
||||||
* @returns {Array<object>} 解析出的陣列;任何失敗情況皆回傳空陣列 []。
|
|
||||||
*/
|
|
||||||
function readJSONArray(fullPath, label) {
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
warn(`${label}檔案不存在,視為空`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch (e) {
|
|
||||||
warn(`讀取${label}失敗: ${e.message},視為空`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
||||||
*
|
*
|
||||||
@@ -305,7 +284,8 @@ function buildExclusionContext(exclusions) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
|
* 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
|
||||||
* 每筆項目一律標記 is_new: false(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
|
* 同時相容舊版頂層陣列與新版 wrapper 物件;每筆項目一律標記 is_new: false
|
||||||
|
*(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
|
||||||
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
||||||
*
|
*
|
||||||
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
|
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
|
||||||
@@ -313,11 +293,20 @@ function buildExclusionContext(exclusions) {
|
|||||||
*/
|
*/
|
||||||
export function loadOldFindings(workspace) {
|
export function loadOldFindings(workspace) {
|
||||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
let old = [];
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
|
try {
|
||||||
const stat = fs.statSync(fullPath);
|
const stat = fs.statSync(fullPath);
|
||||||
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
const sourceFormat = Array.isArray(data) ? 'array' : (data && Array.isArray(data.findings) ? 'wrapper' : 'unknown');
|
||||||
|
const rawFindings = Array.isArray(data) ? data : (data && Array.isArray(data.findings) ? data.findings : []);
|
||||||
|
old = rawFindings.map(f => ({ ...f, is_new: false }));
|
||||||
line(`讀取舊 findings 檔案: ${fullPath}`);
|
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||||
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} source=${sourceFormat} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
} catch (e) {
|
||||||
|
warn(`讀取舊 findings 失敗: ${e.message},視為空: ${fullPath}`);
|
||||||
|
old = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
||||||
}
|
}
|
||||||
@@ -326,7 +315,7 @@ export function loadOldFindings(workspace) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合併新舊 findings:以 (role + location + suggestion 前 50 字) 組成的字串為 key,
|
* 合併新舊 findings:以 (role + location + problem + suggestion 前 50 字) 組成的字串為 key,
|
||||||
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
|
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
|
||||||
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
|
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
|
||||||
*
|
*
|
||||||
@@ -335,7 +324,7 @@ export function loadOldFindings(workspace) {
|
|||||||
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
|
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
|
||||||
*/
|
*/
|
||||||
export function mergeFindings(oldFindings, newFindings) {
|
export function mergeFindings(oldFindings, newFindings) {
|
||||||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
const key = f => `${f.role}|${f.location}|${String(f.problem || '')}|${String(f.suggestion || '').slice(0, 50)}`;
|
||||||
const seen = new Set(oldFindings.map(key));
|
const seen = new Set(oldFindings.map(key));
|
||||||
const deduped = newFindings.filter(f => {
|
const deduped = newFindings.filter(f => {
|
||||||
if (seen.has(key(f))) return false;
|
if (seen.has(key(f))) return false;
|
||||||
@@ -348,15 +337,17 @@ export function mergeFindings(oldFindings, newFindings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 依等級排序(critical > warning > info),回傳新陣列,不修改傳入的 findings。
|
* 依等級排序(critical > warning > info,未知等級排最後),回傳新陣列,不修改傳入的 findings。
|
||||||
*
|
*
|
||||||
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
|
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
|
||||||
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列。
|
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列;未知等級會排在最後。
|
||||||
* @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1,
|
|
||||||
* 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。
|
|
||||||
*/
|
*/
|
||||||
export function sortByLevel(findings) {
|
export function sortByLevel(findings) {
|
||||||
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
|
const rank = (level) => {
|
||||||
|
const index = LEVELS.indexOf(level);
|
||||||
|
return index === -1 ? LEVELS.length : index;
|
||||||
|
};
|
||||||
|
return [...findings].sort((a, b) => rank(a.level) - rank(b.level));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+105
-3
@@ -1,9 +1,85 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chat } from './llm.js';
|
import { chat } from './llm.js';
|
||||||
|
import { FINDINGS_PATH, PR_HEAD_SHA, PR_NUMBER, getLLMConfig } from './config.js';
|
||||||
import { ok, warn, error } from './log.js';
|
import { ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
const PACKAGE_VERSION = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function formatTaipeiTimestamp(date = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Taipei',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const map = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value]));
|
||||||
|
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrNumber(raw) {
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFindingsWrapperLabel(label) {
|
||||||
|
return String(label || '') === FINDINGS_PATH || String(label || '').endsWith('/findings.json') || String(label || '').endsWith('findings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultToolInfo() {
|
||||||
|
const { model } = getLLMConfig();
|
||||||
|
return {
|
||||||
|
name: 'ai-code-review',
|
||||||
|
version: PACKAGE_VERSION,
|
||||||
|
model: model || 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFindingsWrapper(findings, excluded = [], overrides = {}) {
|
||||||
|
return {
|
||||||
|
generatedAt: overrides.generatedAt || formatTaipeiTimestamp(),
|
||||||
|
commitSha: overrides.commitSha || PR_HEAD_SHA || '',
|
||||||
|
prNumber: overrides.prNumber !== undefined ? overrides.prNumber : parsePrNumber(PR_NUMBER),
|
||||||
|
tool: overrides.tool || defaultToolInfo(),
|
||||||
|
findings: Array.isArray(findings) ? findings : [],
|
||||||
|
excluded: Array.isArray(excluded) ? excluded : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFindingsWrapper(data) {
|
||||||
|
if (Array.isArray(data)) return buildFindingsWrapper(data, []);
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
if (!Array.isArray(data.findings)) return null;
|
||||||
|
return {
|
||||||
|
generatedAt: typeof data.generatedAt === 'string' && data.generatedAt.trim() ? data.generatedAt : formatTaipeiTimestamp(),
|
||||||
|
commitSha: typeof data.commitSha === 'string' ? data.commitSha : (PR_HEAD_SHA || ''),
|
||||||
|
prNumber: data.prNumber !== undefined ? parsePrNumber(data.prNumber) : parsePrNumber(PR_NUMBER),
|
||||||
|
tool: data.tool && typeof data.tool === 'object'
|
||||||
|
? {
|
||||||
|
name: typeof data.tool.name === 'string' && data.tool.name.trim() ? data.tool.name : 'ai-code-review',
|
||||||
|
version: typeof data.tool.version === 'string' && data.tool.version.trim() ? data.tool.version : PACKAGE_VERSION,
|
||||||
|
model: typeof data.tool.model === 'string' && data.tool.model.trim() ? data.tool.model : 'auto',
|
||||||
|
}
|
||||||
|
: defaultToolInfo(),
|
||||||
|
findings: data.findings,
|
||||||
|
excluded: Array.isArray(data.excluded) ? data.excluded : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJSON(fullPath, data) {
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
||||||
@@ -92,6 +168,7 @@ function readJSONText(fullPath, label) {
|
|||||||
*/
|
*/
|
||||||
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
const expectsFindingsWrapper = isFindingsWrapperLabel(label);
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
warn(`${label} 不存在,將於驗證後補建`);
|
warn(`${label} 不存在,將於驗證後補建`);
|
||||||
@@ -99,7 +176,23 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JSON.parse(readJSONText(fullPath, label));
|
const parsed = JSON.parse(readJSONText(fullPath, label));
|
||||||
|
if (expectsFindingsWrapper) {
|
||||||
|
const normalized = normalizeFindingsWrapper(parsed);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error(`${label} 不是 findings wrapper`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
ok(`${label} JSON 格式正確`);
|
||||||
|
return { exists: true, valid: true, repaired: false };
|
||||||
|
}
|
||||||
|
writeJSON(fullPath, normalized);
|
||||||
|
ok(`${label} 已正規化為 findings wrapper`);
|
||||||
|
return { exists: true, valid: true, repaired: true };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${label} 不是 JSON 陣列`);
|
||||||
|
}
|
||||||
ok(`${label} JSON 格式正確`);
|
ok(`${label} JSON 格式正確`);
|
||||||
return { exists: true, valid: true, repaired: false };
|
return { exists: true, valid: true, repaired: false };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -110,10 +203,18 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
||||||
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
||||||
const parsed = JSON.parse(normalized);
|
const parsed = JSON.parse(normalized);
|
||||||
|
if (expectsFindingsWrapper) {
|
||||||
|
const wrapper = normalizeFindingsWrapper(parsed);
|
||||||
|
if (!wrapper) {
|
||||||
|
throw new Error(`${label} 修復後內容不是 findings wrapper`);
|
||||||
|
}
|
||||||
|
writeJSON(fullPath, wrapper);
|
||||||
|
} else {
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
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 };
|
||||||
} catch (repairErr) {
|
} catch (repairErr) {
|
||||||
@@ -138,7 +239,8 @@ export function ensureJSONArrayFileExists(fullPath, label) {
|
|||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
if (fs.existsSync(fullPath)) return false;
|
if (fs.existsSync(fullPath)) return false;
|
||||||
|
|
||||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
const content = isFindingsWrapperLabel(label) ? buildFindingsWrapper([], []) : [];
|
||||||
warn(`${label} 不存在,已建立空陣列`);
|
writeJSON(fullPath, content);
|
||||||
|
warn(`${label} 不存在,已建立空${isFindingsWrapperLabel(label) ? ' findings wrapper' : '陣列'}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-26
@@ -31,24 +31,9 @@ export async function mapWithConcurrency(items, limit, fn) {
|
|||||||
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
/**
|
/**
|
||||||
* mapWithConcurrency 的工作者(worker)迴圈:從共用游標 `cursor` 依序搶下一個尚未
|
* 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。
|
||||||
* 處理的索引,呼叫外層傳入的 `fn`,並把結果寫入外層 `results` 陣列對應位置;直到
|
|
||||||
* `cursor` 到達 `list.length` 為止。
|
|
||||||
*
|
*
|
||||||
* 多個 `run()` 會被同時啟動(依 `workers` 數量),透過共用的 `cursor` 變數達到
|
* @returns {Promise<void>} 無回傳值。
|
||||||
* 「限制併發數、動態搶下一筆」的效果——先完成者會先搶到下一個索引,因此各次 `fn`
|
|
||||||
* 呼叫的完成順序不保證,但因寫入位置以原始索引 `i` 為準,`results` 仍能保持與
|
|
||||||
* `items` 相同順序。
|
|
||||||
*
|
|
||||||
* 本函式為 `mapWithConcurrency` 內部使用的閉包(closure),依賴外層作用域的
|
|
||||||
* `list`、`results`、`fn`、`cursor` 變數運作;不接受參數,也不可、不應在外部
|
|
||||||
* 單獨呼叫或匯出。
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>} 無回傳值;副作用為寫入外層 `results` 陣列與推進 `cursor`。
|
|
||||||
* @throws 若某次 `fn(list[i], i)` reject,本函式會原樣向外拋出該錯誤(不吞例外),
|
|
||||||
* 使 `mapWithConcurrency` 的 `Promise.all` 立即 reject;但其他已啟動、尚在執行
|
|
||||||
* 中的 `run()` 實例不會被取消,仍會在背景繼續搬移 `cursor` 並寫入 `results`,
|
|
||||||
* 只是其結果最終會被捨棄。
|
|
||||||
*/
|
*/
|
||||||
async function run() {
|
async function run() {
|
||||||
while (cursor < list.length) {
|
while (cursor < list.length) {
|
||||||
@@ -132,10 +117,10 @@ function summarizeApiError(e) {
|
|||||||
|| responseData?.message
|
|| responseData?.message
|
||||||
|| responseData?.error
|
|| responseData?.error
|
||||||
|| '';
|
|| '';
|
||||||
const stderr = String(e.stderr || '').trim();
|
const stderr = String(e?.stderr || '').trim();
|
||||||
const stdout = String(e.stdout || '').trim();
|
const stdout = String(e?.stdout || '').trim();
|
||||||
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
|
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
|
||||||
const message = extractMeaningfulError(responseText || stderr || stdout || e.message || String(e));
|
const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e));
|
||||||
return [status, message].filter(Boolean).join(' ').trim();
|
return [status, message].filter(Boolean).join(' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,18 +149,17 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
|||||||
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
||||||
const root = String(baseURL || '').trim().replace(/\/$/, '');
|
const root = String(baseURL || '').trim().replace(/\/$/, '');
|
||||||
const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : '';
|
const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : '';
|
||||||
const body = {
|
const resp = await axios.post(
|
||||||
|
`${root}/v1/chat/completions`,
|
||||||
|
{
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
...(model ? { model } : {}),
|
||||||
if (model) body.model = model;
|
},
|
||||||
const resp = await axios.post(
|
|
||||||
`${root}/v1/chat/completions`,
|
|
||||||
body,
|
|
||||||
{
|
{
|
||||||
timeout,
|
timeout,
|
||||||
maxBodyLength: maxBuffer,
|
maxBodyLength: maxBuffer,
|
||||||
|
|||||||
+16
-11
@@ -1,12 +1,4 @@
|
|||||||
/**
|
const timestampFormatter = new Intl.DateTimeFormat('zh-TW', {
|
||||||
* 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。
|
|
||||||
* 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。
|
|
||||||
*
|
|
||||||
* @param {Date} [date] - 要格式化的時間點;省略時使用呼叫當下的系統時間。
|
|
||||||
* @returns {string} 例如 `2026/08/07 12:39:43`。
|
|
||||||
*/
|
|
||||||
function formatTimestamp(date = new Date()) {
|
|
||||||
const parts = new Intl.DateTimeFormat('zh-TW', {
|
|
||||||
timeZone: 'Asia/Taipei',
|
timeZone: 'Asia/Taipei',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -15,8 +7,21 @@ function formatTimestamp(date = new Date()) {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
hourCycle: 'h23',
|
hourCycle: 'h23',
|
||||||
}).formatToParts(date);
|
});
|
||||||
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
||||||
|
/**
|
||||||
|
* 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。
|
||||||
|
* 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。
|
||||||
|
*
|
||||||
|
* @param {Date} [date] - 要格式化的時間點;省略時使用呼叫當下的系統時間。
|
||||||
|
* @returns {string} 例如 `2026/08/07 12:39:43`。
|
||||||
|
*/
|
||||||
|
function formatTimestamp(date = new Date()) {
|
||||||
|
const parts = timestampFormatter.formatToParts(date);
|
||||||
|
const map = parts.reduce((acc, part) => {
|
||||||
|
acc[part.type] = part.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user