docs(workflow 階段): 將前置驗證改為 Step2

This commit is contained in:
2026-06-22 10:20:52 +00:00
parent 6256576603
commit 4dc50941b0
6 changed files with 28 additions and 28 deletions
+12 -12
View File
@@ -4,20 +4,20 @@
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過) # 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
0. 前置驗證(action 最開始執行、做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1` 1. Pipeline 啟動:輸出 repo、PR 編號、來源分支與目標分支等基本資訊
2. 前置驗證(做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1`
- 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗) - 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗)
- Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效) - Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效)
- 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷 - 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷
- git push 認證可用:用與第 7 點 commit/push 完全相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(例如 `could not read Username`)的問題。此路徑與上面的 REST API 不同,API token 有效不代表 git push 一定能用,故獨立驗證 - git push 認證可用:用與第 8 點 commit/push 完全相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(例如 `could not read Username`)的問題。此路徑與上面的 REST API 不同,API token 有效不代表 git push 一定能用,故獨立驗證
- 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線 - 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線
1. 服務名稱、模型名稱角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request 3. 檢查是否為 AI 助理自動提交;若不是,選定 LLM provider/model、載入角色、取得 PR diff,將服務名稱、模型名稱角色資訊 Comment 到 Pull Request,並讓每個角色個別分析 Git Diff 產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 4. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 問題表格(PR問題表格)覆蓋問題檔案 5. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題 6. 將 PR 問題表格寫入 `.gitea/ai-review/findings.json`,並發布一個 Gitea Review:Review 本文用「嚴重/警告/建議」三欄統計各等級數量;之後將可找出檔案與行數的問題依照嚴重等級排序後加入 Review Comments 內,每個 Comment 包含嚴重等級/審查員/問題/建議
5. 將 PR 問題表格寫入 `.gitea/ai-review/findings.json`,並發布一個 Gitea Review:Review 本文用「嚴重/警告/建議」三欄統計各等級數量;之後將可找出檔案與行數的問題依照嚴重等級排序後加入 Review Comments 內,每個 Comment 包含嚴重等級/審查員/問題/建議 7. 驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON array;格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;檔案不存在則建立並寫入 `[]`
6. 驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON array;格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;檔案不存在則建立並寫入 `[]` 8. Commit 問題檔案,只將 workspace 中實際存在的 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 覆蓋到記憶區;workspace 沒有的問題檔就略過。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
7. Commit 問題檔案,只將 workspace 中實際存在的 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 覆蓋到記憶區;workspace 沒有的問題檔就略過。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑 9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
8. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
# 設計 # 設計
@@ -28,10 +28,10 @@
5. 將提示詞放到 ./app/prompts 內供程式讀取 5. 將提示詞放到 ./app/prompts 內供程式讀取
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
7. 讀取 Git Diff 時排除 `.gitea/``.github/` 資料夾,以及 `TODO.md``README.md`,避免 AI 分析 workflow 設定與文件等非業務程式碼 7. 讀取 Git Diff 時排除 `.gitea/``.github/` 資料夾,以及 `TODO.md``README.md`,避免 AI 分析 workflow 設定與文件等非業務程式碼
8. 階段驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 8. 階段驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題 10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`git push 驗證委派給 `app/git.js``verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷 11. action 一啟動就先做「前置驗證」(流程第 2 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`git push 驗證委派給 `app/git.js``verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷
# 使用說明 # 使用說明
+1 -1
View File
@@ -141,7 +141,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
ok(`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) {
warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
} }
}); });
} catch (e) { } catch (e) {
+1 -1
View File
@@ -193,7 +193,7 @@ describe('commitAndPush', () => {
console.warn = originalWarn; console.warn = originalWarn;
} }
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗'))); assert.ok(logs.some(line => line.includes('Step8 commit 成功但 push 失敗')));
assert.ok(logs.some(line => line.includes('pre-receive hook declined'))); assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
}); });
}); });
+12 -12
View File
@@ -71,7 +71,7 @@ async function main() {
warn(`comment 發布失敗(繼續執行): ${e.message}`); warn(`comment 發布失敗(繼續執行): ${e.message}`);
} }
step('Step2', 'Findings 產生'); step('Step3', '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++) {
@@ -81,9 +81,9 @@ async function main() {
warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
} }
} }
ok(`Step2 完成: 新 findings 總計 ${newFindings.length}`); ok(`Step3 完成: 新 findings 總計 ${newFindings.length}`);
step('Step3', 'Findings 合併與語意去重'); step('Step4', 'Findings 合併與語意去重');
let repoDir; let repoDir;
try { try {
repoDir = cloneRepo(WORKSPACE); repoDir = cloneRepo(WORKSPACE);
@@ -96,28 +96,28 @@ async function main() {
} }
const oldFindings = loadOldFindings(repoDir || WORKSPACE); const oldFindings = loadOldFindings(repoDir || WORKSPACE);
const mergedFindings = mergeFindings(oldFindings, newFindings); const mergedFindings = mergeFindings(oldFindings, newFindings);
ok(`Step3 merged findings total=${mergedFindings.length}`); ok(`Step4 merged findings total=${mergedFindings.length}`);
const deduped = await deduplicateWithAI(mergedFindings); const deduped = await deduplicateWithAI(mergedFindings);
const sorted = sortByLevel(deduped); const sorted = sortByLevel(deduped);
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})`); ok(`Step4 去重完成: ${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})`);
step('Step4', 'AI 排除問題過濾'); step('Step5', 'AI 排除問題過濾');
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions); const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
ok(`Step4 完成: findings total=${filtered.length}`); ok(`Step5 完成: findings total=${filtered.length}`);
step('Step5', 'Findings 寫入與 Review 發布'); step('Step6', 'Findings 寫入與 Review 發布');
const reviewDir = repoDir || WORKSPACE; const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir); saveFindings(WORKSPACE, filtered, reviewDir);
try { try {
await postFindingsReview(filtered); await postFindingsReview(filtered);
ok('Step5 完成'); ok('Step6 完成');
} catch (e) { } catch (e) {
warn(`review 發布失敗(繼續執行): ${e.message}`); warn(`review 發布失敗(繼續執行): ${e.message}`);
} }
step('Step6', 'JSON 格式驗證'); step('Step7', '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);
@@ -133,12 +133,12 @@ async function main() {
ensureJSONArrayFileExists(fullPath, relPath); ensureJSONArrayFileExists(fullPath, relPath);
} }
step('Step7', '記憶區 Commit/Push'); step('Step8', '記憶區 Commit/Push');
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
line(`review outcome=${reviewOutcome}`); line(`review outcome=${reviewOutcome}`);
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
step('Step8', '嚴重問題檢查'); step('Step9', '嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length; const criticalCount = filtered.filter(f => f.level === 'critical').length;
if (criticalCount > 0) { if (criticalCount > 0) {
error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`); error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
+1 -1
View File
@@ -136,7 +136,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
verifyRemote = verifyRemoteAccess, verifyRemote = verifyRemoteAccess,
verifyLLMFn = verifyLLM, verifyLLMFn = verifyLLM,
} = deps; } = deps;
step('Step1.5', '前置驗證(驗證相關設定)'); step('Step2', '前置驗證(驗證相關設定)');
const env = checkEnv(); const env = checkEnv();
if (!env.ok) { if (!env.ok) {
+1 -1
View File
@@ -39,7 +39,7 @@ function readRoleFiles() {
} }
/** /**
* 載入攻擊方角色(Step2 產生 findings 用),依檔名排序。 * 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。 * 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。
*/ */
export function loadRoles() { export function loadRoles() {