docs(ai-code-review): 補齊各模組 JSDoc、指令檔逐行註解並重建 README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-06-26 14:16:04 +08:00
co-authored by Claude Opus 4.8
parent 303104bb20
commit 1378f03595
18 changed files with 2243 additions and 68 deletions
+19
View File
@@ -1,12 +1,31 @@
# =============================================================================
# 用途:Gitea CD(持續部署)workflow
# 當有變更 push 到 master 分支時,自動釋出並標註(tag)一個成品版本。
# 更新日期:2026/06/26 11:34:46
# =============================================================================
# workflow 名稱,顯示於 Gitea Actions 介面
name: CD name: CD
# 觸發條件設定
on: on:
# 以 push 事件觸發
push: push:
# 限定觸發的分支
branches: branches:
# 只有當變更 push 到 master 分支時才會啟動此 workflow(CD 釋出版本)
- master - master
# 此 workflow 包含的工作(jobs
jobs: jobs:
# job 識別碼:負責釋出並標註版本
release-tag-version: release-tag-version:
# job 在 Gitea Actions 介面上顯示的名稱
name: Release Tag Version name: Release Tag Version
# 指定執行此 job 的 runner 標籤(ubuntu runner
runs-on: ubuntu runs-on: ubuntu
# 此 job 的執行步驟清單
steps: steps:
# 步驟名稱(中文):釋出並標註成品版本
- name: 釋出並標註成品版本 - name: 釋出並標註成品版本
# 引用外部 composite action 來執行釋出與標註版本的流程;
# @ 後方版本由 Gitea 變數 vars.ACTION_RELEASE_TAG_VERSION 動態指定,便於統一管理版本。
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
+24
View File
@@ -1,19 +1,43 @@
# ============================================================
# 用途:Gitea CI,在 pull request 上執行 AI 程式碼審查(AI code review on pull requests
# 更新日期:2026/06/26 11:34:46
# ============================================================
# workflow 名稱,會顯示在 Gitea Actions 介面上
name: CI name: CI
# 觸發條件設定
on: on:
# 針對 pull request 事件觸發
pull_request: pull_request:
# 忽略指定目標分支:當 PR 目標分支為 master 時不觸發此 workflow
branches-ignore: branches-ignore:
- master - master
# 觸發的 PR 事件類型:openedPR 開啟)、synchronizePR 有新 commit 推送)
types: [opened, synchronize] types: [opened, synchronize]
# 定義此 workflow 的 jobs
jobs: jobs:
# job 識別碼:ai-code-review
ai-code-review: ai-code-review:
# job 顯示名稱
name: AI Code Review name: AI Code Review
# 指定執行環境的 runner 標籤:ubuntu
runs-on: ubuntu runs-on: ubuntu
# 此 job 所需的權限設定
permissions: permissions:
# 對 repository 內容的寫入權限(讀寫程式碼/檔案)
contents: write contents: write
# 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查)
pull-requests: write pull-requests: write
# 對 issues 的寫入權限(建立/更新 issue 留言所需)
issues: write issues: write
# job 的執行步驟
steps: steps:
# 步驟名稱:呼叫 OpenCode 進行 AI 程式碼審查
- name: AI 程式碼審查 by OpenCode - name: AI 程式碼審查 by OpenCode
# 使用外部 composite action 執行審查邏輯
# 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本
uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }}
# 傳遞給 composite action 的輸入參數
with: with:
# 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言
comment_token: ${{ secrets.COMMENT_TOKEN }} comment_token: ${{ secrets.COMMENT_TOKEN }}
+25
View File
@@ -1,12 +1,37 @@
# =============================================================================
# 用途:建置「AI 程式碼審查」Docker action 映像檔。
# 以 Alpine Linux 為基底,安裝 bash / git / Node.js / npm 等執行環境,
# 將 app/ 程式碼與相依套件打包進映像,並透過 entrypoint.sh 作為容器進入點,
# 供 CIGitea Actions)以 Docker action 形式執行 AI code review 流程。
# 更新日期:2026/06/26 11:34:46
# =============================================================================
# 指定基底映像為 Alpine Linux 最新版;Alpine 體積小,可縮小最終映像大小並加快拉取速度。
# 需人工確認:使用 latest tag 會在不同時間建置出不同基底版本,可能影響可重現性,
# 建議釘選明確版本(例如 alpine:3.20)以確保建置一致。
FROM alpine:latest FROM alpine:latest
# 安裝必要的工具 # 安裝必要的工具
# 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、
# nodejs 與 npm(執行 app 內的 Node.js 程式)。
# --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。
# 需人工確認:--no-check-certificate 會略過套件來源的憑證驗證,存在中間人攻擊風險,
# 僅在內網或憑證受限環境下使用;正式環境建議移除以維持安全性。
RUN apk add --no-cache --no-check-certificate bash git nodejs npm RUN apk add --no-cache --no-check-certificate bash git nodejs npm
# 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。
COPY ./app /app COPY ./app /app
# 進入 /app 安裝 npm 相依套件,使 Node.js 程式可在容器內正常執行。
# 副作用:會在 /app/node_modules 產生套件檔案,並依 package-lock.json(若存在)解析版本。
RUN cd /app && npm install RUN cd /app && npm install
# 將容器進入點腳本 entrypoint.sh 複製到映像根目錄 /entrypoint.sh。
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
# 賦予 entrypoint.sh 可執行權限,確保容器啟動時能直接執行該腳本。
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# 設定容器進入點為 /entrypoint.shexec 形式,不經過 shell 解析);
# 容器啟動時即執行此腳本,作為 Docker action 的實際入口。
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+1197
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -8,16 +8,47 @@ const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
const LEVEL_ORDER = ['critical', 'warning', 'info']; const LEVEL_ORDER = ['critical', 'warning', 'info'];
/**
* 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。
*
* @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f
* 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值;
* `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 會拋 TypeError(需人工確認是否需防呆)。
* @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串。
* @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。
*/
function findingRow(f) { function findingRow(f) {
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`; return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
} }
/**
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
*
* @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
* @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。
*/
function buildTable(findings) { function buildTable(findings) {
const rows = findings.map(findingRow).join('\n'); const rows = findings.map(findingRow).join('\n');
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
} }
/**
* 取得 finding 等級的人類可讀字串(emoji + 中文標籤),已去除頭尾空白。
*
* @param {{ level?: string }} f 單筆審查問題物件。`level` 查無對應時 emoji 留空、標籤回退為原始 level 值。
* @returns {string} 例如 `🔴 嚴重`;無法對應時回退為原始 level 字串(無 emoji)。
* @remarks 內部輔助函式,供 {@link inlineCommentBody} 與 {@link reviewCommentBody} 組裝 comment 內文使用。
*/
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim(); const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
/**
* findings 排序比較器:先依嚴重等級(critical < warning < info < 其他),同級再依 location 字串排序。
*
* @param {{ level?: string, location?: string }} a 比較項 A。
* @param {{ level?: string, location?: string }} b 比較項 B。
* @returns {number} 負值代表 a 排在 b 之前,正值代表之後,0 代表相等(供 Array.prototype.sort 使用)。
* @remarks 不在 LEVEL_ORDER 內的等級一律視為最低優先(排在最後);location 未定義時以空字串參與比較,因此排序穩定不會丟例外。
*/
const bySeverity = (a, b) => { const bySeverity = (a, b) => {
const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length; const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length;
const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length; const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length;
@@ -43,10 +74,26 @@ function inlineCommentBody(f) {
return `**等級**${levelText(f)}\n**審查員**${f.role}\n**建議**${f.suggestion}`; return `**等級**${levelText(f)}\n**審查員**${f.role}\n**建議**${f.suggestion}`;
} }
/**
* 從 finding 取出問題原因描述,依序嘗試多個可能欄位。
*
* @param {{ problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f
* 單筆審查問題物件;依序取第一個有值(truthy)的欄位。所有欄位皆無值時回退為「未提供問題原因」。
* @returns {string} 問題原因字串。
* @remarks 內部輔助函式,供 {@link reviewCommentBody} 組裝 comment 內文使用,用以容忍不同來源 finding 的欄位命名差異。
*/
function problemText(f) { function problemText(f) {
return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因'; return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因';
} }
/**
* 產生 review comment 內文(嚴重等級/審查員/問題/建議四行)。
*
* @param {{ level?: string, role?: string, suggestion?: string, problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f
* 單筆審查問題物件。
* @returns {string} 多行 Markdown 字串。
* @remarks 內部輔助函式,供 {@link toReviewComment} 產生批次 review comment 內文使用。比 {@link inlineCommentBody} 多了「問題」一行。
*/
function reviewCommentBody(f) { function reviewCommentBody(f) {
return [ return [
`**嚴重等級**${levelText(f)}`, `**嚴重等級**${levelText(f)}`,
@@ -56,17 +103,48 @@ function reviewCommentBody(f) {
].join('\n'); ].join('\n');
} }
/**
* 計算陣列中符合條件的元素數量。
*
* @param {Array<T>} findings 待計數的陣列。
* @param {(item: T) => boolean} predicate 判斷函式;回傳 true 的元素計入。
* @returns {number} 符合條件的元素數量。
* @template T
* @remarks 內部輔助函式,供 {@link formatFindingsStats} 與 {@link formatFindingsStatsLine} 統計各等級筆數使用。
*/
function countBy(findings, predicate) { function countBy(findings, predicate) {
return findings.filter(predicate).length; return findings.filter(predicate).length;
} }
/**
* 過濾出新問題(is_new 不等於 false 者)。
*
* @param {Array<{ is_new?: boolean }>} findings 審查問題陣列。
* @returns {Array<object>} 新問題子集合。
* @remarks 內部輔助函式。判定採 `is_new !== false`,因此未設定 is_newundefined)的 finding 也視為新問題;僅明確 `is_new === false` 會被排除。供統計與 review 發布判斷使用。
*/
function newFindingsOnly(findings) { function newFindingsOnly(findings) {
return findings.filter(f => f.is_new !== false); return findings.filter(f => f.is_new !== false);
} }
// 等級無法歸入 critical/warning/info(例如缺漏或無法辨識)時,歸入「無法標示」 // 等級無法歸入 critical/warning/info(例如缺漏或無法辨識)時,歸入「無法標示」
/**
* 判斷 finding 等級是否無法歸入 critical/warning/info(無法標示)。
*
* @param {{ level?: string }} f 單筆審查問題物件。
* @returns {boolean} 等級不在 LEVEL_ORDER 內時為 true。
* @remarks 內部輔助函式,供統計表的「⚪ 無法標示」欄位計數使用。
*/
const isUnclassified = f => !LEVEL_ORDER.includes(f.level); const isUnclassified = f => !LEVEL_ORDER.includes(f.level);
/**
* 產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。
*
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
* `is_new === false` 計入舊問題,其餘計入新問題。
* @returns {string} 含表頭、分隔列與兩資料列的 Markdown 表格字串。
* @remarks 供 {@link buildReviewSummary} 組裝 review 統計本文使用。空陣列時仍輸出表格(各欄為 0 筆)。
*/
export function formatFindingsStats(findings) { export function formatFindingsStats(findings) {
const oldFindings = findings.filter(f => f.is_new === false); const oldFindings = findings.filter(f => f.is_new === false);
const newFindings = newFindingsOnly(findings); const newFindings = newFindingsOnly(findings);
@@ -80,6 +158,14 @@ export function formatFindingsStats(findings) {
].join('\n'); ].join('\n');
} }
/**
* 產生 findings 統計的單行文字摘要(供 log 使用)。
*
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
* `is_new === false` 計入舊問題,其餘計入新問題。
* @returns {string} 形如 `新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...` 的單行字串。
* @remarks 供 {@link postFindingsReview} 在 log 輸出統計時呼叫。內容與 {@link formatFindingsStats} 一致,僅格式為單行純文字。
*/
export function formatFindingsStatsLine(findings) { export function formatFindingsStatsLine(findings) {
const oldFindings = findings.filter(f => f.is_new === false); const oldFindings = findings.filter(f => f.is_new === false);
const newFindings = newFindingsOnly(findings); const newFindings = newFindingsOnly(findings);
@@ -87,6 +173,14 @@ export function formatFindingsStatsLine(findings) {
return `新: ${row(newFindings)};舊: ${row(oldFindings)}`; return `新: ${row(newFindings)};舊: ${row(oldFindings)}`;
} }
/**
* 組裝 review 本文:標題 + findings 統計表 +(選擇性)用量區塊。
*
* @param {Array<object>} findings 用於統計的審查問題陣列。
* @param {string} [usageSection=''] 額外附加的用量/token 統計區塊;空字串時不附加。
* @returns {string} review 本文(Markdown)。
* @remarks 內部輔助函式,供 {@link postFindingsReview} 產生整批 review 的 body。
*/
function buildReviewSummary(findings, usageSection = '') { function buildReviewSummary(findings, usageSection = '') {
const parts = [ const parts = [
'## AI Code Review 統計', '## AI Code Review 統計',
@@ -97,6 +191,14 @@ function buildReviewSummary(findings, usageSection = '') {
return parts.join('\n'); return parts.join('\n');
} }
/**
* 將 finding 轉為 Gitea review comment 物件(含檔案路徑、內文、行號)。
*
* @param {{ location?: string, level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。
* @returns {{ path: string, body: string, new_position: number } | null}
* 可定位時回傳 comment 物件;location 無法解析出行號時回傳 null。
* @remarks 內部輔助函式,供 {@link postFindingsReview} 在 map 後以 `filter(Boolean)` 濾除無法定位的項目。
*/
function toReviewComment(f) { function toReviewComment(f) {
const loc = parseLocation(f.location); const loc = parseLocation(f.location);
if (!loc) return null; if (!loc) return null;
+19
View File
@@ -12,10 +12,29 @@ export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
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';
/**
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent
* 供連接使用自簽或無效憑證的 OpenCode 服務時使用。
*
* @remarks 每次呼叫都會回傳全新的 Agent 實例(不快取),建議呼叫端重用以共用連線池。
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 實例。
*/
export function getOpenCodeHttpsAgent() { export function getOpenCodeHttpsAgent() {
return new https.Agent({ rejectUnauthorized: false }); return new https.Agent({ rejectUnauthorized: false });
} }
/**
* 依環境變數解析並回傳 LLM 提供者設定。
*
* 當設定了 `OPENCODE_BASE_URL` 時回傳 OpenCode 提供者設定
* model 取自 `OPENCODE_MODEL`,預設為 `gemini-2.5-flash`);
* 否則回傳各欄位皆為空/null 的「無提供者」設定,由呼叫端據此判斷是否略過 LLM 流程。
*
* @remarks 每次呼叫都會即時讀取 `process.env`。`apiKeys` 在 OpenCode 模式下為固定佔位值 `['opencode']`,並非真實金鑰。
* @returns {{ provider: ('opencode'|null), apiKeys: string[], baseURL: (string|null), model: (string|null) }}
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
*/
export function getLLMConfig() { export function getLLMConfig() {
if (process.env.OPENCODE_BASE_URL) { if (process.env.OPENCODE_BASE_URL) {
return { return {
+97 -1
View File
@@ -37,6 +37,13 @@ function readJSONArray(fullPath, label) {
} }
} }
/**
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
*
* @param {Array<object>|{exclusions?: Array<object>, excluded_findings?: Array<object>}|*} data - 任意形式的排除資料來源。
* @returns {Array<object>} 對應的排除條目陣列;無法辨識時回傳空陣列。
* @remarks 與 detectExclusionSource 搭配,相容舊有多種 exclusions.json 結構。
*/
function normalizeExclusions(data) { function normalizeExclusions(data) {
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
if (data && Array.isArray(data.exclusions)) return data.exclusions; if (data && Array.isArray(data.exclusions)) return data.exclusions;
@@ -44,6 +51,13 @@ function normalizeExclusions(data) {
return []; return [];
} }
/**
* 偵測排除資料的原始容器格式,回傳格式標籤。
*
* @param {Array<object>|{exclusions?: *, excluded_findings?: *}|*} data - 任意形式的排除資料來源。
* @returns {('array'|'exclusions'|'excluded_findings'|'unknown')} 對應的格式標籤。
* @remarks 供 loadExclusions 判斷是否需把非陣列格式改寫成標準頂層陣列。
*/
function detectExclusionSource(data) { function detectExclusionSource(data) {
if (Array.isArray(data)) return 'array'; if (Array.isArray(data)) return 'array';
if (data && Array.isArray(data.exclusions)) return 'exclusions'; if (data && Array.isArray(data.exclusions)) return 'exclusions';
@@ -51,19 +65,49 @@ function detectExclusionSource(data) {
return 'unknown'; return 'unknown';
} }
/**
* 以標準格式(2 空白縮排 JSON 陣列、結尾換行、UTF-8)將排除條目寫回檔案,覆蓋原內容。
*
* @param {string} fullPath - 目標檔案路徑;上層目錄須事先存在(本函式不建立目錄)。
* @param {Array<object>} exclusions - 欲寫入的排除條目陣列。
* @returns {void}
* @throws 檔案寫入失敗(權限不足、目錄不存在等)時拋出 fs 錯誤。
* @remarks 統一輸出格式,使 exclusions.json 永遠是可預期的頂層陣列。
*/
function writeCanonicalExclusions(fullPath, exclusions) { function writeCanonicalExclusions(fullPath, exclusions) {
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8'); fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
} }
/**
* 將檔案 mtime(毫秒時間戳)格式化為 ISO 字串,無效值回傳 'unknown'。
*
* @param {number} mtimeMs - 毫秒時間戳(通常為 fs.Stats.mtimeMs)。
* @returns {string} ISO 8601 時間字串,或在輸入非有限數時回傳 'unknown'。
* @remarks 僅用於診斷日誌,呈現舊 findings / exclusions 檔案的修改時間。
*/
function formatFileTime(mtimeMs) { function formatFileTime(mtimeMs) {
if (!Number.isFinite(mtimeMs)) return 'unknown'; if (!Number.isFinite(mtimeMs)) return 'unknown';
return new Date(mtimeMs).toISOString(); return new Date(mtimeMs).toISOString();
} }
/**
* 安全取字串:字串則去頭尾空白,其餘型別(含 null/undefined/數字)一律回傳空字串。
*
* @param {*} value - 任意值。
* @returns {string} 去除頭尾空白後的字串,或空字串。
* @remarks 作為 normalizeText、toKeyText、getExclusionText 等的基礎防呆。
*/
function cleanText(value) { function cleanText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
/**
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
*/
function normalizeText(value) { function normalizeText(value) {
return cleanText(value) return cleanText(value)
.normalize('NFKC') .normalize('NFKC')
@@ -73,6 +117,14 @@ function normalizeText(value) {
.trim(); .trim();
} }
/**
* 將文字壓縮成無分隔符的鍵值:NFKC 後移除所有標點/符號/空白。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 去除所有分隔符的緊湊字串(可能為空字串)。
* @remarks 用於 normalizeExclusionEntry 的 textKey 與 fingerprint,以及群組鍵。
* 不確定:是否刻意不轉小寫(與 normalizeText 不同),需人工確認此差異是否預期。
*/
function toKeyText(value) { function toKeyText(value) {
return cleanText(value) return cleanText(value)
.normalize('NFKC') .normalize('NFKC')
@@ -80,6 +132,13 @@ function toKeyText(value) {
.trim(); .trim();
} }
/**
* 從排除條目取出代表性文字,依優先序 original_finding > title > suggestion > reason > note 取第一個非空值。
*
* @param {object|null|undefined} exclusion - 排除條目物件(可為 null/undefined)。
* @returns {string} 第一個非空的代表性文字,皆空時回傳空字串。
* @remarks 供 normalizeExclusionEntry 產生比對文字;相容多種人工撰寫的排除欄位命名。
*/
function getExclusionText(exclusion) { function getExclusionText(exclusion) {
return cleanText(exclusion?.original_finding) return cleanText(exclusion?.original_finding)
|| cleanText(exclusion?.title) || cleanText(exclusion?.title)
@@ -88,6 +147,14 @@ function getExclusionText(exclusion) {
|| cleanText(exclusion?.note); || cleanText(exclusion?.note);
} }
/**
* 正規化單一排除條目,補上 filePath、text、textKey 與唯一 fingerprint,保留原始欄位。
*
* @param {object} exclusion - 原始排除條目(可能僅含部分欄位)。
* @param {number} index - 條目在來源陣列中的索引;無文字可用時用於產生 fallback 指紋(entry-N)。
* @returns {object} 合併原欄位與衍生欄位(location、filePath、role、text、textKey、fingerprint)的新物件。
* @remarks fingerprint 以 filePath|role|textKey 組成,缺值以 '*' 或 entry-N 補位,供 dedupeExclusions 去重。
*/
function normalizeExclusionEntry(exclusion, index) { function normalizeExclusionEntry(exclusion, index) {
const location = cleanText(exclusion?.location); const location = cleanText(exclusion?.location);
const filePath = location ? location.split(':')[0] : ''; const filePath = location ? location.split(':')[0] : '';
@@ -106,6 +173,13 @@ function normalizeExclusionEntry(exclusion, index) {
}; };
} }
/**
* 依 fingerprint 去除重複的排除條目,保留首次出現者並維持原順序。
*
* @param {Array<object>} exclusions - 已正規化(含 fingerprint)的排除條目陣列。
* @returns {Array<object>} 去重後的排除條目陣列。
* @remarks 須先呼叫 normalizeExclusionEntry 補上 fingerprint,否則缺指紋的條目可能被誤併。
*/
function dedupeExclusions(exclusions) { function dedupeExclusions(exclusions) {
const seen = new Set(); const seen = new Set();
return exclusions.filter(exclusion => { return exclusions.filter(exclusion => {
@@ -115,6 +189,14 @@ function dedupeExclusions(exclusions) {
}); });
} }
/**
* 將排除條目依 textKey 分組統計,產生供 AI prompt 使用的群組摘要(含出現次數、涉及路徑與角色、樣本)。
*
* @param {Array<object>} exclusions - 已正規化(含 textKey、filePath、role、text、fingerprint)的排除條目。
* @returns {Array<{text: string, count: number, paths: string[], roles: string[], samples: string[]}>}
* 依出現次數、涉及路徑數、文字字典序排序的群組摘要陣列。
* @remarks 每組最多保留 2 筆樣本,避免後續 prompt 過長;供 buildExclusionContext 取前 N 組組裝提示。
*/
function groupExclusionsForAI(exclusions) { function groupExclusionsForAI(exclusions) {
const groups = new Map(); const groups = new Map();
for (const exclusion of exclusions) { for (const exclusion of exclusions) {
@@ -147,6 +229,14 @@ function groupExclusionsForAI(exclusions) {
})); }));
} }
/**
* 由原始排除條目建立「已知誤報」上下文:正規化、去重、分組後,產生計數摘要與可直接嵌入 prompt 的文字。
*
* @param {Array<object>} exclusions - 原始(未正規化)排除條目陣列。
* @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array<object>, prompt: string}}
* 含計數、前 12 組群組摘要與 prompt 字串;空輸入時 prompt 為空字串且不含 groupCount。
* @remarks 供 loadExclusions 日誌與 filterFalsePositivesWithAI 組裝防守方提示使用;prompt 最多展開 12 類群組。
*/
function buildExclusionContext(exclusions) { function buildExclusionContext(exclusions) {
if (exclusions.length === 0) { if (exclusions.length === 0) {
return { return {
@@ -303,7 +393,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
return findings; return findings;
} }
/** 只保留 AI 需要的欄位,減少 token 用量 */ /**
* 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。
*
* @param {Array<object>} findings - 完整 findings 陣列。
* @returns {Array<{level: *, role: *, location: *, problem: *, suggestion: *}>} 精簡後的 payload 陣列。
* @remarks 送往 LLM 前的瘦身步驟;原始欄位(如 is_new)需由呼叫端事後依鍵補回。
*/
function toAIPayload(findings) { function toAIPayload(findings) {
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion })); return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
} }
+96
View File
@@ -8,6 +8,21 @@ const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const BOT_COMMIT_MARKER = '[ai-review-bot]'; export const BOT_COMMIT_MARKER = '[ai-review-bot]';
/**
* 建立一個同步執行 git 子行程的 runner。透過注入 `spawn` 以利測試
* (正式環境傳入 `child_process.spawnSync`,測試可傳入 stub)。
*
* 回傳的 `run(args, cwd, env)` 會以 utf8 編碼執行 `git <args>`
* 成功回傳經 trim 的 stdout,失敗則丟出 Error。
*
* @param {(cmd: string, args: string[], opts: object) => {error?: Error & {code?: string}, status?: number, stdout?: string, stderr?: string}} spawn
* 同步 spawn 實作(依賴注入,通常為 `spawnSync`)。
* @returns {(args: string[], cwd?: string, env?: object) => string}
* 執行 git 的函式:回傳 trim 後的 stdout。
* @throws {Error} 找不到 git 指令時(`ENOENT`)丟出中文提示。
* @throws {Error} git 子行程本身的 `error`(非 ENOENT)原樣丟出。
* @throws {Error} git 離開碼非 0 時,以 stderr/stdout 內容丟出。
*/
function makeRunner(spawn) { function makeRunner(spawn) {
return function run(args, cwd, env) { return function run(args, cwd, env) {
const opts = { cwd, encoding: 'utf8' }; const opts = { cwd, encoding: 'utf8' };
@@ -22,6 +37,23 @@ function makeRunner(spawn) {
}; };
} }
/**
* 包裝一段需要 git HTTP 認證的工作:先在 workspace 寫出暫時的
* `.git-askpass.sh`(透過 `GIT_ASKPASS` 提供 token),呼叫 `fn(credEnv)`
* 再清除該暫存腳本。
*
* 清理時機會依 `fn` 回傳型別自動判斷:
* 同步回傳會立即清理;回傳 Promise(含 async 回呼)則延後到 Promise
* settle 後才清理,避免在第一個 await 就刪掉腳本,導致後續 git push
* 因 `cannot exec .git-askpass.sh` 而失敗。
*
* @template T
* @param {string} workspace 寫入暫存 askpass 腳本的目錄。
* @param {(credEnv: NodeJS.ProcessEnv) => T} fn 帶入憑證環境變數執行的回呼。
* @returns {T} 即 `fn` 的回傳值(Promise 會被包成 `.finally(cleanup)` 後回傳)。
* @throws 透傳 `fn` 丟出的任何例外(同步路徑會先清理暫存腳本再 re-throw)。
* @remarks askpass 腳本以權限 0o700 寫出;token 取自 config 的 `GITEA_TOKEN`。
*/
function withAskpass(workspace, fn) { function withAskpass(workspace, fn) {
const askpassScript = path.join(workspace, '.git-askpass.sh'); const askpassScript = path.join(workspace, '.git-askpass.sh');
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 }); fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
@@ -44,6 +76,19 @@ function withAskpass(workspace, fn) {
return result; return result;
} }
/**
* 以容錯方式執行 git 讀取指令:成功回傳 trim 後的輸出,
* 任何錯誤都吞掉並回傳空字串。適用於「失敗也不該中斷流程」的唯讀查詢
* (例如取 HEAD SHA、分支名、commit 時間)。
*
* @param {(args: string[], cwd?: string, env?: object) => string} run
* 由 `makeRunner` 產生的 git 執行函式。
* @param {string[]} args git 子指令與參數。
* @param {string} [cwd] 執行目錄。
* @param {object} [env] 環境變數覆寫。
* @returns {string} git 的 trim 輸出;失敗時回傳空字串。
* @remarks 不會拋出例外,也不記錄錯誤。
*/
function readGitOutput(run, args, cwd, env) { function readGitOutput(run, args, cwd, env) {
try { try {
return run(args, cwd, env); return run(args, cwd, env);
@@ -52,6 +97,17 @@ function readGitOutput(run, args, cwd, env) {
} }
} }
/**
* 讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間)。
* 所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。
*
* @param {string} repoDir git 工作目錄路徑。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入:覆寫底層的同步 spawn 實作。
* @returns {{repoDir: string, branch: string, headSha: string, shortSha: string, commitTime: string}}
* repo 狀態快照;無法取得的欄位為空字串。
* @remarks `commitTime` 為 `%cI` 格式(committer date, ISO 8601 嚴格格式)。
*/
export function getRepoState(repoDir, _spawnSync = spawnSync) { export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
@@ -61,11 +117,30 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) {
return { repoDir, branch, headSha, shortSha, commitTime }; return { repoDir, branch, headSha, shortSha, commitTime };
} }
/**
* 取得 HEAD commit 的完整 commit message`%B`,含 subject 與 body)。
* 容錯讀取:失敗時回傳空字串。
*
* @param {string} repoDir git 工作目錄路徑。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入。
* @returns {string} HEAD 的完整 commit 訊息;失敗時為空字串。
*/
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) { export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir); return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
} }
/**
* 判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit
* commit message 含 `BOT_COMMIT_MARKER`)。常用於避免機器人 commit
* 反覆觸發新一輪審查。
*
* @param {string} repoDir git 工作目錄路徑。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入。
* @returns {boolean} HEAD 訊息含機器人標記時為 true;讀取失敗時安全地回傳 false。
*/
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER); return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
} }
@@ -108,6 +183,27 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
}); });
} }
/**
* 將 AI 審查產出的 review 檔(findings / exclusions)結轉到 repo,並 commit、
* push 回 PR head branch。流程:設定機器人 git 身分 → fetch + hard reset 對齊
* 遠端 → 從 workspace 複製存在的 review 檔到 repo 並 add → 若無變更則跳過 →
* 以含 `BOT_COMMIT_MARKER` 與結果標籤的訊息 commit → push。
*
* 失敗策略:push 失敗只記 warning(commit 已在本地完成);其餘步驟的例外
* 由外層捕捉並記 warning,函式整體**不丟出例外**,以免中斷上層流程。
*
* @param {string} workspace review 檔來源目錄、askpass 腳本所在目錄。
* @param {string} repoDir 目標 git repo 目錄(commit/push 的工作目錄)。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入:覆寫底層同步 spawn。
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
* @param {'success'|'failure'} [reviewOutcome='success']
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
* @remarks `git reset --hard origin/<branch>` 會丟棄本地未對齊變更,請確認
* review 檔是在 reset 之後才複製進來(流程已如此安排)。
*/
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') { export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
+98 -16
View File
@@ -4,9 +4,26 @@ import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, P
import { line, warn } from './log.js'; import { line, warn } from './log.js';
const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const httpsAgent = new https.Agent({ rejectUnauthorized: false });
/**
* 產生呼叫 Gitea API 所需的 HTTP headers(含 Gitea token 授權與 JSON content-type)。
* 授權格式為 Gitea 專用的 `token <token>`,並非 OAuth Bearer。
* @param {string} [token=GITEA_TOKEN] - Gitea access token;讀取類用預設 token,留言/寫入類通常傳入 GITEA_COMMENT_TOKEN。
* @returns {{Authorization: string, 'Content-Type': string}} 可直接給 axios 的 headers 物件。
*/
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
/**
* 將相對路徑組成 Gitea REST API v1 的完整 URL(自動去除 server URL 結尾斜線)。
* @param {string} path - 以斜線開頭的 API 子路徑,例如 `/repos/owner/repo/pulls/1.diff`。
* @returns {string} 形如 `<server>/api/v1<path>` 的完整 URL。
*/
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
/**
* 從 Gitea commit 相關 API 的回應中萃取 commit message,相容多種巢狀結構。
* 依序嘗試 `message`、`commit.message`、`commit.commit.message`,皆無則回空字串。
* @param {object|null|undefined} payload - Gitea API 回傳的物件(如 git/commits 或 branch 回應)。
* @returns {string} commit 訊息,找不到時為空字串。
*/
function extractCommitMessage(payload) { function extractCommitMessage(payload) {
return payload?.message return payload?.message
|| payload?.commit?.message || payload?.commit?.message
@@ -14,13 +31,22 @@ function extractCommitMessage(payload) {
|| ''; || '';
} }
/**
* 解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查結果。
* 用於 commit 訊息或留言內容;無標記或無後綴時視為未知。
* @param {string} message - 待解析的 commit 訊息或留言文字。
* @returns {'success'|'failure'|'unknown'} 解析出的審查結果。
*/
export function getBotReviewOutcome(message) { export function getBotReviewOutcome(message) {
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i); const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
return match?.[1]?.toLowerCase() || 'unknown'; return match?.[1]?.toLowerCase() || 'unknown';
} }
/** /**
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾 * 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md
* 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
* @returns {Promise<string>} 過濾後的 diff 文字。
* @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
*/ */
export async function getPRDiff() { export async function getPRDiff() {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent }); const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
@@ -32,6 +58,12 @@ export async function getPRDiff() {
]); ]);
} }
/**
* 依 commit SHA 向 Gitea 查詢該 commit 的訊息(`GET /repos/{repo}/git/commits/{sha}`)。
* 失敗或 sha 為空時不拋例外,僅記錄警告並回傳空字串,方便呼叫端做容錯判斷。
* @param {string} sha - commit 的完整或縮寫 SHA。
* @returns {Promise<string>} commit 訊息;查無、sha 空或請求失敗時為空字串。
*/
export async function getCommitMessageBySha(sha) { export async function getCommitMessageBySha(sha) {
if (!sha) return ''; if (!sha) return '';
try { try {
@@ -47,6 +79,12 @@ export async function getCommitMessageBySha(sha) {
} }
} }
/**
* 取得指定分支 head commit 的訊息(先查 `GET /repos/{repo}/branches/{branch}` 取 SHA,再查該 commit)。
* 失敗或 branch 為空時不拋例外,記錄警告並回傳空字串。
* @param {string} [branch=PR_HEAD_BRANCH] - 分支名稱。
* @returns {Promise<string>} 該分支 head commit 的訊息;查無或失敗時為空字串。
*/
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
if (!branch) return ''; if (!branch) return '';
try { try {
@@ -63,7 +101,15 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
} }
} }
/** 檢查 PR headcommit sha 或分支 head)的訊息是否帶 [ai-review-bot] 標記,是則代表本次是自動提交、應跳過審查。 */ /**
* 判斷目前 PR headcommit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記;
* 若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。
* @param {object} [options]
* @param {string} [options.sha=PR_HEAD_SHA||process.env.GITHUB_SHA] - 要檢查的 commit SHA。
* @param {string} [options.branch=PR_HEAD_BRANCH] - 要檢查的分支名稱。
* @returns {Promise<boolean>} true 表示應跳過審查。
* @remarks 內部查詢失敗會被降級為空字串(視為未命中),因此正常情況下不會拋出例外。
*/
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]')) return true;
@@ -75,8 +121,11 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
} }
/** /**
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。 * 過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊。
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴 * 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對
* @param {string} diff - 完整的 unified diff 文字。
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
* @returns {string} 過濾後重新接合的 diff 文字。
*/ */
export function filterDiff(diff, excludePrefixes) { export function filterDiff(diff, excludePrefixes) {
return diff.split(/(?=^diff --git )/m) return diff.split(/(?=^diff --git )/m)
@@ -88,6 +137,13 @@ export function filterDiff(diff, excludePrefixes) {
.join(''); .join('');
} }
/**
* 在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。
* 透過 `POST /repos/{repo}/issues/{index}/comments`,優先使用 GITEA_COMMENT_TOKEN 授權。
* @param {string} body - 留言內容(支援 Markdown)。
* @returns {Promise<object>} Gitea 建立的 comment 物件。
* @throws {Error} 請求失敗(網路、逾時或非 2xx)時拋出 axios 例外。
*/
export async function postComment(body) { export async function postComment(body) {
const resp = await axios.post( const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`),
@@ -98,9 +154,14 @@ export async function postComment(body) {
} }
/** /**
* 在 PR 指定檔案的指定行數發布行內 review comment標註程式碼位置)。 * 在 PR 指定檔案的指定新版行號發布一筆行內 review comment建立一個只含單一 comment 的 COMMENT review)。
* 透過 Gitea 的 pull reviews API以 new_position 對應新版檔案的行號 * 以 `new_position` 對應新檔行號;該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。 * @param {object} params
* @param {string} params.path - 檔案路徑(PR 內的相對路徑)。
* @param {number} params.line - 新版檔案中的行號(diff 右側行)。
* @param {string} params.body - 行內留言內容。
* @returns {Promise<object>} Gitea 建立的 review 物件。
* @throws {Error} 請求失敗或行號超出 diff 範圍時拋出 axios 例外。
*/ */
export async function postPullReviewComment({ path: filePath, line, body }) { export async function postPullReviewComment({ path: filePath, line, body }) {
const resp = await axios.post( const resp = await axios.post(
@@ -117,7 +178,13 @@ export async function postPullReviewComment({ path: filePath, line, body }) {
} }
/** /**
* 建立一個 PR review本文放統計摘要,comments 放多筆行內 review comments。 * 建立一個 PR review本文放摘要,comments 批次放多筆行內 review comments。
* 透過 `POST /repos/{repo}/pulls/{index}/reviews`event=COMMENT),優先使用 GITEA_COMMENT_TOKEN。
* @param {object} params
* @param {string} params.body - review 本文(通常為統計摘要)。
* @param {Array<{path:string, body:string, new_position?:number}>} [params.comments=[]] - 行內 comment 陣列。
* @returns {Promise<object>} Gitea 建立的 review 物件。
* @throws {Error} 請求失敗(如某筆 comment 行號不在 diff 範圍)時拋出 axios 例外。
*/ */
export async function postPullReview({ body, comments = [] }) { export async function postPullReview({ body, comments = [] }) {
const resp = await axios.post( const resp = await axios.post(
@@ -134,7 +201,10 @@ export async function postPullReview({ body, comments = [] }) {
} }
/** /**
* 取得 PR 上所有 review每個 review 可含多個行內 comment)。 * 取得目前 PR 上所有 review`GET /repos/{repo}/pulls/{index}/reviews`)。
* 回應非陣列時回傳空陣列以保證型別一致。
* @returns {Promise<object[]>} review 物件陣列。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/ */
export async function listPullReviews() { export async function listPullReviews() {
const resp = await axios.get( const resp = await axios.get(
@@ -145,7 +215,10 @@ export async function listPullReviews() {
} }
/** /**
* 取得單一 review 底下的所有行內 comment。 * 取得指定 review 底下的所有行內 comment`GET /repos/{repo}/pulls/{index}/reviews/{id}/comments`
* @param {number|string} reviewId - review 的 ID。
* @returns {Promise<object[]>} comment 物件陣列;非陣列回應時為空陣列。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/ */
export async function getPullReviewComments(reviewId) { export async function getPullReviewComments(reviewId) {
const resp = await axios.get( const resp = await axios.get(
@@ -156,8 +229,10 @@ export async function getPullReviewComments(reviewId) {
} }
/** /**
* 取得 PR 上所有 review 的行內 comment展平單一陣列。 * 取得目前 PR 上所有 review 的行內 comment展平單一陣列。
* 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程。 * 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程;最後輸出統計日誌
* @returns {Promise<object[]>} 所有行內 comment 的展平陣列。
* @throws {Error} 當 listPullReviews 取得 review 清單失敗時拋出例外。
*/ */
export async function listAllReviewComments() { export async function listAllReviewComments() {
const reviews = await listPullReviews(); const reviews = await listPullReviews();
@@ -175,8 +250,11 @@ export async function listAllReviewComments() {
} }
/** /**
* 解決(resolve一個 review comment 所屬的對話。 * 解決(resolve指定 review comment 所屬的對話。
* 對應 Gitea 官方 APIPOST /repos/{repo}/pulls/comments/{id}/resolve。 * 對應 Gitea API `POST /repos/{repo}/pulls/comments/{id}/resolve`,使用 GITEA_COMMENT_TOKEN 授權
* @param {number|string} commentId - 要解決的 review comment ID。
* @returns {Promise<object>} Gitea API 回應內容。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/ */
export async function resolvePullReviewComment(commentId) { export async function resolvePullReviewComment(commentId) {
const resp = await axios.post( const resp = await axios.post(
@@ -188,8 +266,12 @@ export async function resolvePullReviewComment(commentId) {
} }
/** /**
* 取得指定 ref(預設 PR head)下某檔案的最新文字內容 * 取得指定 ref(預設 PR head)下某檔案的文字內容
* Gitea contents API 回傳 base64,這裡解碼成字串。檔案不存在或非文字時回傳空字串。 * 透過 Gitea contents API`GET /repos/{repo}/contents/{path}`),base64 內容會自動解碼為 UTF-8 字串。
* 檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。
* @param {string} filePath - 檔案在 repo 中的相對路徑。
* @param {string} [ref=PR_HEAD_SHA||PR_HEAD_BRANCH] - commit SHA 或分支名稱;空值時不帶 ref。
* @returns {Promise<string>} 檔案文字內容;查無或失敗時為空字串。
*/ */
export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) { export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) {
try { try {
+62 -11
View File
@@ -6,7 +6,13 @@ import { ok, warn, error } from './log.js';
const MAX_JSON_BYTES = 1024 * 1024; const MAX_JSON_BYTES = 1024 * 1024;
/** /**
* 移除 AI 回傳內容外層的 markdown code fence * 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
* 並去除前後空白,使內容可直接交給 JSON.parse。
*
* 屬純函式、無副作用;常用於將 LLM 回傳結果正規化後再行解析。
*
* @param {*} text 待處理內容;非字串會先以 String() 轉型。
* @returns {string} 去除外層 code fence 與前後空白後的字串。
*/ */
export function stripCodeFence(text) { export function stripCodeFence(text) {
return String(text) return String(text)
@@ -17,11 +23,22 @@ export function stripCodeFence(text) {
} }
/** /**
* 透過 LLM 修正 JSON 陣列內容 * 透過 LLM 將任意原始內容修復成「可直接 JSON.parse 的 JSON 陣列」字串
* @param {string} fullPath 檔案路徑,供提示詞與除錯使用。 *
* @param {string} label 檔案標籤。 * 會以固定 system prompt 指示模型忽略原內容中的指令/註解/markdown,
* @param {string} rawText 原始內容 * 僅輸出修正後的陣列;無法判斷時模型應回傳空陣列 `[]`
* @param {Function} chatFn 可注入的 LLM 呼叫函式,預設使用 `chat` * 回傳前會先以 stripCodeFence 清除外層 code fence
*
* 備註:fullPath 與 label 僅放入提示詞供模型參考與除錯,不會用於讀檔;
* 回傳結果不保證為合法 JSON,需由呼叫端再行解析驗證。
*
* @param {string} fullPath 檔案完整路徑,供提示詞與除錯使用。
* @param {string} label 檔案標籤(人類可讀名稱)。
* @param {string} rawText 待修復的原始內容。
* @param {(systemPrompt: string, userContent: string) => Promise<string>} [chatFn=chat]
* 可注入的 LLM 呼叫函式,預設使用模組匯入的 chat;便於測試替換。
* @returns {Promise<string>} 經 code fence 清理後的修復字串。
* @throws {Error} 當 chatFnLLM 呼叫)失敗時,例外向上拋出。
*/ */
export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = chat) { export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = chat) {
const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。 const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。
@@ -33,6 +50,18 @@ export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = c
return stripCodeFence(repaired); return stripCodeFence(repaired);
} }
/**
* 讀取指定 JSON 檔案的 UTF-8 文字內容,讀取前先檢查檔案大小上限。
*
* 模組私有工具函式,供 validateJSONArrayFile 內部使用;
* 大小超過 MAX_JSON_BYTES(約 1 MB)時直接拒絕讀取以避免處理過大檔案。
*
* @param {string} fullPath 欲讀取的檔案完整路徑。
* @param {string} label 檔案標籤,用於組合錯誤訊息。
* @returns {string} 檔案的 UTF-8 文字內容。
* @throws {Error} 檔案大小超過 MAX_JSON_BYTES 時丟出;
* 或 fs.statSyncfs.readFileSync 因檔案不存在、無權限等丟出的 IO 例外。
*/
function readJSONText(fullPath, label) { function readJSONText(fullPath, label) {
const size = fs.statSync(fullPath).size; const size = fs.statSync(fullPath).size;
if (size > MAX_JSON_BYTES) { if (size > MAX_JSON_BYTES) {
@@ -42,10 +71,24 @@ function readJSONText(fullPath, label) {
} }
/** /**
* 驗證 JSON 陣列檔案是否存在且格式正確 * 驗證指定路徑是否為合法的 JSON 檔案;格式錯誤時嘗試以 AI 修復一次後再次驗證
* 若格式錯誤,直接嘗試透過 AI 修復,修復後再次檢查; *
* 第二次檢查仍失敗才丟出例外。 * 行為摘要:
* 若檔案不存在,回傳 exists=false,交由呼叫端決定是否補檔 * - 先確保父目錄存在
* - 檔案不存在:不丟例外,回傳 { exists:false },交由呼叫端決定是否補檔。
* - 解析成功:回傳 { exists:true, valid:true, repaired:false }。
* - 解析失敗:呼叫 repairer 修復、覆寫檔案(確保以換行結尾)、再驗證一次;
* 通過則回傳 repaired:true,仍失敗則丟出例外。
*
* 備註:僅嘗試修復一次;會寫入磁碟並輸出日誌,屬有副作用之非同步函式。
*
* @param {string} fullPath 欲驗證的 JSON 檔案完整路徑。
* @param {string} label 檔案標籤,用於日誌與提示訊息。
* @param {(fullPath: string, label: string, rawText: string) => Promise<string>} [repairer=repairJSONArrayWithAI]
* 可注入的修復函式,預設使用 repairJSONArrayWithAI;便於測試替換。
* @returns {Promise<{exists: boolean, valid: boolean, repaired: boolean}>}
* 驗證結果;repaired 表示是否經由 AI 修復後才通過驗證。
* @throws {Error} 修復後二次驗證仍失敗,或修復/檔案讀寫過程發生例外時拋出。
*/ */
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 });
@@ -76,7 +119,15 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
} }
/** /**
* 若檔案不存在則建立空陣列。 * 確保指定路徑存在一個 JSON 檔案;若不存在則建立內容為 "[]\n" 的空陣列
*
* 會先建立父目錄。若檔案已存在則原樣保留、不檢查其內容是否合法
* (內容驗證請改用 validateJSONArrayFile)。為同步函式。
*
* @param {string} fullPath 目標檔案完整路徑。
* @param {string} label 檔案標籤,用於日誌訊息。
* @returns {boolean} 是否為本次新建:新建回傳 true,原本即存在回傳 false。
* @throws {Error} 建立目錄或寫入檔案失敗(如權限不足)時,IO 例外向上拋出。
*/ */
export function ensureJSONArrayFileExists(fullPath, label) { export function ensureJSONArrayFileExists(fullPath, label) {
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+92
View File
@@ -3,11 +3,28 @@ import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
import { recordUsage } from './usage.js'; import { recordUsage } from './usage.js';
import { line, error } from './log.js'; import { line, error } from './log.js';
/**
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。
*
* 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider
* 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。
*
* @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。
* @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。
*/
function opencodeModelConfig(model) { function opencodeModelConfig(model) {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID }; return { providerID, modelID };
} }
/**
* 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。
*
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。
*
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent }} axios 請求選項物件。
*/
function opencodeAxiosOptions(headers) { function opencodeAxiosOptions(headers) {
return { return {
headers, headers,
@@ -15,6 +32,15 @@ function opencodeAxiosOptions(headers) {
}; };
} }
/**
* 從 OpenCode 訊息回應中抽取並串接所有文字片段。
*
* 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` /
* `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接。
*
* @param {object} data - OpenCode `/session/{id}/message` 的回應資料物件。
* @returns {string} 串接後的純文字內容;無可用片段時回傳空字串。
*/
function extractOpenCodeContent(data) { function extractOpenCodeContent(data) {
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || []; const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
return parts return parts
@@ -23,6 +49,21 @@ function extractOpenCodeContent(data) {
.join(''); .join('');
} }
/**
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
*
* 先 POST `/session` 取得 session id(缺少則拋錯),再 POST
* `/session/{id}/message` 送出 system prompt 與使用者內容,最後抽取回應文字。
* 會發出兩次 HTTP 請求;網路或 API 錯誤會向外拋出,交由呼叫端處理。
*
* @param {string} baseURL - OpenCode server 基底 URL(尾端斜線會被去除)。
* @param {string} model - 模型識別字串,將交由 {@link opencodeModelConfig} 解析。
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @param {Record<string, string>} headers - 附加於請求的 HTTP 標頭。
* @returns {Promise<{ content: string, data: object }>} 抽取後的文字內容與原始回應資料。
* @throws {Error} 當回應中無 session id,或任一 HTTP 請求失敗時。
*/
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) { async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
const base = baseURL.replace(/\/$/, ''); const base = baseURL.replace(/\/$/, '');
const { providerID, modelID } = opencodeModelConfig(model); const { providerID, modelID } = opencodeModelConfig(model);
@@ -46,6 +87,18 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
return { content: extractOpenCodeContent(resp.data), data: resp.data }; return { content: extractOpenCodeContent(resp.data), data: resp.data };
} }
/**
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
*
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)`
* 終止整個行程(不會回傳)。
*
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @returns {Promise<string>} 模型回應的純文字內容。
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時。
*/
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const { provider, baseURL, model } = getLLMConfig(); const { provider, baseURL, model } = getLLMConfig();
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL'); if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
@@ -65,6 +118,16 @@ export async function chat(systemPrompt, userContent) {
process.exit(1); process.exit(1);
} }
/**
* 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。
*
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
*
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @returns {Promise<any>} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。
*/
export async function chatJSON(systemPrompt, userContent) { export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent); const text = await chat(systemPrompt, userContent);
try { try {
@@ -75,6 +138,15 @@ export async function chatJSON(systemPrompt, userContent) {
} }
} }
/**
* 去除文字外層的 Markdown code fence```),用於清理被 code block 包裹的輸出。
*
* 會 trim、移除開頭 fence(含可選語言標籤與換行)與結尾 fence,再 trim。
* 對非字串輸入會先以 `String()` 轉換;無 fence 時回傳 trim 後原文。
*
* @param {*} text - 待清理的內容(會被轉為字串)。
* @returns {string} 去除外層 fence 並 trim 後的字串。
*/
function stripOuterFence(text) { function stripOuterFence(text) {
return String(text) return String(text)
.trim() .trim()
@@ -83,6 +155,16 @@ function stripOuterFence(text) {
.trim(); .trim();
} }
/**
* 從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串。
*
* 依起始字元判定為物件(`{}`)或陣列(`[]`),逐字元計數巢狀深度,
* 並正確略過字串字面值與其中的跳脫字元,深度歸零時回傳完整片段。
*
* @param {*} text - 來源內容(會被轉為字串)。
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
*/
function extractBalancedJSON(text, startIndex) { function extractBalancedJSON(text, startIndex) {
const source = String(text); const source = String(text);
const open = source[startIndex]; const open = source[startIndex];
@@ -116,6 +198,16 @@ function extractBalancedJSON(text, startIndex) {
return null; return null;
} }
/**
* 從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段。
*
* 先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個
* `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;
* 全數失敗則回傳去 fence 後的原文(仍可能非合法 JSON,交由呼叫端再處理)。
*
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
*/
function extractJSONText(text) { function extractJSONText(text) {
const stripped = stripOuterFence(text); const stripped = stripOuterFence(text);
try { try {
+72 -3
View File
@@ -1,38 +1,107 @@
/**
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `=== 標題 ===`)。
* 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」),
* 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。
*
* @param {string} title - 區塊標題文字。
* @returns {void} 無回傳值,僅將標題寫入 stdout。
*/
export function section(title) { export function section(title) {
console.log(`\n=== ${title} ===`); console.log(`\n=== ${title} ===`);
} }
/**
* 輸出某個「步驟」的標題(前綴空行 + `[步驟代號] 標題`)。
* 適合在一個 section 之下標示流程中的各個有序步驟(如 `[1] 載入設定`、`[2] 呼叫模型`),
* 之後再用 input / output / line 等細項函式描述該步驟的細節。
*
* @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。
* @param {string} title - 步驟標題文字。
* @returns {void} 無回傳值,僅將步驟標題寫入 stdout。
*/
export function step(stepName, title) { export function step(stepName, title) {
console.log(`\n[${stepName}] ${title}`); console.log(`\n[${stepName}] ${title}`);
} }
/**
* 輸出一筆縮排的一般明細列(` - 訊息`)。
* 用於在某個 step 之下列出不帶語意成敗的中性資訊(例如逐項說明、設定值、進度敘述);
* 若要表達輸入/輸出或成敗,請改用 input / output / result / ok 等更具語意的函式。
*
* @param {string} message - 要顯示的明細訊息。
* @returns {void} 無回傳值,僅將明細寫入 stdout。
*/
export function line(message) { export function line(message) {
console.log(` - ${message}`); console.log(` - ${message}`);
} }
/** 階段輸入:這個階段吃進什麼。 */ /**
* 輸出「階段輸入」描述(` ← 輸入:訊息`),標示目前步驟吃進了什麼資料。
* 在一個步驟開始處理前,用來明確記錄其輸入來源或內容,方便日後對照輸出(output)追蹤資料流。
*
* @param {string} message - 描述輸入內容的訊息。
* @returns {void} 無回傳值,僅將輸入描述寫入 stdout。
*/
export function input(message) { export function input(message) {
console.log(` ← 輸入:${message}`); console.log(` ← 輸入:${message}`);
} }
/** 階段輸出:這個階段產出什麼。 */ /**
* 輸出「階段輸出」描述(` → 輸出:訊息`),標示目前步驟產出了什麼結果。
* 在一個步驟處理完成後,用來記錄其產出,與 input 搭配可在 log 中清楚呈現該步驟的資料流向。
*
* @param {string} message - 描述輸出內容的訊息。
* @returns {void} 無回傳值,僅將輸出描述寫入 stdout。
*/
export function output(message) { export function output(message) {
console.log(` → 輸出:${message}`); console.log(` → 輸出:${message}`);
} }
/** 檢查/把關結果:明確標示成功或失敗。 */ /**
* 輸出一筆檢查/把關結果列,依結果以 `✅ 成功` 或 `❌ 失敗` 為前綴(` ✅ 成功:訊息`)。
* 用於明確標示某個驗證、條件判斷或 gate 的通過與否;
* 需要由布林值決定成敗、且希望成功與失敗使用一致格式時最適合(注意:失敗仍寫入 stdout,非 stderr)。
*
* @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。
* @param {string} message - 描述該結果的訊息。
* @returns {void} 無回傳值,僅將結果寫入 stdout。
*/
export function result(passed, message) { export function result(passed, message) {
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}${message}`); console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}${message}`);
} }
/**
* 輸出一筆成功/完成訊息(` ✓ 訊息`)。
* 用於確認某項動作已順利完成的正向回饋;當只需表達成功、無需處理失敗分支時使用,
* 若需依條件同時涵蓋成功與失敗請改用 result,需要警告或錯誤請改用 warn / error。
*
* @param {string} message - 描述成功內容的訊息。
* @returns {void} 無回傳值,僅將成功訊息寫入 stdout。
*/
export function ok(message) { export function ok(message) {
console.log(`${message}`); console.log(`${message}`);
} }
/**
* 輸出一筆警告訊息(` ! 訊息`),透過 `console.warn` 寫入 stderr。
* 用於流程仍可繼續、但需要提醒使用者注意的非致命狀況(例如使用了預設值、跳過某項可選步驟);
* 比 line/ok 更醒目,但比 error 輕,真正導致失敗的狀況請改用 error。
*
* @param {string} message - 要顯示的警告訊息。
* @returns {void} 無回傳值,僅將警告訊息寫入 stderr。
*/
export function warn(message) { export function warn(message) {
console.warn(` ! ${message}`); console.warn(` ! ${message}`);
} }
/**
* 輸出一筆錯誤訊息(` x 訊息`),透過 `console.error` 寫入 stderr。
* 用於明確的失敗或例外狀況,是日誌中最高的嚴重層級;
* 適合在捕捉到錯誤或前置條件不滿足而無法繼續時使用,僅需提醒注意的非致命狀況請改用 warn。
*
* @param {string} message - 要顯示的錯誤訊息。
* @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。
*/
export function error(message) { export function error(message) {
console.error(` x ${message}`); console.error(` x ${message}`);
} }
+38
View File
@@ -13,6 +13,44 @@ import { section, step, line, input, output, result, warn, error } from './log.j
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
/**
* AI Code Review Pipeline 的總指揮(orchestrator)。
*
* 依序串接 Step1~Step11:啟動參數讀取、前置驗證、自動提交檢查、PR 對話收斂、
* 角色平行分析產生 findings、新舊 findings 合併與語意去重、排除規則與誤報過濾、
* 寫入 findings 並發布 Gitea Review、findings/exclusions JSON 格式驗證、
* 記憶區 commit/push,以及嚴重問題把關。
*
* 結果主要透過 `process.exit()` 決定 workflow 成敗,而非以回傳值傳遞。
*
* @async
* @returns {Promise<void>} 流程正常走完(無嚴重問題)時 resolve;多數結束路徑會直接
* 呼叫 `process.exit()` 結束程序,函式不會以回傳值回報審查結果。
* @throws {Error} 內部未被個別 try/catch 攔截的未預期例外會向上拋出,
* 由頂層 `main().catch(...)` 接住並以 `process.exit(1)` 結束。
*
* @remarks
* 流程階段(Step1~Step11):
* - Step1 啟動:讀取 repo / PR / 分支等基本參數。
* - Step2 前置驗證:`runPreflight`,未通過則 exit 1。
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`exit 1)或本次為 bot 自動提交(exit 0 跳過)。
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
* 未設定 API Key 或取 diff 失敗 exit 1diff 為空 exit 0。
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
* - Step9 JSON 驗證:驗證 findings/exclusions 檔,格式錯誤 exit 1,缺檔則建立空陣列檔。
* - Step10 記憶區 commit/push:依是否有 critical 計算 reviewOutcome 後推回來源分支。
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
*
* 退出行為:
* - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
*
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
*/
async function main() { async function main() {
section('AI Code Review Pipeline'); section('AI Code Review Pipeline');
+86 -8
View File
@@ -13,24 +13,72 @@ import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error, result } from './log.js'; import { step, line, ok, error, result } from './log.js';
const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const httpsAgent = new https.Agent({ rejectUnauthorized: false });
/**
* 組出 Gitea REST API v1 的完整網址。
*
* 會將模組層級的 GITEA_SERVER_URL 尾端斜線去除後串接 `/api/v1` 與傳入路徑。
* @param {string} path - 以 `/` 開頭的 API 子路徑,例如 `/repos/owner/name`。
* @returns {string} 完整可請求的 API URL。
* @remarks 依賴模組層級常數 GITEA_SERVER_URL;若該值為空會丟出 TypeError。
*/
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
/**
* 產生呼叫 Gitea API 用的 HTTP headers。
*
* Authorization 採 Gitea 的 `token <token>` 認證格式。
* @param {string} token - Gitea 個人存取權杖(personal access token)。
* @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。
*/
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
/**
* 將模型字串解析為 OpenCode 的 providerID 與 modelID。
*
* 若 model 含 `/` 則以斜線拆分為 provider/model;否則 provider 取
* 環境變數 OPENCODE_PROVIDER(預設 `google`),model 即原字串。
* @param {string} model - 模型識別字串,例如 `google/gemini-2.0` 或 `gemini-2.0`。
* @returns {{providerID: string, modelID: string}} 解析後的 provider 與 model 識別碼。
* @remarks 讀取 process.env.OPENCODE_PROVIDERsplit 上限為 2 段,多餘段落會被忽略。
*/
const opencodeModelConfig = (model) => { const opencodeModelConfig = (model) => {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID }; return { providerID, modelID };
}; };
/**
* 組出呼叫 OpenCode server 用的 axios 請求設定。
*
* 固定 30 秒逾時,並套用 config.js 的 getOpenCodeHttpsAgent() 作為 httpsAgent。
* @param {object} headers - 要套用的 HTTP headers 物件。
* @returns {{headers: object, timeout: number, httpsAgent: import('https').Agent}} axios 設定物件。
* @remarks 每次呼叫都會執行 getOpenCodeHttpsAgent() 取得 agent。
*/
const opencodeAxiosOptions = (headers) => ({ const opencodeAxiosOptions = (headers) => ({
headers, headers,
timeout: 30000, timeout: 30000,
httpsAgent: getOpenCodeHttpsAgent(), httpsAgent: getOpenCodeHttpsAgent(),
}); });
/**
* 將(axios)錯誤格式化為易讀的訊息字串。
*
* 有 HTTP 回應狀態碼時輸出 `HTTP <status> <message>`,否則僅輸出 message。
* @param {Error & {response?: {status?: number}, message: string}} e - 捕捉到的錯誤物件。
* @returns {string} 格式化後的錯誤描述。
*/
function giteaErr(e) { function giteaErr(e) {
const status = e.response?.status; const status = e.response?.status;
return status ? `HTTP ${status} ${e.message}` : e.message; return status ? `HTTP ${status} ${e.message}` : e.message;
} }
/** 檢查必要環境變數是否齊全;可傳入覆寫值供測試使用 */ /**
* 檢查 code review 所需的必要環境變數是否齊全。
*
* 用法:preflight 第一關,缺任何一項即視為不通過並列出缺少項目。
* @param {object} [opts] - 覆寫值,供測試注入;省略時各欄取模組層級常數預設值。
* @param {string} [opts.token=GITEA_TOKEN] - Gitea token。
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
*/
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) { export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
const missing = []; const missing = [];
if (!token) missing.push('GITEA_TOKEN'); if (!token) missing.push('GITEA_TOKEN');
@@ -39,7 +87,15 @@ export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY,
return { ok: missing.length === 0, missing }; return { ok: missing.length === 0, missing };
} }
/** 用 GITEA_TOKEN 讀取此 repo,同時驗證 token 有效與有讀取權限 */ /**
* 驗證 Gitea token 有效且對指定 repo 有讀取權限。
*
* 透過唯讀的 `GET /repos/{repo}` 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。
* 採用 rejectUnauthorized:false 的 httpsAgent(不驗證 TLS 憑證)。
* @param {string} [token=GITEA_TOKEN] - Gitea token,可注入供測試。
* @param {string} [repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo,可注入供測試。
* @returns {Promise<{ok: true}|{ok: false, error: string}>} 成功僅含 ok;失敗含格式化錯誤訊息。
*/
export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) { export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) {
try { try {
await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent }); await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
@@ -49,7 +105,14 @@ export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSIT
} }
} }
/** 若有提供 comment token,用它呼叫 /user 驗證可用;沒提供則略過 */ /**
* 驗證選用的 comment tokenGITEA_COMMENT_TOKEN)是否可用。
*
* 未提供 token 時直接視為通過並標記 skipped:true(之後 comment 會沿用主 token);
* 有提供則以 `GET /user` 探測。錯誤被攔截轉為回傳值,不會 throw。
* @param {string} [token=GITEA_COMMENT_TOKEN] - 專用於發布 comment 的 token,可注入供測試。
* @returns {Promise<{ok: true, skipped?: true}|{ok: false, error: string}>} skipped 表示未提供而略過。
*/
export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
if (!token) return { ok: true, skipped: true }; if (!token) return { ok: true, skipped: true };
try { try {
@@ -61,9 +124,13 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
} }
/** /**
* 驗證 LLM 設定可用 * 驗證 LLMOpenCode server設定可用
* - 僅支援 OpenCode server *
* - 檢查 OpenCode base URL 是否可連線,並確認 provider/model 已設定 * 依序確認:已設定 provider、有 base URL、health 端點可連線、OpenCode 已設定
* 對應 provider 且其列出指定 model。任一不符回傳對應錯誤;錯誤被攔截不會 throw。
* @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>}
* 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。
* @remarks 設定來源為 config.js 的 getLLMConfig()provider/model 鍵的比對使用 opencodeModelConfig 解析後的 providerID/modelID。
*/ */
export async function verifyLLM() { export async function verifyLLM() {
const { provider, baseURL, model } = getLLMConfig(); const { provider, baseURL, model } = getLLMConfig();
@@ -87,8 +154,19 @@ export async function verifyLLM() {
} }
/** /**
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM
* 僅做唯讀的認證/連線確認,不發布任何 comment。 *
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
* 各檢查可經 deps 注入覆寫,方便單元測試。
* @param {string} [workspace=process.env.GITHUB_WORKSPACE||'/workspace'] - git 遠端驗證用的工作目錄。
* @param {object} [deps] - 依賴注入,覆寫各檢查函式(預設為本模組/ git.js 的實作)。
* @param {Function} [deps.checkEnv=checkRequiredEnv] - 環境變數檢查。
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLMOpenCode)連線驗證。
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
*/ */
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) { export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
const { const {
+48 -5
View File
@@ -13,7 +13,15 @@ const FIELD_PATTERNS = {
建議: /\*\*建議\*\*[:]\s*(.+)/, 建議: /\*\*建議\*\*[:]\s*(.+)/,
}; };
/** 取出 "**label**value" 這一行的 value(單行)。 */ /**
* 從 Markdown 內文擷取單行欄位值,對應格式為 `**標籤**:value`(全形或半形冒號皆可)。
* 僅支援預先編譯於 FIELD_PATTERNS 的標籤:嚴重等級/等級/審查員/問題/建議;
* 標籤不在表內或無命中時回傳空字串。
* @param {string} body - 已正規化換行(\n)的留言內文;呼叫端須先確保為字串。
* @param {('嚴重等級'|'等級'|'審查員'|'問題'|'建議')} label - 要擷取的欄位標籤鍵。
* @returns {string} 該行的 value(已 trim);找不到或標籤不支援時為 ''。
* @remarks 正則為靜態定義,避免每次呼叫重建並排除以外部輸入動態組 regex 的注入風險。
*/
function fieldValue(body, label) { function fieldValue(body, label) {
const re = FIELD_PATTERNS[label]; const re = FIELD_PATTERNS[label];
if (!re) return ''; if (!re) return '';
@@ -21,6 +29,12 @@ function fieldValue(body, label) {
return m ? m[1].trim() : ''; return m ? m[1].trim() : '';
} }
/**
* 將中文嚴重等級描述(如「嚴重」「警告」「建議」)映射為內部標準鍵。
* 採子字串比對且依序判斷,第一個命中者勝出。
* @param {string} raw - 來自留言的嚴重等級文字(可能為空)。
* @returns {('critical'|'warning'|'info'|null)} 對應的內部鍵;空字串或無法辨識時回傳 null。
*/
function levelToKey(raw) { function levelToKey(raw) {
if (!raw) return null; if (!raw) return null;
if (raw.includes('嚴重')) return 'critical'; if (raw.includes('嚴重')) return 'critical';
@@ -124,12 +138,24 @@ export async function judgeConversations(items, chatFn = chatJSON) {
return items.map(it => ({ idx: it.idx, verdict: byIdx.get(it.idx) || 'open' })); return items.map(it => ({ idx: it.idx, verdict: byIdx.get(it.idx) || 'open' }));
} }
/**
* 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。
* 若該對話無 botFinding 則不做任何事。
* @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。
* @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。
* @returns {void}
*/
function pushCarried(target, conversation) { function pushCarried(target, conversation) {
if (!conversation.botFinding) return; if (!conversation.botFinding) return;
target.push({ ...conversation.botFinding, is_new: false }); target.push({ ...conversation.botFinding, is_new: false });
} }
/** 把判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。 */ /**
* 將判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。
* original_finding 取 suggestion,缺則退回 problem 再退回空字串;reason 為固定的誤報說明。
* @param {{location: string, role: string, suggestion?: string, problem?: string}} botFinding - 被判為誤報的 finding(呼叫端須確保非 null)。
* @returns {{location: string, role: string, original_finding: string, reason: string}} 排除條目。
*/
function toExclusion(botFinding) { function toExclusion(botFinding) {
return { return {
location: botFinding.location, location: botFinding.location,
@@ -140,8 +166,10 @@ function toExclusion(botFinding) {
} }
/** /**
* 僅允許 repo 內的相對路徑:排除絕對路徑/ 或 Windows 磁碟機與含 `..` 的路徑穿越。 * 安全守衛:判定路徑是否為 repo 內的相對路徑(拒絕絕對路徑Windows 磁碟機前綴與含 `..` 的路徑穿越
* comment 的 path 源自外部PR 檔名),用此守衛避免被用來讀取 repo 外的檔案。 * 用於防止以外部 PR 檔名讀取 repo 外的檔案。
* @param {string} p - 待檢查的檔案路徑。
* @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。
*/ */
function isSafeRepoPath(p) { function isSafeRepoPath(p) {
if (typeof p !== 'string' || p === '') return false; if (typeof p !== 'string' || p === '') return false;
@@ -263,10 +291,21 @@ export async function reconcileConversations(deps = {}) {
}; };
} }
/**
* 從 location(格式 `path:line`)取出檔案路徑部分(以第一個冒號切割並 trim)。
* @param {string} location - 位置字串,可能為 `path:line` 或僅 `path`(容許 null/undefined)。
* @returns {string} 檔案路徑;無輸入時為空字串。
*/
function fileOf(location) { function fileOf(location) {
return String(location || '').split(':')[0].trim(); return String(location || '').split(':')[0].trim();
} }
/**
* 將文字正規化為穩定比對鍵:NFKC 正規化後移除所有標點/符號/空白,再 trim 並轉小寫。
* 用於讓 finding 簽章對標點與空白差異不敏感。
* @param {string} text - 待正規化文字(容許 null/undefined)。
* @returns {string} 正規化後的小寫鍵。
*/
function normalizeKey(text) { function normalizeKey(text) {
return String(text || '') return String(text || '')
.normalize('NFKC') .normalize('NFKC')
@@ -275,7 +314,11 @@ function normalizeKey(text) {
.toLowerCase(); .toLowerCase();
} }
/** 以「檔案路徑 + 正規化建議內容」為簽章,對 line 漂移與標點差異穩定。 */ /**
* 計算 finding 的去重簽章:以「檔案路徑 + 正規化建議內容」組成,對行號漂移與標點差異穩定。
* @param {{location?: string, suggestion?: string}} f - finding 物件(容許欄位缺漏)。
* @returns {string} 形如 `檔案路徑|正規化建議` 的簽章字串。
*/
function findingSig(f) { function findingSig(f) {
return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`; return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`;
} }
+88 -14
View File
@@ -7,8 +7,20 @@ import { warn } from './log.js';
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles'); const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
/** /**
* 解析單一角色 .md 檔:前置 YAML frontmatter(徽章、代表色、面向、個性等)+ 本文(審查重點) * 解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter 與本文
* 回傳合併後的角色物件:{ name, side, focus, badge, color, personality, body }。 *
* 會先將 CRLF 正規化為 LF,再以 `---` 分隔線切出 frontmatter(徽章、代表色、
* 面向、個性等欄位)與其後的本文(審查重點 / 裁決準則)。frontmatter 欄位會
* 被攤平到回傳物件,本文則放入 `body`(已去除頭尾空白)。
*
* @param {string} content - 角色 `.md` 檔的完整文字內容。
* @returns {{ name?: string, side?: string, focus?: string, badge?: string,
* color?: string, personality?: string, body: string,
* [key: string]: unknown }} 合併 frontmatter 與本文後的角色物件。
* @throws {Error} 當內容缺少合法 `---` frontmatter 區塊時拋出「角色檔缺少 frontmatter」。
* @throws {import('js-yaml').YAMLException} 當 frontmatter 不是合法 YAML 時(由 `yaml.load` 拋出,未攔截)。
*
* @remarks 純字串處理,無任何檔案 IOfrontmatter 中若自帶 `body` 欄位會被本文覆蓋。
*/ */
export function parseRoleFile(content) { export function parseRoleFile(content) {
const normalized = content.replace(/\r\n/g, '\n'); const normalized = content.replace(/\r\n/g, '\n');
@@ -21,8 +33,16 @@ export function parseRoleFile(content) {
let cachedRoles = null; let cachedRoles = null;
/** /**
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變) * 讀取並解析 `ROLES_DIR` 下所有角色 `.md` 檔,依檔名排序後回傳角色陣列
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。 *
* 結果快取於模組層級(`cachedRoles`),同一程序生命週期內只讀檔一次;之後即使
* 角色檔有變動也不會重新載入,需重啟程序才會生效。單一檔案解析失敗(壞 YAML、
* 缺 frontmatter 等)只記錄警告並略過,不會中斷其他角色的載入。
*
* @returns {Array<ReturnType<typeof parseRoleFile>>} 已解析的角色物件陣列(依檔名排序)。
*
* @remarks 模組私有函式;使用同步檔案 IO。目錄不存在或無權限時,`fs.readdirSync`
* 會在容錯範圍外拋出錯誤。
*/ */
function readRoleFiles() { function readRoleFiles() {
if (cachedRoles) return cachedRoles; if (cachedRoles) return cachedRoles;
@@ -39,22 +59,47 @@ function readRoleFiles() {
} }
/** /**
* 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。 * 載入所有「攻擊方角色(frontmatter `side === 'attack'`),依檔名排序。
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。 *
* 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列,
* 其裁決邏輯由去重 / 誤報過濾流程處理。
*
* @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
*
* @remarks 透過 `readRoleFiles` 取得快取後的全部角色再過濾,首次呼叫會觸發檔案讀取。
*/ */
export function loadRoles() { export function loadRoles() {
return readRoleFiles().filter(r => r.side === 'attack'); return readRoleFiles().filter(r => r.side === 'attack');
} }
/** 依 frontmatter name 取得單一角色(不分大小寫),找不到回傳 null。 */ /**
* 依 frontmatter `name` 取得單一角色(比對不分大小寫),找不到回傳 `null`。
*
* 不分攻擊方 / 防守方,所有已成功載入的角色皆可查得。
*
* @param {string} name - 角色名稱(大小寫不拘)。
* @returns {ReturnType<typeof parseRoleFile> | null} 對應角色物件,無對應時為 `null`。
*
* @remarks 透過 `readRoleFiles` 取得快取角色清單,首次呼叫會觸發檔案讀取。
*/
export function loadRole(name) { export function loadRole(name) {
const target = String(name).toLowerCase(); const target = String(name).toLowerCase();
return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null; return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null;
} }
/** /**
* 由角色定義組出攻擊方的 system prompt * 由攻擊方角色定義組出其分析用 system prompt
* 套用其個性與審查重點本文,並要求以固定 JSON 陣列格式回傳 findings。 *
* 套用角色的徽章、名稱、面向(focus,缺省為「綜合」)、個性(personality,可選)
* 與審查重點本文(body),並附上固定指示:分析 Git Diff 僅針對新增/修改處找問題,
* 並以固定 JSON 陣列格式(level / role / location / problem / suggestion)回傳 findings
* 強制每條問題帶 `檔案路徑:行號`。
*
* @param {ReturnType<typeof parseRoleFile>} role - 攻擊方角色物件(需含 `name`、`body``badge`/`focus`/`personality` 可選)。
* @returns {string} 組裝完成的多行 system prompt 文字。
* @throws {TypeError} 當 `role` 為 `null`/`undefined` 時(未做防呆,存取屬性即拋出)。
*
* @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。
*/ */
export function buildAnalysisPrompt(role) { export function buildAnalysisPrompt(role) {
return [ return [
@@ -90,8 +135,16 @@ export function buildAnalysisPrompt(role) {
} }
/** /**
* 由角色定義組出「補行號」的 system prompt * 組出「補行號」的 system prompt
* 當該角色先前提出的問題只有檔名、缺行號時,請它對照 Git Diff 找出實際行號。 *
* 用於某角色先前提出的 finding 其 `location` 只有檔名、缺行號的情境:請 LLM 對照
* 該檔 Git Diff 找出問題對應的實際行號,並只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。
*
* @param {ReturnType<typeof parseRoleFile> | null | undefined} [role] - 角色物件;可省略或為 null,
* 此時名稱退回 `'AI Review'` 且不帶徽章與面向子句。
* @returns {string} 組裝完成的多行 system prompt 文字。
*
* @remarks 使用選擇性串接(`?.`),對 `role` 為空值具防呆,不會拋出例外。
*/ */
export function buildLocateLinePrompt(role) { export function buildLocateLinePrompt(role) {
const name = role?.name || 'AI Review'; const name = role?.name || 'AI Review';
@@ -104,9 +157,18 @@ export function buildLocateLinePrompt(role) {
} }
/** /**
* 由防守方角色定義組出「單條 finding 誤報裁決」的 system prompt * 由防守方角色定義組出「單條 finding 誤報裁決」的 system prompt
* 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。 *
* role 為 null 時退回不帶角色的通用裁判 prompt。 * `role` 存在時套用其徽章、名稱、面向(focus,缺省「裁決」)、個性與裁決準則本文(body);
* `role` 為空值時退回固定的通用裁判 persona(🛡️ Paladin 聖騎士)。prompt 要求對一條
* 攻擊方 finding 判定「成立 / 誤報」,並只回 `{"verdict", "reason"}`;無法確定時一律回
* `"confirmed"`(寧可保留、不冤枉)。
*
* @param {ReturnType<typeof parseRoleFile> | null | undefined} role - 防守方角色物件;為空值時改用通用裁判 persona。
* @param {string} [exclusionHint=''] - 額外的排除 / 已知誤報提示文字;為空字串時該行會被略過。
* @returns {string} 組裝完成的多行 system prompt 文字。
*
* @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。
*/ */
export function buildVerdictPrompt(role, exclusionHint = '') { export function buildVerdictPrompt(role, exclusionHint = '') {
const persona = role const persona = role
@@ -129,6 +191,18 @@ export function buildVerdictPrompt(role, exclusionHint = '') {
].filter(l => l !== '').join('\n'); ].filter(l => l !== '').join('\n');
} }
/**
* 由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。
*
* 表格含三欄:角色(粗體,含徽章)、面向(focus)、個性(personality);缺省欄位以空字串呈現。
* 通常用於 PR 留言 / 審查報告開頭呈現參與審查的角色陣容。
*
* @param {Array<ReturnType<typeof parseRoleFile>>} roles - 角色物件陣列(每個可含 `badge`/`name`/`focus`/`personality`)。
* @returns {string} Markdown 格式的多行表格字串。
* @throws {TypeError} 當 `roles` 非可迭代值(如 `null`/`undefined`)時,`for...of` 會拋出。
*
* @remarks 純字串組裝,無副作用;傳入空陣列會得到只有標題與表頭的表格。
*/
export function getRoleIntro(roles) { export function getRoleIntro(roles) {
const lines = [ const lines = [
'## 🤖 AI Code Review 團隊', '', '## 🤖 AI Code Review 團隊', '',
+61 -10
View File
@@ -4,6 +4,12 @@ import { warn } from './log.js';
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */ /** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
/**
* 安全數字轉換:將任意輸入轉為有限數字,無法轉換或非有限值(NaN/Infinity)一律回 0。
* 常用於正規化外部 API 回應或 HTTP header 取出的值,避免污染後續加總與百分比運算。
* @param {*} x 任意待轉換的值。
* @returns {number} 有限數字;否則為 0。
*/
function num(x) { function num(x) {
const n = Number(x); const n = Number(x);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
@@ -83,7 +89,12 @@ export function resetRunUsage() {
/** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */ /** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */
const rateLimit = { hasData: false, remaining: null, limit: null, kind: null }; const rateLimit = { hasData: false, remaining: null, limit: null, kind: null };
/** 將物件的 key 全部轉小寫,方便對大小寫不敏感的 HTTP header 取值。 */ /**
* 將物件第一層的 key 全部轉為小寫並回傳新物件,方便對大小寫不敏感的 HTTP header 取值。
* 不修改傳入物件;僅處理第一層 key。呼叫端須自行確保傳入為物件。
* @param {Object<string, *>} obj 來源物件(通常為 HTTP response headers)。
* @returns {Object<string, *>} key 全小寫的新物件。
*/
function lowerCaseKeys(obj) { function lowerCaseKeys(obj) {
const out = {}; const out = {};
for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k]; for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k];
@@ -128,12 +139,20 @@ export function resetRateLimit() {
rateLimit.kind = null; rateLimit.kind = null;
} }
/**
* 去除字串結尾的單一斜線(常用於正規化 baseURL 以利串接路徑)。
* 空值會被視為空字串;僅移除最後一個斜線,不處理連續尾斜線。
* @param {*} s 來源字串(通常為 URL)。
* @returns {string} 去除結尾斜線後的字串。
*/
const stripSlash = (s) => String(s || '').replace(/\/$/, ''); const stripSlash = (s) => String(s || '').replace(/\/$/, '');
/** /**
* 以實際 hostname 精確比對是否為 OpenRouter(僅接受 apex 域名 `openrouter.ai`), * 以解析後的 hostname 精確比對 baseURL 是否為 OpenRouter(僅接受 apex 域名 openrouter.ai)。
* 避免被偽造的 baseURL(如 `openrouter.ai.evil.com`、`evil.com/openrouter.ai` 或任何子網域) * 用於 fetchAccountQuota 的安全守門:防止被偽造或子網域 baseURL 矇騙而外洩 API key。
* 矇騙而把 API key 送往非 OpenRouter 主機 * 無法解析為合法 URL 時回 false(不丟例外)
* @param {string} baseURL 待驗證的 base URL。
* @returns {boolean} hostname 恰為 openrouter.ai 時為 true,否則 false。
*/ */
function isOpenRouterBaseURL(baseURL) { function isOpenRouterBaseURL(baseURL) {
try { try {
@@ -144,8 +163,14 @@ function isOpenRouterBaseURL(baseURL) {
} }
/** /**
* OpenRouter:以 API key 呼叫 GET /auth/key 取得額度(可靠)。 * 呼叫 OpenRouter GET /auth/key,以 API key 取得帳號額度(單位 USD credits)。
* 回傳金額單位為 USD credits * remaining 缺漏時以 limit - used 推算;limit 為 null(無上限)時 remaining 亦為 null
* 本函式不攔截例外;HTTP/網路錯誤會向上拋出,由呼叫端負責降級。
* @param {{apiKey:string, baseURL:string}} cfg 連線設定(API key 與 base URL)。
* @param {function(string, object): Promise<{data:*}>} get HTTP GET 函式(可注入,預設 axios.get)。
* @returns {Promise<{available:true, used:number, limit:number|null, remaining:number|null, currency:'USD', source:'openrouter'}>}
* 額度資訊。
* @throws {Error} HTTP 請求失敗(逾時、非 2xx、網路錯誤等)時拋出。
*/ */
async function fetchOpenRouterQuota({ apiKey, baseURL }, get) { async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
const resp = await get(`${stripSlash(baseURL)}/auth/key`, { const resp = await get(`${stripSlash(baseURL)}/auth/key`, {
@@ -193,7 +218,11 @@ export async function fetchAccountQuota(provider, config = {}, deps = {}) {
} }
} }
/** 千分位整數/小數格式。 */ /**
* 將數字格式化為千分位字串(保留原有小數)。null/undefinedNaN 一律回 '0'。
* @param {number|string|null|undefined} n 待格式化的數值。
* @returns {string} 千分位字串,例如 1234567 → '1,234,567'。
*/
function fmt(n) { function fmt(n) {
if (n == null || Number.isNaN(Number(n))) return '0'; if (n == null || Number.isNaN(Number(n))) return '0';
const [int, frac] = String(Number(n)).split('.'); const [int, frac] = String(Number(n)).split('.');
@@ -201,10 +230,22 @@ function fmt(n) {
return frac ? `${withCommas}.${frac}` : withCommas; return frac ? `${withCommas}.${frac}` : withCommas;
} }
/**
* 將數值格式化為金額字串:有幣別時前綴幣別(如 'USD 1,234'),無幣別則只回千分位數字。
* @param {string} currency 幣別代碼(空字串/falsy 表示無幣別)。
* @param {number|string|null|undefined} n 數值。
* @returns {string} 格式化後的金額字串。
*/
function money(currency, n) { function money(currency, n) {
return currency ? `${currency} ${fmt(n)}` : fmt(n); return currency ? `${currency} ${fmt(n)}` : fmt(n);
} }
/**
* 四捨五入到小數一位(用於百分比顯示)。
* 不對非數字防呆;非有限輸入會得到 NaN(呼叫端應先確保為有限數)。
* @param {number|string} n 數值。
* @returns {number} 四捨五入到一位小數的結果。
*/
function round1(n) { function round1(n) {
return Math.round(Number(n) * 10) / 10; return Math.round(Number(n) * 10) / 10;
} }
@@ -212,9 +253,12 @@ function round1(n) {
const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' }; const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' };
/** /**
* 計算剩餘百分比」= remaining / limit × 100。 * 計算剩餘百分比remaining / limit × 100),四捨五入到一位小數
* limit 或 remaining 為 nullundefinedNaNInfinity,或 limit ≤ 0 時回 null * 任一參數為 nullundefinedNaNInfinity,或 limit ≤ 0 時回 null
* 避免算出 Infinity%NaN%負百分比或除以零。 * 避免算出 Infinity%NaN%負百分比或除以零。
* @param {number|null|undefined} remaining 剩餘量。
* @param {number|null|undefined} limit 上限。
* @returns {number|null} 百分比(一位小數);無法計算時為 null。
*/ */
function calculatePercent(remaining, limit) { function calculatePercent(remaining, limit) {
if (remaining == null || limit == null) return null; if (remaining == null || limit == null) return null;
@@ -253,6 +297,13 @@ export function resolveRemainingPercent(quota, rate) {
return { percent: null, reason }; return { percent: null, reason };
} }
/**
* 把 resolveRemainingPercent 的結果格式化為一行 Markdown 文字(剩餘可用百分比與明細)。
* 無法計算時輸出帶原因的說明字串。
* @param {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}} pct
* resolveRemainingPercent 的回傳值。
* @returns {string} 單行 Markdown 字串。
*/
function remainingLine(pct) { function remainingLine(pct) {
if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason}`; if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason}`;
const detail = `${pct.basis}${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`; const detail = `${pct.basis}${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`;
Regular → Executable
+19
View File
@@ -1,6 +1,25 @@
#!/bin/bash #!/bin/bash
# ============================================================
# 用途:Docker action 的進入點(entrypoint),負責啟動 Node.js
# 撰寫的 AI code review 執行器(review runner)。
# 此 script 為容器啟動時執行的第一支程式。
# 更新日期:2026/06/26 11:34:46
# ============================================================
# set -e:開啟「遇到任何指令回傳非零(失敗)即立刻中止 script」模式。
# 為什麼:避免前面步驟失敗卻仍繼續往下執行,確保 review runner 在
# 乾淨且可預期的狀態下啟動。
# 副作用:之後任一指令失敗會讓整支 entrypoint(連同容器)以非零碼結束。
set -e set -e
# echo:印出啟動提示訊息到 stdout,方便在 CI/容器 log 中辨識啟動點。
# 為什麼:提供可觀測性,確認 entrypoint 已被執行。
# 副作用:僅輸出文字,無其他影響。
echo "🚀 AI Code Review Action 啟動" echo "🚀 AI Code Review Action 啟動"
# exec node /app/main.js:用 node 進程「取代」目前的 shell 進程來執行
# 主程式 main.jsAI code review 的實際邏輯入口)。
# 為什麼:使用 exec 而非直接呼叫,可讓 node 成為 PID 1,正確接收
# 容器的訊號(如 SIGTERM),達成優雅關閉並避免殭屍 shell。
# 副作用:此行之後的任何指令都不會被執行;node 的結束碼即為容器結束碼。
exec node /app/main.js exec node /app/main.js