Compare commits

..

7 Commits

11 changed files with 149 additions and 137 deletions
+14 -21
View File
@@ -1,30 +1,23 @@
[ [
{
"level": "critical",
"role": "Maya",
"location": "action.yaml:6, action.yaml:81",
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true` 且移除了 `secrets.GITEA_TOKEN` 的 fallback 機制,這是一個關鍵性的行為變更。請務必新增整合測試 (integration tests) 來驗證以下情境:\n1. 當 `inputs.GITEA_TOKEN` 未提供時,Action 應如預期般失敗。\n2. 當 `inputs.GITEA_TOKEN` 有提供時,Action 應能正常執行。\n這將確保新的輸入要求和邏輯變更不會導致意外的行為或破壞現有工作流程。",
"is_new": false
},
{
"level": "critical",
"role": "Leo",
"location": "action.yaml:12",
"suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。目前將其設定為 `required: true` 並移除 `secrets.GITEA_TOKEN` 的 fallback 機制,會導致現有依賴 `secrets.GITEA_TOKEN` 的工作流程中斷,並降低配置的彈性。如果目的是強制透過 `inputs` 傳遞,應在文件明確說明此重大變更及其原因。",
"is_new": false
},
{
"level": "warning",
"role": "Leo",
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。",
"is_new": false
},
{ {
"level": "info", "level": "info",
"role": "Rex", "role": "Rex",
"location": "action.yaml:18", "location": "action.yaml:18",
"suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。",
"is_new": true
},
{
"level": "info",
"role": "Leo",
"location": "app/log.js:19",
"suggestion": "在 `warn` 函式中使用 `console.warn` 而非 `console.log`。雖然目前功能相同,但 `console.warn` 在某些環境下(例如瀏覽器開發者工具)會以不同的樣式呈現警告訊息,有助於區分不同嚴重程度的日誌。",
"is_new": true "is_new": true
} }
] ]
+4 -4
View File
@@ -1,6 +1,6 @@
# 簡介 # 簡介
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。 這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Pull Request 中變更的內容後,將問題分級 Comment 到 Pull Request 中。
# 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過) # 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過)
@@ -16,7 +16,7 @@
# 設計 # 設計
1. Gitea 相關參數如果 inputs 沒有定義,則從 ${{ gitea.* }} 取得 1. Gitea 相關參數中,`GITEA_TOKEN` 必須由 inputs 明確提供;`GITEA_SERVER_URL``GITEA_REPOSITORY``PR_NUMBER``PR_HEAD_BRANCH``PR_BASE_BRANCH` 等欄位若 inputs 沒有定義,則從 `${{ gitea.* }}` 取得
2. BASE_URL 如果 inputs 沒有定義,則使用預設值 2. BASE_URL 如果 inputs 沒有定義,則使用預設值
3. Comment 加上些許 emoji 讓資訊有點活力 3. Comment 加上些許 emoji 讓資訊有點活力
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行 4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
@@ -30,7 +30,7 @@
# 使用說明 # 使用說明
1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾 1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾
2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml' 2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml`
3.`ai-review.yaml` 中填入以下內容(選擇一個使用) 3.`ai-review.yaml` 中填入以下內容(選擇一個使用)
> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot][success]` 或 `[ai-review-bot][failure]`,而且 action 執行時會先透過 Gitea API 檢查這次觸發的 PR head commit(優先用 `pull_request.head.sha`)是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。 > **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot][success]` 或 `[ai-review-bot][failure]`,而且 action 執行時會先透過 Gitea API 檢查這次觸發的 PR head commit(優先用 `pull_request.head.sha`)是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。
@@ -180,7 +180,7 @@ jobs:
issues: write issues: write
``` ```
### - Ollama ### 6. Ollama
```yaml ```yaml
name: AI name: AI
+4 -4
View File
@@ -2,7 +2,7 @@
## 階段一:基本流程串接 ## 階段一:基本流程串接
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。 - 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。 - 驗收:log 中能看到每個階段(如「Step1: Pipeline 啟動」、「Step2: Findings 產生」、「Step3: Findings 合併」等)明確訊息,且流程能走完(即使還沒產生 findings)。
- 已驗收:`code-review` job 的 log 已完整出現 `Step1``Step8`,並以 `Pipeline 完成` 結束。 - 已驗收:`code-review` job 的 log 已完整出現 `Step1``Step8`,並以 `Pipeline 完成` 結束。
## 階段二:Git Diff 排除 .gitea/ 資料夾 ## 階段二:Git Diff 排除 .gitea/ 資料夾
@@ -15,9 +15,9 @@
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。 - 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13` - 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`
## 階段四:AI 去重與角色確認 ## 階段四:AI 語意去重
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。 - 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。 - 驗收:log 中能看到 `AI 去重: N -> M 筆` 的成功訊息,或在失敗時出現 `AI 去重失敗(...),降級保留所有問題` 之類的明確訊息。
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。 - 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
## 階段五:AI 排除問題過濾 ## 階段五:AI 排除問題過濾
+8 -7
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { postComment } from './gitea.js'; import { postComment } from './gitea.js';
import { FINDINGS_PATH } from './config.js'; import { FINDINGS_PATH } from './config.js';
import { ok, line } from './log.js';
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
@@ -27,7 +28,7 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
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(findings, null, 2) + '\n', 'utf8');
console.log(`findings 寫入: ${fullPath} (${findings.length} 筆)`); ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
} }
} }
@@ -37,12 +38,12 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
export async function postOldFindingsComment(findings) { export async function postOldFindingsComment(findings) {
const old = findings.filter(f => !f.is_new); const old = findings.filter(f => !f.is_new);
if (old.length === 0) { if (old.length === 0) {
console.log(' 無舊問題,跳過'); line('無舊問題,跳過');
return; return;
} }
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`; const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
await postComment(body); await postComment(body);
console.log(`舊問題 comment 發布 (${old.length} 筆)`); ok(`舊問題 comment 發布 (${old.length} 筆)`);
} }
/** /**
@@ -51,12 +52,12 @@ export async function postOldFindingsComment(findings) {
export async function postNewNonCriticalComment(findings) { export async function postNewNonCriticalComment(findings) {
const items = findings.filter(f => f.is_new && f.level !== 'critical'); const items = findings.filter(f => f.is_new && f.level !== 'critical');
if (items.length === 0) { if (items.length === 0) {
console.log(' 無新的非嚴重問題,跳過'); line('無新的非嚴重問題,跳過');
return; return;
} }
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`; const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
await postComment(body); await postComment(body);
console.log(`新問題(非嚴重)comment 發布 (${items.length} 筆)`); ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`);
} }
/** /**
@@ -65,12 +66,12 @@ export async function postNewNonCriticalComment(findings) {
export async function postNewCriticalComments(findings) { export async function postNewCriticalComments(findings) {
const criticals = findings.filter(f => f.is_new && f.level === 'critical'); const criticals = findings.filter(f => f.is_new && f.level === 'critical');
if (criticals.length === 0) { if (criticals.length === 0) {
console.log(' 無新的嚴重問題,跳過'); line('無新的嚴重問題,跳過');
return; return;
} }
for (const f of criticals) { for (const f of criticals) {
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
await postComment(body); await postComment(body);
console.log(`嚴重問題 comment 發布: [${f.role}] ${f.location}`); ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
} }
} }
+22 -21
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { chatJSON } from './llm.js'; import { chatJSON } from './llm.js';
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { line, ok, warn } from './log.js';
const LEVELS = ['critical', 'warning', 'info']; const LEVELS = ['critical', 'warning', 'info'];
@@ -9,11 +10,11 @@ const LEVELS = ['critical', 'warning', 'info'];
* 用單一角色分析 diff,回傳 findings 陣列 * 用單一角色分析 diff,回傳 findings 陣列
*/ */
export async function analyzeWithRole(role, diff) { export async function analyzeWithRole(role, diff) {
console.log(` [${role.name}] 開始分析...`); line(`[${role.name}] 開始分析`);
const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`); const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`);
const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion) const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
.map(f => ({ ...f, is_new: true })); .map(f => ({ ...f, is_new: true }));
console.log(` [${role.name}] 找到 ${valid.length} 個問題`); ok(`[${role.name}] 找到 ${valid.length} 個問題`);
return valid; return valid;
} }
@@ -22,14 +23,14 @@ export async function analyzeWithRole(role, diff) {
*/ */
function readJSONArray(fullPath, label) { function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
console.log(` ${label}檔案不存在,視為空`); warn(`${label}檔案不存在,視為空`);
return []; return [];
} }
try { try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} catch (e) { } catch (e) {
console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`); warn(`讀取${label}失敗: ${e.message},視為空`);
return []; return [];
} }
} }
@@ -53,12 +54,12 @@ export function loadOldFindings(workspace) {
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false })); const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
if (fs.existsSync(fullPath)) { if (fs.existsSync(fullPath)) {
const stat = fs.statSync(fullPath); const stat = fs.statSync(fullPath);
console.log(` 讀取舊 findings 檔案: ${fullPath}`); line(`讀取舊 findings 檔案: ${fullPath}`);
console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
} else { } else {
console.log(` 舊 findings 檔案不存在: ${fullPath}`); warn(`舊 findings 檔案不存在: ${fullPath}`);
} }
console.log(` 讀取舊 findings: ${old.length}`); ok(`讀取舊 findings: ${old.length}`);
return old; return old;
} }
@@ -74,7 +75,7 @@ export function mergeFindings(oldFindings, newFindings) {
return true; return true;
}); });
const merged = [...oldFindings, ...deduped]; const merged = [...oldFindings, ...deduped];
console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
return merged; return merged;
} }
@@ -91,7 +92,7 @@ export function sortByLevel(findings) {
function fallback(label, findings, e) { function fallback(label, findings, e) {
const status = e.response?.status; const status = e.response?.status;
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message; const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`); warn(`${label}失敗(${reason}),降級:保留所有問題`);
return findings; return findings;
} }
@@ -111,7 +112,7 @@ export async function deduplicateWithAI(findings) {
try { try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) { if (Array.isArray(result) && result.length > 0) {
console.log(` AI 去重: ${findings.length} -> ${result.length}`); ok(`AI 去重: ${findings.length} -> ${result.length}`);
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回 // 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
@@ -128,13 +129,13 @@ export async function deduplicateWithAI(findings) {
export function loadExclusions(workspace, repoState = null) { export function loadExclusions(workspace, repoState = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
console.log(` 排除問題檔案不存在,視為空: ${fullPath}`); warn(`排除問題檔案不存在,視為空: ${fullPath}`);
if (repoState) { if (repoState) {
const branch = repoState.branch || 'detached'; const branch = repoState.branch || 'detached';
const shortSha = repoState.shortSha || repoState.headSha || 'unknown'; const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`); line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
} }
console.log(' 讀取排除問題: raw=0 normalized=0 筆'); ok('讀取排除問題: raw=0 normalized=0 筆');
return []; return [];
} }
@@ -148,14 +149,14 @@ export function loadExclusions(workspace, repoState = null) {
const branch = repoState?.branch || 'detached'; const branch = repoState?.branch || 'detached';
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown'; const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
const commitTime = repoState?.commitTime || 'unknown'; const commitTime = repoState?.commitTime || 'unknown';
console.log(` 讀取排除問題檔案: ${fullPath}`); line(`讀取排除問題檔案: ${fullPath}`);
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
console.log(` 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`); line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
} catch (e) { } catch (e) {
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
exclusions = []; exclusions = [];
} }
console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length}`); ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length}`);
return exclusions; return exclusions;
} }
@@ -171,7 +172,7 @@ export function applyExclusions(findings, exclusions) {
const exPath = ex.location ? String(ex.location).split(':')[0] : null; const exPath = ex.location ? String(ex.location).split(':')[0] : null;
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role); return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
})); }));
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
return filtered; return filtered;
} }
@@ -190,7 +191,7 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) {
try { try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) { if (Array.isArray(result) && result.length > 0) {
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length}`); ok(`AI 誤報過濾: ${findings.length} -> ${result.length}`);
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
} }
+7 -6
View File
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
import { line, ok, warn } from './log.js';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
@@ -78,11 +79,11 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
return withAskpass(workspace, credEnv => { return withAskpass(workspace, credEnv => {
if (!fs.existsSync(repoDir)) { if (!fs.existsSync(repoDir)) {
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv); run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
console.log(`repo cloned to ${repoDir}`); ok(`repo cloned to ${repoDir}`);
} else { } else {
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
run(['checkout', PR_HEAD_BRANCH], repoDir); run(['checkout', PR_HEAD_BRANCH], repoDir);
console.log(`repo already exists, fetched latest`); ok('repo already exists, fetched latest');
} }
return repoDir; return repoDir;
}); });
@@ -130,7 +131,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
const status = run(['status', '--porcelain'], repoDir); const status = run(['status', '--porcelain'], repoDir);
if (!status) { if (!status) {
console.log(' sync files 無變更,跳過 commit'); line('sync files 無變更,跳過 commit');
return; return;
} }
@@ -139,12 +140,12 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
try { try {
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
console.log(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`); ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
} catch (pushErr) { } catch (pushErr) {
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
} }
}); });
} catch (e) { } catch (e) {
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); warn(`Runner failed: commit/push 失敗: ${e.message}`);
} }
} }
+13 -12
View File
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import https from 'https'; import https from 'https';
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js'; import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js';
import { line, ok, warn } from './log.js';
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
@@ -46,10 +47,10 @@ export async function getCommitMessageBySha(sha) {
httpsAgent, httpsAgent,
}); });
const message = extractCommitMessage(resp.data); const message = extractCommitMessage(resp.data);
console.log(` 🔎 bot-check: commit api sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`); line(`bot-check commit api: sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`);
return message; return message;
} catch (e) { } catch (e) {
console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`); warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`);
return ''; return '';
} }
} }
@@ -63,40 +64,40 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
httpsAgent, httpsAgent,
}); });
const sha = resp.data?.commit?.id || resp.data?.commit?.sha || ''; const sha = resp.data?.commit?.id || resp.data?.commit?.sha || '';
console.log(` 🔎 bot-check: branch api branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`); line(`bot-check branch api: branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`);
return await getCommitMessageBySha(sha); return await getCommitMessageBySha(sha);
} catch (e) { } catch (e) {
console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`); warn(`bot-check branch api 失敗: branch=${branch} error=${e.message}`);
return ''; return '';
} }
} }
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 } = {}) {
console.log(` 🔎 bot-check: start PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`); line(`bot-check start: PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`);
const shaMessage = await getCommitMessageBySha(sha); const shaMessage = await getCommitMessageBySha(sha);
if (sha) { if (sha) {
console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`); line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`);
if (shaMessage.includes('[ai-review-bot]')) { if (shaMessage.includes('[ai-review-bot]')) {
console.log('bot-check: matched commit sha marker'); ok('bot-check matched commit sha marker');
return true; return true;
} }
} else { } else {
console.log(' 🔎 bot-check: skip sha lookup because sha is empty'); line('bot-check skip sha lookup because sha is empty');
} }
const branchMessage = await getBranchHeadCommitMessage(branch); const branchMessage = await getBranchHeadCommitMessage(branch);
if (branch) { if (branch) {
console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`); line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`);
if (branchMessage.includes('[ai-review-bot]')) { if (branchMessage.includes('[ai-review-bot]')) {
console.log('bot-check: matched branch head marker'); ok('bot-check matched branch head marker');
return true; return true;
} }
} else { } else {
console.log(' 🔎 bot-check: skip branch lookup because branch is empty'); line('bot-check skip branch lookup because branch is empty');
} }
console.log(' bot-check: no [ai-review-bot] marker found'); line('bot-check no [ai-review-bot] marker found');
return false; return false;
} }
+7 -6
View File
@@ -1,6 +1,7 @@
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 { ok, warn, error } from './log.js';
const MAX_JSON_BYTES = 1024 * 1024; const MAX_JSON_BYTES = 1024 * 1024;
@@ -50,25 +51,25 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
console.log(` ⚠️ ${label} 不存在,將於驗證後補建`); warn(`${label} 不存在,將於驗證後補建`);
return { exists: false, valid: false, repaired: false }; return { exists: false, valid: false, repaired: false };
} }
try { try {
JSON.parse(readJSONText(fullPath, label)); JSON.parse(readJSONText(fullPath, label));
console.log(`${label} JSON 格式正確`); ok(`${label} JSON 格式正確`);
return { exists: true, valid: true, repaired: false }; return { exists: true, valid: true, repaired: false };
} catch (e) { } catch (e) {
console.error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
try { try {
const original = readJSONText(fullPath, label); const original = readJSONText(fullPath, label);
const repaired = await repairer(fullPath, label, original); const repaired = await repairer(fullPath, label, original);
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8'); fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
JSON.parse(readJSONText(fullPath, label)); JSON.parse(readJSONText(fullPath, label));
console.log(`${label} 已由 AI 修正並通過再次驗證`); ok(`${label} 已由 AI 修正並通過再次驗證`);
return { exists: true, valid: true, repaired: true }; return { exists: true, valid: true, repaired: true };
} catch (repairErr) { } catch (repairErr) {
console.error(`${label} 修正失敗: ${repairErr.message}`); error(`${label} 修正失敗: ${repairErr.message}`);
throw repairErr; throw repairErr;
} }
} }
@@ -82,6 +83,6 @@ export function ensureJSONArrayFileExists(fullPath, label) {
if (fs.existsSync(fullPath)) return false; if (fs.existsSync(fullPath)) return false;
fs.writeFileSync(fullPath, '[]\n', 'utf8'); fs.writeFileSync(fullPath, '[]\n', 'utf8');
console.log(` ⚠️ ${label} 不存在,已建立空陣列`); warn(`${label} 不存在,已建立空陣列`);
return true; return true;
} }
+5 -4
View File
@@ -1,11 +1,12 @@
import axios from 'axios'; import axios from 'axios';
import { getLLMConfig } from './config.js'; import { getLLMConfig } from './config.js';
import { line, error } from './log.js';
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const { provider, apiKeys, baseURL, model } = getLLMConfig(); const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) throw new Error('未設定任何 LLM API Key'); if (!provider) throw new Error('未設定任何 LLM API Key');
console.log(` [LLM] provider=${provider} model=${model}`); line(`[LLM] provider=${provider} model=${model}`);
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
@@ -21,10 +22,10 @@ export async function chat(systemPrompt, userContent) {
); );
return resp.data.choices[0].message.content; return resp.data.choices[0].message.content;
} catch (e) { } catch (e) {
console.log(` [LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
} }
} }
console.error(' [LLM] 所有 API Key 均失敗,終止流程'); error('[LLM] 所有 API Key 均失敗,終止流程');
process.exit(1); process.exit(1);
} }
@@ -33,7 +34,7 @@ export async function chatJSON(systemPrompt, userContent) {
try { try {
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
} catch (e) { } catch (e) {
console.log(` [LLM] JSON 解析失敗: ${e.message}`); line(`[LLM] JSON 解析失敗: ${e.message}`);
return []; return [];
} }
} }
+23
View File
@@ -0,0 +1,23 @@
export function section(title) {
console.log(`\n=== ${title} ===`);
}
export function step(stepName, title) {
console.log(`\n[${stepName}] ${title}`);
}
export function line(message) {
console.log(` - ${message}`);
}
export function ok(message) {
console.log(`${message}`);
}
export function warn(message) {
console.log(` ! ${message}`);
}
export function error(message) {
console.error(` x ${message}`);
}
+41 -51
View File
@@ -6,121 +6,113 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { section, step, line, ok, warn, error } from './log.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
async function main() { async function main() {
console.log('='.repeat(60)); section('AI Code Review Pipeline');
console.log('🚀 Step1: Pipeline 啟動'); step('Step1', 'Pipeline 啟動');
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
const headMessage = await getCommitMessageBySha(headSha); const headMessage = await getCommitMessageBySha(headSha);
const headOutcome = getBotReviewOutcome(headMessage); const headOutcome = getBotReviewOutcome(headMessage);
console.log(` 🔎 head check: sha=${headSha || 'empty'} outcome=${headOutcome}`); line(`head check: sha=${headSha || 'empty'} outcome=${headOutcome}`);
if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') { if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') {
console.log('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); error('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗');
console.log('='.repeat(60)); section('Pipeline 結束');
process.exit(1); process.exit(1);
} }
if (await shouldSkipBotCommit()) { if (await shouldSkipBotCommit()) {
console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); ok('偵測到 [ai-review-bot] 自動提交,直接完成 action');
console.log('='.repeat(60)); section('Pipeline 結束');
process.exit(0); process.exit(0);
} }
const { provider, baseURL, model } = getLLMConfig(); const { provider, baseURL, model } = getLLMConfig();
if (!provider) { if (!provider) {
console.error('未設定任何 LLM API Key,請檢查 action inputs'); error('未設定任何 LLM API Key,請檢查 action inputs');
process.exit(1); process.exit(1);
} }
console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`); line(`LLM: provider=${provider} model=${model} base_url=${baseURL}`);
const roles = loadRoles(); const roles = loadRoles();
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
let diff; let diff;
try { try {
diff = await getPRDiff(); diff = await getPRDiff();
console.log(` diff 長度: ${diff.length} 字元`); line(`diff 長度: ${diff.length} 字元`);
} catch (e) { } catch (e) {
console.error(`取得 diff 失敗: ${e.message}`); error(`取得 diff 失敗: ${e.message}`);
process.exit(1); process.exit(1);
} }
if (!diff.trim()) { if (!diff.trim()) {
console.log(' ⚠️ diff 為空,無需審查'); warn('diff 為空,無需審查');
process.exit(0); process.exit(0);
} }
try { try {
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
await postComment(intro); await postComment(intro);
console.log('角色介紹 comment 發布成功'); ok('角色介紹 comment 發布成功');
} catch (e) { } catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); warn(`comment 發布失敗(繼續執行): ${e.message}`);
} }
// Step2: 各角色分析 diff 產生新 findings step('Step2', 'Findings 產生');
console.log('\n📊 Step2: Findings 產生');
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
const newFindings = []; const newFindings = [];
for (let i = 0; i < results.length; i++) { for (let i = 0; i < results.length; i++) {
if (results[i].status === 'fulfilled') { if (results[i].status === 'fulfilled') {
newFindings.push(...results[i].value); newFindings.push(...results[i].value);
} else { } else {
console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
} }
} }
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length}`); ok(`Step2 完成: 新 findings 總計 ${newFindings.length}`);
// Step4: 讀取舊 findings合併去重(含 AI 語意去重) step('Step3', 'Findings 合併與語意去重');
console.log('\n🔀 Step3: Findings 合併');
// Clone repo 以讀取舊 findings 與排除清單
let repoDir; let repoDir;
try { try {
repoDir = cloneRepo(WORKSPACE); repoDir = cloneRepo(WORKSPACE);
} catch (e) { } catch (e) {
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`); warn(`clone repo 失敗(繼續執行): ${e.message}`);
} }
const repoState = repoDir ? getRepoState(repoDir) : null; const repoState = repoDir ? getRepoState(repoDir) : null;
if (repoState) { if (repoState) {
console.log(` repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); line(`repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`);
} }
const oldFindings = loadOldFindings(repoDir || WORKSPACE); const oldFindings = loadOldFindings(repoDir || WORKSPACE);
const mergedFindings = mergeFindings(oldFindings, newFindings); const mergedFindings = mergeFindings(oldFindings, newFindings);
console.log(` Step3 merged findings total=${mergedFindings.length}`); ok(`Step3 merged findings total=${mergedFindings.length}`);
console.log('\n🤖 Step3b: AI 語意去重');
const deduped = await deduplicateWithAI(mergedFindings); const deduped = await deduplicateWithAI(mergedFindings);
const sorted = sortByLevel(deduped); const sorted = sortByLevel(deduped);
console.log(` Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報 step('Step4', 'AI 排除問題過濾');
console.log('\n🚫 Step4: AI 排除問題過濾');
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
const ruleFiltered = applyExclusions(sorted, exclusions); const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
console.log(` Step4 完成: findings total=${filtered.length}`); ok(`Step4 完成: findings total=${filtered.length}`);
// Step6: 寫入 findings.json,依序發布 comment step('Step5', 'Findings 寫入與 Comment 發布');
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
const reviewDir = repoDir || WORKSPACE; const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir); saveFindings(WORKSPACE, filtered, reviewDir);
try { try {
await postOldFindingsComment(filtered); await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered); await postNewNonCriticalComment(filtered);
await postNewCriticalComments(filtered); await postNewCriticalComments(filtered);
console.log(' Step5 完成'); ok('Step5 完成');
} catch (e) { } catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); warn(`comment 發布失敗(繼續執行): ${e.message}`);
} }
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON step('Step6', 'JSON 格式驗證');
console.log('\n🔎 Step6: JSON 格式驗證');
const missingPaths = []; const missingPaths = [];
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(reviewDir, relPath); const fullPath = path.join(reviewDir, relPath);
@@ -136,26 +128,24 @@ async function main() {
ensureJSONArrayFileExists(fullPath, relPath); ensureJSONArrayFileExists(fullPath, relPath);
} }
// Step7: commit/push findings.json 到來源分支 step('Step7', '記憶區 Commit/Push');
console.log('\n💾 Step7: 記憶區 Commit/Push');
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
console.log(` 🔎 review outcome=${reviewOutcome}`); line(`review outcome=${reviewOutcome}`);
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
// Step9: 有 critical 問題則 exit 1 step('Step8', '嚴重問題檢查');
console.log('\n🚦 Step8: 嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length; const criticalCount = filtered.filter(f => f.level === 'critical').length;
if (criticalCount > 0) { if (criticalCount > 0) {
console.log(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`); error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60)); section('Pipeline 結束');
process.exit(1); process.exit(1);
} }
console.log('無嚴重問題'); ok('無嚴重問題');
console.log('\n✅ Pipeline 完成'); ok('Pipeline 完成');
console.log('='.repeat(60)); section('Pipeline 結束');
} }
main().catch(e => { main().catch(e => {
console.error('❌ Runner failed:', e.message); error(`Runner failed: ${e.message}`);
process.exit(1); process.exit(1);
}); });