Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e623a3f2e | |||
| 0c9748049c | |||
| 3f3ead0f08 | |||
| 8f413439b3 | |||
| 480a0693f7 | |||
| 79506eb905 | |||
| 8872e7366a | |||
| 7616dd1816 | |||
| 9bef365a32 | |||
| 21b3df6d79 | |||
| cc6345c32e | |||
| c758c99a28 | |||
| 505cf6d30d | |||
| c3e57ff442 | |||
| 5876154dbb | |||
| 0e0cd252b0 | |||
| fcc8d59f7a | |||
| a92b6440ff |
@@ -218,5 +218,62 @@
|
|||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/gitea.js",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "TODO.md",
|
||||||
|
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Rex",
|
||||||
|
"location": "app/gitea.js",
|
||||||
|
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Zara",
|
||||||
|
"location": "app/git.js",
|
||||||
|
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Aria",
|
||||||
|
"location": "app/main.js",
|
||||||
|
"suggestion": "在 main.js 中,表達式 repoDir。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Zara",
|
||||||
|
"location": "app/gitea.js:L20-L21",
|
||||||
|
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "TODO.md",
|
||||||
|
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "TODO.md",
|
||||||
|
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/findings.js",
|
||||||
|
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/findings.js",
|
||||||
|
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/comments.js",
|
||||||
|
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/main.js",
|
||||||
|
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/git.js",
|
||||||
|
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/main.js",
|
||||||
|
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "critical",
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/gitea.js:11, app/main.js:42-45",
|
"location": "TODO.md:50",
|
||||||
"suggestion": "`filterDiff` 函數的邏輯已從正規表達式比對改為 `startsWith`,並將其呼叫從 `getPRDiff` 移至 `main.js`。雖然 `startsWith` 可能更高效精確,但這是一個行為變更與職責重分配。請確保為 `filterDiff` 函數撰寫足夠的單元測試,以驗證:\n1. 正確過濾 `.gitea/` 路徑下的檔案。\n2. 不會錯誤過濾非 `.gitea/` 路徑下的檔案。\n3. 處理空 diff 內容。\n4. 處理僅包含 `.gitea/` 檔案的 diff 內容(應返回空字串)。",
|
"suggestion": "階段九是阻擋嚴重問題 PR 的關鍵品質門檻,但目前為「部分驗收」,且其失敗路徑(`exit 1`)仍需「另一次含 critical 的 PR log 驗證」。這表示此關鍵阻擋機制缺乏自動化且持續的測試覆蓋。建議實作一個自動化的端到端測試 (E2E test),專門模擬一個包含嚴重 (critical) 問題的 PR。這將確保 PR 阻擋機制在每次變更後都能被可靠地驗證,而不是依賴手動或偶發的 PR 觸發。",
|
||||||
"is_new": false
|
"is_new": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "warning",
|
||||||
"role": "Rex",
|
"role": "Aria",
|
||||||
"location": "app/gitea.js:11",
|
"location": "TODO.md:19",
|
||||||
"suggestion": "`getPRDiff` 函數現在直接回傳未經篩選的 Git Diff 內容,將 `.gitea/` 資料夾的排除邏輯移至 `main.js`。這改變了 `getPRDiff` 的契約,增加了未來若 `main.js` 未正確呼叫 `filterDiff`,可能導致 `.gitea/` 內敏感配置(如 workflow 設定、潛在的秘密資訊)被傳送給 AI 分析的風險。建議考慮將 `.gitea/` 排除邏輯保留在 `getPRDiff` 內部,或在 `getPRDiff` 的文件註釋中明確指出其輸出是未經篩選的,並強調必須在外部進行敏感路徑過濾。",
|
"suggestion": "在「階段五」中,`可驗收紀錄情境` 作為一個新的頂層項目,與其上方的 `部分驗收` 項目並列,導致該階段的驗收狀態呈現為多個獨立的項目,而非單一狀態的補充說明。建議將 `可驗收紀錄情境` 的內容合併至 `部分驗收` 的描述中,或將其縮排為 `部分驗收` 的子項目,以維持 TODO 列表的結構一致性與可讀性。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:20",
|
||||||
|
"suggestion": "階段四的驗收描述了失敗時的降級處理,但未明確指出此關鍵錯誤處理路徑是否有單元測試覆蓋。建議為 AI 去重與降級處理的邏輯撰寫單元測試,特別是模擬 API 失敗或額度不足的情境,確保降級行為符合預期。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:26",
|
||||||
|
"suggestion": "階段五為「部分驗收」,且驗證依賴於特定情境的 log 輸出。這表示核心的過濾邏輯(包含規則過濾與 AI 誤報過濾)及其降級處理,可能未被全面的單元測試所覆蓋。建議為此階段的邏輯撰寫全面的單元測試,確保所有分支(例如:有排除規則、無排除規則、AI 判斷為誤報、AI 判斷非誤報、API 失敗降級)都能被獨立驗證。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:32",
|
||||||
|
"suggestion": "階段六為「部分驗收」,因本次執行未產生實際 comment 內容,無法完整驗證發布順序。這表示 comment 排序與發布的邏輯,特別是邊界條件(如零 findings),可能未被充分的單元測試所覆蓋。建議為 findings 寫入與 comment 發布的邏輯撰寫單元測試,特別是針對 comment 的排序規則、不同嚴重等級與新舊問題的發布順序,以及零 findings 的邊界條件,確保其行為正確。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:38",
|
||||||
|
"suggestion": "階段七的驗收僅確認了 JSON 格式正確的「正常路徑」。但對於格式錯誤時的關鍵錯誤處理與恢復邏輯(重置為空陣列、備份原檔、修正失敗才 exit 1),未明確指出是否有測試覆蓋。建議為 JSON 格式驗證及其錯誤處理撰寫單元測試,應模擬輸入非法 JSON 格式的檔案,驗證系統能否正確執行備份、重置為空陣列,並在修正失敗時正確終止。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:44",
|
||||||
|
"suggestion": "階段八的驗收僅確認了 commit/push 成功的「正常路徑」。但對於 commit/push 失敗時的錯誤處理機制,未明確指出是否有測試覆蓋。建議為 Git commit/push 的錯誤處理機制撰寫單元測試或整合測試。應模擬 Git 操作失敗的情境(例如:權限不足、網路問題),驗證系統能否正確記錄錯誤日誌並採取適當的錯誤處理。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "TODO.md:62",
|
||||||
|
"suggestion": "階段十一描述了複雜的 AI 傳入內容壓縮與回傳後欄位重建邏輯,但驗收描述中未明確提及此轉換邏輯的單元測試覆蓋。建議為 `app/findings.js` 中壓縮 AI 傳入內容與回傳後補回原始欄位的邏輯撰寫單元測試。應涵蓋各種邊界條件,例如空 findings 列表、findings 缺少選填欄位、以及確保所有原始欄位都能正確無誤地被還原。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||||
7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
|
7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
|
||||||
8. 階段五完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1
|
8. 階段五完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1
|
||||||
|
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
|
|||||||
@@ -3,49 +3,57 @@
|
|||||||
## 階段一:基本流程串接
|
## 階段一:基本流程串接
|
||||||
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
||||||
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
||||||
- 未驗收
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
|
||||||
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
|
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
|
||||||
- 未驗收
|
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
||||||
|
|
||||||
## 階段三:Findings 產生與合併
|
## 階段三:Findings 產生與合併
|
||||||
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
||||||
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
||||||
- 未驗收
|
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。
|
||||||
|
|
||||||
## 階段四:AI 去重與角色確認
|
## 階段四:AI 去重與角色確認
|
||||||
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
|
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
|
||||||
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
|
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
|
||||||
- 未驗收
|
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
||||||
|
|
||||||
## 階段五:AI 排除問題過濾
|
## 階段五:AI 排除問題過濾
|
||||||
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
||||||
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
||||||
- 未驗收
|
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆` 與 `排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
|
||||||
|
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
||||||
|
|
||||||
## 階段六:findings 寫入與 comment 發布
|
## 階段六:findings 寫入與 comment 發布
|
||||||
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
||||||
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
||||||
- 未驗收
|
- 部分驗收:`findings.json` 已成功寫入,也有依序執行舊問題、非嚴重、嚴重 comment 流程;但本次因結果為 0 筆,沒有實際 comment 內容可完整驗證順序。
|
||||||
|
- 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布`、`新問題(非嚴重)comment 發布`、`嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。
|
||||||
|
|
||||||
## 階段七:階段六後驗證 JSON 格式
|
## 階段七:階段六後驗證 JSON 格式
|
||||||
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。
|
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。
|
||||||
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。
|
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。
|
||||||
- 未驗收
|
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||||
|
|
||||||
## 階段八:記憶區 commit/push 與錯誤處理
|
## 階段八:記憶區 commit/push 與錯誤處理
|
||||||
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 未驗收
|
- 已驗收:log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功。
|
||||||
|
|
||||||
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||||
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
||||||
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
||||||
- 未驗收
|
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證。
|
||||||
|
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log。
|
||||||
|
|
||||||
## 階段十:API Key 輪替
|
## 階段十:API Key 輪替
|
||||||
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
||||||
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
||||||
- 未驗收
|
- 已驗收:`review.yaml` 已以逗號串接多把 Gemini key,且 `app/llm.js` 與單元測試已覆蓋輪替與失敗退出行為。
|
||||||
|
|
||||||
|
## 階段十一:壓縮 AI 傳入內容減少 token 用量
|
||||||
|
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion;AI 回傳後補回原始完整欄位(含 is_new)。
|
||||||
|
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
|
||||||
|
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
|
||||||
|
|||||||
+15
-16
@@ -76,22 +76,26 @@ function fallback(label, findings, e) {
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只保留 AI 需要的欄位,減少 token 用量 */
|
||||||
|
function toAIPayload(findings) {
|
||||||
|
return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
||||||
*/
|
*/
|
||||||
export async function deduplicateWithAI(findings) {
|
export async function deduplicateWithAI(findings) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const systemPrompt = `你是一位程式碼審查問題去重專家。
|
const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`;
|
||||||
給你一份問題清單(JSON 陣列),請移除語意重複的問題(即使描述文字不同,但指的是同一個問題)。
|
|
||||||
保留等級較高的版本,優先保留 critical > warning > info。
|
|
||||||
只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`);
|
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} 筆`);
|
console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`);
|
||||||
return result;
|
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
throw new Error('AI 回傳空陣列');
|
throw new Error('AI 回傳空陣列');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -131,22 +135,17 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
|||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const exclusionHint = exclusions.length > 0
|
const exclusionHint = exclusions.length > 0
|
||||||
? `\n\n以下是已知的誤報或不需處理的問題清單(供參考,相同檔案路徑且語意相近的問題應一併排除):\n${JSON.stringify(exclusions, null, 2)}`
|
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const systemPrompt = `你是一位資深程式碼審查專家,負責判斷審查問題是否為誤報或不需處理。
|
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
||||||
給你一份問題清單(JSON 陣列),每筆包含 level、role、location、suggestion。
|
|
||||||
請移除以下類型的問題:
|
|
||||||
1. 誤報:問題描述與實際程式碼不符(例如:程式碼已正確使用環境變數或 secrets,卻被標記為硬編碼敏感資料)
|
|
||||||
2. 不適用:問題在此專案情境下不需處理(例如:CI/CD action 本來就需要透過環境變數傳遞 token)
|
|
||||||
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
|
|
||||||
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`);
|
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} 筆`);
|
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
||||||
return result;
|
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);
|
||||||
}
|
}
|
||||||
throw new Error('AI 回傳空陣列或非陣列');
|
throw new Error('AI 回傳空陣列或非陣列');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+1
-3
@@ -47,12 +47,10 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repoDir = cloneRepo(workspace, _spawnSync);
|
|
||||||
|
|
||||||
await withAskpass(workspace, async credEnv => {
|
await withAskpass(workspace, async credEnv => {
|
||||||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||||
|
|||||||
+6
-7
@@ -38,7 +38,6 @@ describe('commitAndPush', () => {
|
|||||||
before(() => { workspace = makeTmpWorkspace(); });
|
before(() => { workspace = makeTmpWorkspace(); });
|
||||||
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Remove leftover askpass scripts between tests
|
|
||||||
for (const f of fs.readdirSync(workspace)) {
|
for (const f of fs.readdirSync(workspace)) {
|
||||||
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
||||||
}
|
}
|
||||||
@@ -46,7 +45,7 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
it('does not embed token in any git command argument', async () => {
|
it('does not embed token in any git command argument', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
|
|
||||||
for (const { args } of spawn.calls) {
|
for (const { args } of spawn.calls) {
|
||||||
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||||
@@ -55,7 +54,7 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
|
|
||||||
const networkOps = ['fetch', 'push', 'clone'];
|
const networkOps = ['fetch', 'push', 'clone'];
|
||||||
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
||||||
@@ -67,28 +66,28 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script after successful run', async () => {
|
it('cleans up askpass script after successful run', async () => {
|
||||||
await commitAndPush(workspace, makeSpawn());
|
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script even when git fails', async () => {
|
it('cleans up askpass script even when git fails', async () => {
|
||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await commitAndPush(workspace, failSpawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips commit when status shows no changes', async () => {
|
it('skips commit when status shows no changes', async () => {
|
||||||
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
||||||
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when git command fails', async () => {
|
it('does not throw when git command fails', async () => {
|
||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await assert.doesNotReject(() => commitAndPush(workspace, failSpawn));
|
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -7,12 +7,11 @@ const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type':
|
|||||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得 PR 的原始 Git Diff 內容。
|
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
|
||||||
* 注意:回傳值未經路徑過濾,呼叫端須使用 filterDiff 排除敏感路徑(如 .gitea/)後再傳給 AI。
|
|
||||||
*/
|
*/
|
||||||
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 });
|
||||||
return resp.data;
|
return filterDiff(resp.data, ['.gitea/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+17
-27
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
import { loadRoles, getRoleIntro } from './roles.js';
|
import { loadRoles, getRoleIntro } from './roles.js';
|
||||||
import { getPRDiff, filterDiff, postComment } from './gitea.js';
|
import { getPRDiff, postComment } from './gitea.js';
|
||||||
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
||||||
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
||||||
import { cloneRepo, commitAndPush } from './git.js';
|
import { cloneRepo, commitAndPush } from './git.js';
|
||||||
@@ -47,18 +47,8 @@ async function main() {
|
|||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step2: 排除 .gitea/ 資料夾內的所有檔案
|
// Step2: 各角色分析 diff 產生新 findings
|
||||||
console.log('\n🗂️ Step2: Git Diff 過濾');
|
console.log('\n📊 Step2: Findings 產生');
|
||||||
diff = filterDiff(diff, ['.gitea/']);
|
|
||||||
console.log(` 排除 .gitea/ 後 diff 長度: ${diff.length} 字元`);
|
|
||||||
|
|
||||||
if (!diff.trim()) {
|
|
||||||
console.log(' ⚠️ 過濾後 diff 為空,無需審查');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step3: 各角色分析 diff 產生新 findings
|
|
||||||
console.log('\n📊 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++) {
|
||||||
@@ -68,10 +58,10 @@ async function main() {
|
|||||||
console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
|
console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(` Step3 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||||
|
|
||||||
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
|
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
|
||||||
console.log('\n🔀 Step4: Findings 合併');
|
console.log('\n🔀 Step3: Findings 合併');
|
||||||
// Clone repo 以讀取舊 findings 與排除清單
|
// Clone repo 以讀取舊 findings 與排除清單
|
||||||
let repoDir;
|
let repoDir;
|
||||||
try {
|
try {
|
||||||
@@ -81,35 +71,35 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||||
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||||
console.log(` Step4 merged findings total=${mergedFindings.length}`);
|
console.log(` Step3 merged findings total=${mergedFindings.length}`);
|
||||||
|
|
||||||
console.log('\n🤖 Step4b: AI 語意去重');
|
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(` Step4b 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})`);
|
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})`);
|
||||||
|
|
||||||
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
||||||
console.log('\n🚫 Step5: AI 排除問題過濾');
|
console.log('\n🚫 Step4: AI 排除問題過濾');
|
||||||
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
||||||
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
||||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||||
console.log(` Step5 完成: findings total=${filtered.length}`);
|
console.log(` Step4 完成: findings total=${filtered.length}`);
|
||||||
|
|
||||||
// Step6: 寫入 findings.json,依序發布 comment
|
// Step6: 寫入 findings.json,依序發布 comment
|
||||||
console.log('\n📝 Step6: Findings 寫入與 Comment 發布');
|
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
||||||
saveFindings(WORKSPACE, filtered);
|
saveFindings(WORKSPACE, filtered);
|
||||||
try {
|
try {
|
||||||
await postOldFindingsComment(filtered);
|
await postOldFindingsComment(filtered);
|
||||||
await postNewNonCriticalComment(filtered);
|
await postNewNonCriticalComment(filtered);
|
||||||
await postNewCriticalComments(filtered);
|
await postNewCriticalComments(filtered);
|
||||||
console.log(' Step6 完成');
|
console.log(' Step5 完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
|
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
|
||||||
console.log('\n🔎 Step7: JSON 格式驗證');
|
console.log('\n🔎 Step6: JSON 格式驗證');
|
||||||
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||||
const fullPath = path.join(repoDir || WORKSPACE, relPath);
|
const fullPath = path.join(repoDir || WORKSPACE, relPath);
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
@@ -133,12 +123,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step8: commit/push findings.json 到來源分支
|
// Step7: commit/push findings.json 到來源分支
|
||||||
console.log('\n💾 Step8: 記憶區 Commit/Push');
|
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
||||||
await commitAndPush(WORKSPACE);
|
await commitAndPush(WORKSPACE, repoDir);
|
||||||
|
|
||||||
// Step9: 有 critical 問題則 exit 1
|
// Step9: 有 critical 問題則 exit 1
|
||||||
console.log('\n🚦 Step9: 嚴重問題檢查');
|
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)`);
|
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
||||||
|
|||||||
Reference in New Issue
Block a user