Compare commits

...

32 Commits

Author SHA1 Message Date
AI Review Bot ed061f85ce chore: update ai-review findings [ai-review-bot][success] 2026-05-18 02:53:34 +00:00
jiantw83 b4c54124ec feat: force overwrite core instruction files 2026-05-18 02:50:47 +00:00
jiantw83 b51ab78a5e feat: force sync skill trees 2026-05-18 02:48:54 +00:00
AI Review Bot 1129f37384 chore: update ai-review findings [ai-review-bot][success] 2026-05-18 02:43:56 +00:00
jiantw83 b8294d5ca7 fix: persist repaired exclusions 2026-05-18 02:40:53 +00:00
jiantw83 915e9cc2da docs: require canonical exclusions array 2026-05-18 02:35:35 +00:00
jiantw83 b1ed236720 feat: normalize exclusions format 2026-05-18 02:33:24 +00:00
jiantw83 d18c4a4a8e feat: optimize exclusion filtering 2026-05-18 02:06:36 +00:00
jiantw83 b06a89f2b9 更新 README.md 2026-05-15 16:06:02 +00:00
jiantw83 bb0158dadd Merge pull request 'chore: refine pipeline stage logs' (#119) from feat/美化輸出 into develop
Reviewed-on: #119
2026-05-15 15:54:33 +00:00
AI Review Bot ce6afdd5ee chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:53:01 +00:00
jiantw83 86d8666cda test: cover log helpers 2026-05-15 15:51:56 +00:00
AI Review Bot 95e90393e7 chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:46:29 +00:00
jiantw83 c836ec08e4 chore: triage log output suggestions 2026-05-15 15:45:08 +00:00
AI Review Bot acb3604cda chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:34:01 +00:00
jiantw83 38a3349e4f chore: refine pipeline stage logs 2026-05-15 15:32:43 +00:00
jiantw83 f382667946 Merge pull request 'feat: 美化輸出' (#118) from feat/美化輸出 into develop
Reviewed-on: #118
2026-05-15 15:32:15 +00:00
AI Review Bot 4e586158a5 chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:31:48 +00:00
jiantw83 3fcbf788fc chore: unify log formatting 2026-05-15 15:25:26 +00:00
jiantw83 bd4c3bce9e docs: align README and TODO with action flow 2026-05-15 15:20:25 +00:00
jiantw83 d7fb174fc6 Merge pull request 'chore: require gitea token input' (#117) from feat/ai_code_review into develop
Reviewed-on: #117
2026-05-15 15:16:41 +00:00
AI Review Bot 7d5057cf65 chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:15:18 +00:00
jiantw83 45e875153c chore: triage review findings 2026-05-15 15:13:07 +00:00
AI Review Bot 140c5059f1 chore: update ai-review findings [ai-review-bot][failure] 2026-05-15 15:07:27 +00:00
jiantw83 ce53c67cac fix: fail workflow on bot failure marker 2026-05-15 15:05:52 +00:00
AI Review Bot 4702f3814e chore: update ai-review findings [ai-review-bot][failure] 2026-05-15 15:02:45 +00:00
jiantw83 069e43c689 chore: pass separate gitea comment token 2026-05-15 15:01:11 +00:00
AI Review Bot 259d0e42c4 chore: update ai-review findings [ai-review-bot][failure] 2026-05-15 15:01:06 +00:00
jiantw83 b0c4d5a0bc feat: split gitea comment token 2026-05-15 14:59:15 +00:00
jiantw83 066b21aa5c feat: encode ai review outcome in commit marker 2026-05-15 14:47:02 +00:00
AI Review Bot bfa01721e4 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:40:43 +00:00
jiantw83 fba54c9c8d Merge pull request 'fix: remove GITEA_TOKEN from AI Code Review step and ensure master branch is ignored in pull requests' (#115) from feat/新增讀檔診斷資訊 into develop
Reviewed-on: #115
2026-05-15 09:56:09 +00:00
24 changed files with 627 additions and 236 deletions
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
5. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
6. Add tests when behavior changes.
## Output Rules
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+2 -1
View File
@@ -21,7 +21,7 @@ It is also used when some findings are false positives and should be moved into
4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics.
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
## Resolution Flow
@@ -41,5 +41,6 @@ After the list is merged and ordered, resolve the remaining findings one by one.
- Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
@@ -1,4 +1,4 @@
interface:
display_name: "Triage Findings"
short_description: "Triage, sort, fix, and exclude review findings"
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to exclusions."
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to `.gitea/ai-review/exclusions.json` as a top-level JSON array."
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
5. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
6. Add tests when behavior changes.
## Output Rules
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+20
View File
@@ -319,5 +319,25 @@
{
"location": "app/json.test.js:10",
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
},
{
"role": "Maya",
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
},
{
"role": "Leo",
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
},
{
"role": "Rex",
"location": "action.yaml:18",
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
},
{
"role": "Leo",
"location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
}
]
+3 -24
View File
@@ -1,30 +1,9 @@
[
{
"level": "critical",
"level": "info",
"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這將確保新的輸入要求和邏輯變更不會導致意外的行為或破壞現有工作流程。",
"location": "app/log.test.js",
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。",
"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": "warning",
"role": "Rex",
"location": "action.yaml:81",
"suggestion": "在 `action.yaml` 中,`GITEA_TOKEN` 的設定從 `secrets.GITEA_TOKEN` 的 fallback 移除,現在僅從 `inputs.GITEA_TOKEN` 取得。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets.MY_GITEA_TOKEN` 安全地傳遞,但此變更將確保敏感資料安全傳遞的責任完全轉移到工作流程的配置者。請確保所有使用此 action 的工作流程都透過 GitHub/Gitea secrets 將 `GITEA_TOKEN` 傳遞給 `inputs.GITEA_TOKEN`,以避免將敏感令牌硬編碼或暴露在日誌中。",
"is_new": true
}
]
+1
View File
@@ -31,6 +31,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
+1 -1
View File
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
+13 -7
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 助理的自動提交則直接跳過)
@@ -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 沒有定義,則使用預設值
3. Comment 加上些許 emoji 讓資訊有點活力
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
@@ -30,12 +30,12 @@
# 使用說明
1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾
2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml'
2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml`
3.`ai-review.yaml` 中填入以下內容(選擇一個使用)
> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`,而且 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 也能先檢查一次,效果最好。
> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment、以及 commit status 寫入權限,為正常運作所必要,無法縮減
> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment三項權限,為正常運作所必要,無法縮減。若你想讓 comment 用不同權限的 token,可額外傳 `GITEA_COMMENT_TOKEN`,其餘 Gitea 操作仍使用 `GITEA_TOKEN`
### 1. OpenAI
```yaml
@@ -57,6 +57,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
OPENAI_BASE_URL: https://api.openai.com/v1
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
@@ -86,6 +87,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
OPENAI_BASE_URL: https://openrouter.ai/api/v1
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
@@ -115,6 +117,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
CLAUDE_BASE_URL: https://api.anthropic.com/v1
permissions:
@@ -143,6 +146,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
@@ -172,6 +176,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
AMAZONQ_BASE_URL: https://q.api.aws
permissions:
@@ -180,7 +185,7 @@ jobs:
issues: write
```
### - Ollama
### 6. Ollama
```yaml
name: AI
@@ -200,7 +205,8 @@ jobs:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
permissions:
+6 -6
View File
@@ -2,7 +2,7 @@
## 階段一:基本流程串接
- 目標:確保 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 完成` 結束。
## 階段二:Git Diff 排除 .gitea/ 資料夾
@@ -15,14 +15,14 @@
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`
## 階段四:AI 去重與角色確認
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
## 階段四:AI 語意去重
- 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。
- 驗收:log 中能看到 `AI 去重: N -> M 筆` 的成功訊息,或在失敗時出現 `AI 去重失敗(...),降級保留所有問題` 之類的明確訊息。
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
## 階段五:AI 排除問題過濾
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`時先去除重複條目、整理成語意群組摘要;若檔案不是頂層陣列格式,需主動修正成正確格式,再進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、重複排除條目的整理摘要、格式修正訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆``排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`
+4
View File
@@ -6,6 +6,9 @@ inputs:
GITEA_TOKEN:
description: 'Gitea API Token'
required: true
GITEA_COMMENT_TOKEN:
description: 'Gitea API Token for posting comments only'
required: false
GITEA_SERVER_URL:
description: 'Gitea Server URL'
required: false
@@ -82,6 +85,7 @@ runs:
env:
# Gitea context(改為只從 inputs 取得)
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ inputs.GITEA_COMMENT_TOKEN }}
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
+8 -7
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { postComment } from './gitea.js';
import { FINDINGS_PATH } from './config.js';
import { ok, line } from './log.js';
const LEVEL_EMOJI = { 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);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
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) {
const old = findings.filter(f => !f.is_new);
if (old.length === 0) {
console.log(' 無舊問題,跳過');
line('無舊問題,跳過');
return;
}
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
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) {
const items = findings.filter(f => f.is_new && f.level !== 'critical');
if (items.length === 0) {
console.log(' 無新的非嚴重問題,跳過');
line('無新的非嚴重問題,跳過');
return;
}
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
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) {
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
if (criticals.length === 0) {
console.log(' 無新的嚴重問題,跳過');
line('無新的嚴重問題,跳過');
return;
}
for (const f of criticals) {
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
await postComment(body);
console.log(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
}
}
+1
View File
@@ -1,4 +1,5 @@
export const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || '';
export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com';
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || '';
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
+195 -30
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { chatJSON } from './llm.js';
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { line, ok, warn } from './log.js';
const LEVELS = ['critical', 'warning', 'info'];
@@ -9,11 +10,11 @@ const LEVELS = ['critical', 'warning', 'info'];
* 用單一角色分析 diff,回傳 findings 陣列
*/
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 valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
.map(f => ({ ...f, is_new: true }));
console.log(` [${role.name}] 找到 ${valid.length} 個問題`);
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
return valid;
}
@@ -22,29 +23,175 @@ export async function analyzeWithRole(role, diff) {
*/
function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) {
console.log(` ${label}檔案不存在,視為空`);
warn(`${label}檔案不存在,視為空`);
return [];
}
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (e) {
console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`);
warn(`讀取${label}失敗: ${e.message},視為空`);
return [];
}
}
function normalizeExclusions(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.exclusions)) return data.exclusions;
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
return [];
}
function detectExclusionSource(data) {
if (Array.isArray(data)) return 'array';
if (data && Array.isArray(data.exclusions)) return 'exclusions';
if (data && Array.isArray(data.excluded_findings)) return 'excluded_findings';
return 'unknown';
}
function writeCanonicalExclusions(fullPath, exclusions) {
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
}
function formatFileTime(mtimeMs) {
if (!Number.isFinite(mtimeMs)) return 'unknown';
return new Date(mtimeMs).toISOString();
}
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeText(value) {
return cleanText(value)
.normalize('NFKC')
.toLowerCase()
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function toKeyText(value) {
return cleanText(value)
.normalize('NFKC')
.replace(/[\p{P}\p{S}\s]+/gu, '')
.trim();
}
function getExclusionText(exclusion) {
return cleanText(exclusion?.original_finding)
|| cleanText(exclusion?.title)
|| cleanText(exclusion?.suggestion)
|| cleanText(exclusion?.reason)
|| cleanText(exclusion?.note);
}
function normalizeExclusionEntry(exclusion, index) {
const location = cleanText(exclusion?.location);
const filePath = location ? location.split(':')[0] : '';
const role = cleanText(exclusion?.role);
const text = getExclusionText(exclusion);
const textKey = toKeyText(text);
const fingerprint = [filePath || '*', role || '*', textKey || `entry-${index + 1}`].join('|');
return {
...exclusion,
location: location || null,
filePath,
role: role || null,
text,
textKey,
fingerprint,
};
}
function dedupeExclusions(exclusions) {
const seen = new Set();
return exclusions.filter(exclusion => {
if (seen.has(exclusion.fingerprint)) return false;
seen.add(exclusion.fingerprint);
return true;
});
}
function groupExclusionsForAI(exclusions) {
const groups = new Map();
for (const exclusion of exclusions) {
const groupKey = exclusion.textKey || exclusion.fingerprint;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
key: groupKey,
text: exclusion.text || exclusion.location || exclusion.fingerprint,
count: 0,
paths: new Set(),
roles: new Set(),
samples: [],
});
}
const group = groups.get(groupKey);
group.count += 1;
if (exclusion.filePath) group.paths.add(exclusion.filePath);
if (exclusion.role) group.roles.add(exclusion.role);
if (group.samples.length < 2 && exclusion.text) group.samples.push(exclusion.text);
}
return [...groups.values()]
.sort((a, b) => b.count - a.count || b.paths.size - a.paths.size || a.text.localeCompare(b.text))
.map(group => ({
text: group.text,
count: group.count,
paths: [...group.paths].sort(),
roles: [...group.roles].sort(),
samples: group.samples,
}));
}
function buildExclusionContext(exclusions) {
if (exclusions.length === 0) {
return {
rawCount: 0,
uniqueCount: 0,
groups: [],
prompt: '',
};
}
const normalized = exclusions.map((exclusion, index) => normalizeExclusionEntry(exclusion, index));
const unique = dedupeExclusions(normalized);
const groups = groupExclusionsForAI(unique);
const topGroups = groups.slice(0, 12).map(group => ({
text: group.text,
count: group.count,
paths: group.paths.slice(0, 4),
roles: group.roles.slice(0, 3),
samples: group.samples.slice(0, 2),
}));
const omitted = groups.length - topGroups.length;
const promptLines = [
`已知誤報清單(原始 ${exclusions.length} 筆,整理後 ${unique.length} 筆,分成 ${groups.length} 類):`,
...topGroups.map((group, index) => {
const parts = [
`${index + 1}. ${group.text}`,
`count=${group.count}`,
];
if (group.paths.length > 0) parts.push(`paths=${group.paths.join(', ')}`);
if (group.roles.length > 0) parts.push(`roles=${group.roles.join(', ')}`);
if (group.samples.length > 0) parts.push(`samples=${group.samples.join(' | ')}`);
return `- ${parts.join(' ; ')}`;
}),
];
if (omitted > 0) {
promptLines.push(`- 另有 ${omitted} 類相似排除條目未展開,請依上述群組規則推論。`);
}
return {
rawCount: exclusions.length,
uniqueCount: unique.length,
groupCount: groups.length,
groups: topGroups,
prompt: promptLines.join('\n'),
};
}
/**
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH
*/
@@ -53,12 +200,12 @@ export function loadOldFindings(workspace) {
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
if (fs.existsSync(fullPath)) {
const stat = fs.statSync(fullPath);
console.log(` 讀取舊 findings 檔案: ${fullPath}`);
console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
line(`讀取舊 findings 檔案: ${fullPath}`);
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
} else {
console.log(` 舊 findings 檔案不存在: ${fullPath}`);
warn(`舊 findings 檔案不存在: ${fullPath}`);
}
console.log(` 讀取舊 findings: ${old.length}`);
ok(`讀取舊 findings: ${old.length}`);
return old;
}
@@ -74,7 +221,7 @@ export function mergeFindings(oldFindings, newFindings) {
return true;
});
const merged = [...oldFindings, ...deduped];
console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
return merged;
}
@@ -91,7 +238,7 @@ export function sortByLevel(findings) {
function fallback(label, findings, e) {
const status = e.response?.status;
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`);
warn(`${label}失敗(${reason}),降級:保留所有問題`);
return findings;
}
@@ -111,7 +258,7 @@ export async function deduplicateWithAI(findings) {
try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
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)補回
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);
@@ -125,16 +272,16 @@ export async function deduplicateWithAI(findings) {
/**
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH
*/
export function loadExclusions(workspace, repoState = null) {
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) {
console.log(` 排除問題檔案不存在,視為空: ${fullPath}`);
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
if (repoState) {
const branch = repoState.branch || 'detached';
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 [];
}
@@ -143,19 +290,31 @@ export function loadExclusions(workspace, repoState = null) {
try {
const stat = fs.statSync(fullPath);
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0;
exclusions = normalizeExclusions(data);
const sourceFormat = detectExclusionSource(data);
const normalizedSource = normalizeExclusions(data);
rawCount = normalizedSource.length;
exclusions = dedupeExclusions(normalizedSource.map((exclusion, index) => normalizeExclusionEntry(exclusion, index)));
const branch = repoState?.branch || 'detached';
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
const commitTime = repoState?.commitTime || 'unknown';
console.log(` 讀取排除問題檔案: ${fullPath}`);
console.log(` 來源分支狀態: 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(`讀取排除問題檔案: ${fullPath}`);
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
if (sourceFormat !== 'array') {
writeCanonicalExclusions(fullPath, normalizedSource);
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) {
const mirrorPath = path.join(mirrorWorkspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
writeCanonicalExclusions(mirrorPath, normalizedSource);
}
line(`排除問題格式已修正為頂層陣列: source=${sourceFormat} -> array`);
}
} catch (e) {
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
exclusions = [];
}
console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length}`);
const summary = buildExclusionContext(exclusions);
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount}`);
return exclusions;
}
@@ -168,29 +327,35 @@ export function applyExclusions(findings, exclusions) {
const before = findings.length;
const filtered = findings.filter(f => !exclusions.some(ex => {
const fPath = String(f.location).split(':')[0];
const exPath = ex.location ? String(ex.location).split(':')[0] : null;
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
const findingText = normalizeText(f.suggestion || f.title || '');
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || '');
const locationMatches = (!exPath || fPath === exPath);
const roleMatches = (!ex.role || ex.role === f.role);
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
return locationMatches && roleMatches && (exPath || ex.role ? true : textMatches);
}));
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
return filtered;
}
/**
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
*/
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
if (findings.length === 0) return findings;
const exclusionHint = exclusions.length > 0
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
const exclusionContext = buildExclusionContext(exclusions);
const exclusionHint = exclusionContext.prompt
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
: '';
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
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]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
}
+86 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js';
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
describe('findings exclusions', () => {
@@ -41,6 +41,47 @@ describe('findings exclusions', () => {
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
});
it('repairs exclusions wrapper format to a top-level array', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify({
exclusions: [
{ location: 'README.md:12', suggestion: 'keep' },
],
}, null, 2));
const exclusions = loadExclusions(workspace);
const repaired = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
assert.equal(exclusions.length, 1);
assert.ok(Array.isArray(repaired));
assert.equal(repaired[0].location, 'README.md:12');
assert.equal(repaired[0].suggestion, 'keep');
assert.ok(logs.some(line => line.includes('排除問題格式已修正為頂層陣列: source=exclusions -> array')));
});
it('mirrors repaired exclusions into the workspace root when requested', () => {
const repoRoot = path.join(workspace, 'repo');
const mirrorRoot = path.join(workspace, 'workspace');
const repoFullPath = path.join(repoRoot, EXCLUSIONS_PATH);
const mirrorFullPath = path.join(mirrorRoot, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(repoFullPath), { recursive: true });
fs.mkdirSync(path.dirname(mirrorFullPath), { recursive: true });
fs.writeFileSync(repoFullPath, JSON.stringify({
exclusions: [
{ location: 'README.md:12', suggestion: 'keep' },
],
}, null, 2));
const exclusions = loadExclusions(repoRoot, null, mirrorRoot);
const mirror = JSON.parse(fs.readFileSync(mirrorFullPath, 'utf8'));
assert.equal(exclusions.length, 1);
assert.ok(Array.isArray(mirror));
assert.equal(mirror[0].location, 'README.md:12');
assert.equal(mirror[0].suggestion, 'keep');
});
it('applies exclusions loaded from wrapper format', () => {
const findings = [
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
@@ -56,6 +97,50 @@ describe('findings exclusions', () => {
assert.equal(filtered[0].location, 'README.md:12');
});
it('dedupes repeated exclusions when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:999', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
], null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].filePath, 'entrypoint.sh');
assert.equal(exclusions[0].text, 'fetch_package_versions jq overhead');
});
it('builds a compact exclusion hint for AI', async () => {
const findings = [
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', suggestion: 'update tests' },
];
const exclusions = [
{ location: 'src/app.cs:1', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/app.cs:99', original_finding: '更新套件後請補上測試驗證 ' },
{ location: 'src/service.cs:3', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/service.cs:8', title: '請確認安全性變更' },
];
let capturedSystemPrompt = '';
let capturedUserContent = '';
const result = await filterFalsePositivesWithAI(findings, exclusions, async (systemPrompt, userContent) => {
capturedSystemPrompt = systemPrompt;
capturedUserContent = userContent;
return findings;
});
assert.equal(result.length, 1);
assert.ok(capturedSystemPrompt.includes('已知誤報清單(原始 4 筆,整理後 3 筆,分成 2 類)'));
assert.ok(capturedSystemPrompt.includes('更新套件後請補上測試驗證'));
assert.ok(capturedSystemPrompt.includes('paths=src/app.cs, src/service.cs'));
assert.ok(capturedSystemPrompt.includes('請確認安全性變更'));
assert.ok(capturedUserContent.includes('"location":"src/app.cs:12"'));
assert.ok(capturedUserContent.includes('"suggestion":"update tests"'));
});
it('logs exclusions file metadata and repo state when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+70 -18
View File
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
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 GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
@@ -19,6 +20,17 @@ export const SYNC_PATHS = [
'CLAUDE.md',
'GEMINI.md',
];
const FORCE_SYNC_FILE_PATHS = [
'.github/copilot-instructions.md',
'CLAUDE.md',
'GEMINI.md',
];
const SYNC_TREE_PATHS = [
'.codex/skills/triage-findings',
'.claude/skills/triage-findings',
'.gemini/skills/triage-findings',
'.github/skills/triage-findings',
];
function makeRunner(spawn) {
return function run(args, cwd, env) {
@@ -50,6 +62,35 @@ function readGitOutput(run, args, cwd, env) {
}
}
function copyTree(sourceRoot, repoDir, relDir) {
const srcDir = path.join(sourceRoot, relDir);
if (!fs.existsSync(srcDir)) return [];
const copied = [];
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const relPath = path.join(relDir, entry.name);
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (entry.isDirectory()) {
copied.push(...copyTree(sourceRoot, repoDir, relPath));
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
copied.push(relPath);
}
return copied;
}
function copyFileOverwrite(sourceRoot, repoDir, relPath) {
const src = path.join(sourceRoot, relPath);
if (!fs.existsSync(src)) return null;
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return relPath;
}
export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
@@ -78,17 +119,17 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
return withAskpass(workspace, credEnv => {
if (!fs.existsSync(repoDir)) {
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
console.log(`repo cloned to ${repoDir}`);
ok(`repo cloned to ${repoDir}`);
} else {
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
run(['checkout', PR_HEAD_BRANCH], repoDir);
console.log(`repo already exists, fetched latest`);
ok('repo already exists, fetched latest');
}
return repoDir;
});
}
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT) {
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT, reviewOutcome = 'success') {
const run = makeRunner(_spawnSync);
try {
@@ -100,21 +141,31 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
}
const existingSyncPaths = [];
const existingSyncPaths = new Set();
// Copy action skill files into the target repo. Existing files are overwritten;
// Copy action skill trees into the target repo. Existing files are overwritten;
// missing source files are ignored so we do not delete target repo content.
for (const relPath of SYNC_PATHS) {
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
existingSyncPaths.push(relPath);
for (const relDir of SYNC_TREE_PATHS) {
for (const relPath of copyTree(sourceRoot, repoDir, relDir)) {
existingSyncPaths.add(relPath);
}
}
if (existingSyncPaths.length > 0) {
// Force overwrite the direct instruction files first so the target repo always
// receives the action-owned versions even if the repo has drifted.
for (const relPath of FORCE_SYNC_FILE_PATHS) {
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied);
}
// Copy standalone action files into the target repo. Existing files are overwritten.
for (const relPath of SYNC_PATHS) {
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied);
}
if (existingSyncPaths.size > 0) {
run(['add', ...existingSyncPaths], repoDir);
}
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
@@ -130,20 +181,21 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
const status = run(['status', '--porcelain'], repoDir);
if (!status) {
console.log(' sync files 無變更,跳過 commit');
line('sync files 無變更,跳過 commit');
return;
}
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}`], repoDir);
const outcomeTag = reviewOutcome === 'failure' ? '[failure]' : '[success]';
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}${outcomeTag}`], repoDir);
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
try {
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
console.log(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
} catch (pushErr) {
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`);
warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
}
});
} catch (e) {
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
warn(`Runner failed: commit/push 失敗: ${e.message}`);
}
}
+35 -1
View File
@@ -67,6 +67,17 @@ describe('commitAndPush', () => {
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
assert.ok(commitCall, 'expected git commit to run');
assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
assert.ok(commitCall.args.some(arg => arg.includes('[success]')), 'expected commit message to include success outcome');
});
it('tags failed reviews with the failure outcome marker', async () => {
const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot, 'failure');
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
assert.ok(commitCall, 'expected git commit to run');
assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome');
});
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
@@ -148,11 +159,30 @@ describe('commitAndPush', () => {
const repoDir = path.join(workspace, 'repo');
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'stale');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md');
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
assert.equal(fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'), 'GEMINI.md');
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
});
it('recursively overwrites skill tree files from the action source', async () => {
const repoDir = path.join(workspace, 'repo');
const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt';
const sourceNestedPath = path.join(sourceRoot, nestedRelPath);
const repoNestedPath = path.join(repoDir, nestedRelPath);
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
fs.writeFileSync(sourceNestedPath, 'fresh');
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
fs.writeFileSync(repoNestedPath, 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh');
});
it('does not throw when git command fails', async () => {
@@ -174,12 +204,16 @@ describe('commitAndPush', () => {
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => { logs.push(args.join(' ')); };
const originalWarn = console.warn;
const capture = (...args) => { logs.push(args.join(' ')); };
console.log = capture;
console.warn = capture;
try {
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
} finally {
console.log = originalLog;
console.warn = originalWarn;
}
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
+25 -33
View File
@@ -1,9 +1,10 @@
import axios from 'axios';
import https from 'https';
import { GITEA_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 headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
function extractCommitMessage(payload) {
@@ -13,6 +14,11 @@ function extractCommitMessage(payload) {
|| '';
}
export function getBotReviewOutcome(message) {
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
return match?.[1]?.toLowerCase() || 'unknown';
}
/**
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
*/
@@ -41,10 +47,10 @@ export async function getCommitMessageBySha(sha) {
httpsAgent,
});
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;
} catch (e) {
console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`);
warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`);
return '';
}
}
@@ -58,61 +64,43 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
httpsAgent,
});
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);
} catch (e) {
console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`);
warn(`bot-check branch api 失敗: branch=${branch} error=${e.message}`);
return '';
}
}
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);
if (sha) {
console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'}`);
line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`);
if (shaMessage.includes('[ai-review-bot]')) {
console.log('bot-check: matched commit sha marker');
ok('bot-check matched commit sha marker');
return true;
}
} 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);
if (branch) {
console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'}`);
line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`);
if (branchMessage.includes('[ai-review-bot]')) {
console.log('bot-check: matched branch head marker');
ok('bot-check matched branch head marker');
return true;
}
} 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;
}
export async function setCommitStatus(sha, state, description, context = 'ai-review/critical', targetUrl = '') {
if (!sha) throw new Error('commit sha is required for status update');
const payload = {
state,
context,
description,
};
if (targetUrl) payload.target_url = targetUrl;
const resp = await axios.post(api(`/repos/${GITEA_REPOSITORY}/statuses/${encodeURIComponent(sha)}`), payload, {
headers: headers(),
timeout: 30000,
httpsAgent,
});
console.log(` ✅ status: sha=${sha} state=${state} context=${context} description=${description}`);
return resp.data;
}
/**
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
@@ -128,6 +116,10 @@ export function filterDiff(diff, excludePrefixes) {
}
export async function postComment(body) {
const resp = await axios.post(api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), { body }, { headers: headers(), timeout: 30000, httpsAgent });
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`),
{ body },
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
+5 -21
View File
@@ -1,7 +1,7 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import axios from 'axios';
import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, setCommitStatus } from './gitea.js';
import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
afterEach(() => mock.restoreAll());
@@ -86,7 +86,7 @@ describe('gitea', () => {
it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => {
mock.method(axios, 'get', async (url) => {
if (url.includes('/git/commits/sha-bot')) {
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } };
}
if (url.includes('/branches/feat%2Ftest')) {
return { data: { commit: { id: 'sha-bot' } } };
@@ -94,25 +94,9 @@ describe('gitea', () => {
return { data: { message: 'regular commit' } };
});
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true);
});
it('setCommitStatus posts commit status to Gitea API', async () => {
let capturedUrl, capturedBody, capturedOpts;
mock.method(axios, 'post', async (url, body, opts) => {
capturedUrl = url;
capturedBody = body;
capturedOpts = opts;
return { data: { state: body.state } };
});
const result = await setCommitStatus('sha-123', 'failure', 'found 2 critical issues', 'ai-review/critical', 'https://example.com/pr/1');
assert.equal(result.state, 'failure');
assert.ok(capturedUrl.includes('/statuses/sha-123'));
assert.equal(capturedBody.state, 'failure');
assert.equal(capturedBody.context, 'ai-review/critical');
assert.equal(capturedBody.description, 'found 2 critical issues');
assert.equal(capturedBody.target_url, 'https://example.com/pr/1');
assert.ok(capturedOpts.headers['Authorization'].startsWith('token '));
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'), 'failure');
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][success]'), 'success');
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown');
});
});
+7 -6
View File
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { chat } from './llm.js';
import { ok, warn, error } from './log.js';
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 });
if (!fs.existsSync(fullPath)) {
console.log(` ⚠️ ${label} 不存在,將於驗證後補建`);
warn(`${label} 不存在,將於驗證後補建`);
return { exists: false, valid: false, repaired: false };
}
try {
JSON.parse(readJSONText(fullPath, label));
console.log(`${label} JSON 格式正確`);
ok(`${label} JSON 格式正確`);
return { exists: true, valid: true, repaired: false };
} catch (e) {
console.error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
try {
const original = readJSONText(fullPath, label);
const repaired = await repairer(fullPath, label, original);
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
JSON.parse(readJSONText(fullPath, label));
console.log(`${label} 已由 AI 修正並通過再次驗證`);
ok(`${label} 已由 AI 修正並通過再次驗證`);
return { exists: true, valid: true, repaired: true };
} catch (repairErr) {
console.error(`${label} 修正失敗: ${repairErr.message}`);
error(`${label} 修正失敗: ${repairErr.message}`);
throw repairErr;
}
}
@@ -82,6 +83,6 @@ export function ensureJSONArrayFileExists(fullPath, label) {
if (fs.existsSync(fullPath)) return false;
fs.writeFileSync(fullPath, '[]\n', 'utf8');
console.log(` ⚠️ ${label} 不存在,已建立空陣列`);
warn(`${label} 不存在,已建立空陣列`);
return true;
}
+5 -4
View File
@@ -1,11 +1,12 @@
import axios from 'axios';
import { getLLMConfig } from './config.js';
import { line, error } from './log.js';
export async function chat(systemPrompt, userContent) {
const { provider, apiKeys, baseURL, model } = getLLMConfig();
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' };
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;
} 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);
}
@@ -33,7 +34,7 @@ export async function chatJSON(systemPrompt, userContent) {
try {
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
} catch (e) {
console.log(` [LLM] JSON 解析失敗: ${e.message}`);
line(`[LLM] JSON 解析失敗: ${e.message}`);
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.warn(` ! ${message}`);
}
export function error(message) {
console.error(` x ${message}`);
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { section, step, line, ok, warn, error } from './log.js';
afterEach(() => mock.restoreAll());
describe('log helpers', () => {
it('formats section and step messages', () => {
const calls = [];
mock.method(console, 'log', (...args) => {
calls.push(args.join(' '));
});
section('Pipeline');
step('Step1', 'Start');
assert.deepEqual(calls, [
'\n=== Pipeline ===',
'\n[Step1] Start',
]);
});
it('formats line and ok messages with console.log', () => {
const calls = [];
mock.method(console, 'log', (...args) => {
calls.push(args.join(' '));
});
line('hello');
ok('done');
assert.deepEqual(calls, [
' - hello',
' ✓ done',
]);
});
it('formats warn messages with console.warn', () => {
const calls = [];
mock.method(console, 'warn', (...args) => {
calls.push(args.join(' '));
});
warn('careful');
assert.deepEqual(calls, [' ! careful']);
});
it('formats error messages with console.error', () => {
const calls = [];
mock.method(console, 'error', (...args) => {
calls.push(args.join(' '));
});
error('boom');
assert.deepEqual(calls, [' x boom']);
});
});
+53 -73
View File
@@ -1,137 +1,118 @@
import path from 'path';
import { GITEA_REPOSITORY, GITEA_SERVER_URL, 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 { getPRDiff, postComment, shouldSkipBotCommit, setCommitStatus } from './gitea.js';
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.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 REVIEW_STATUS_CONTEXT = 'ai-review/critical';
async function updateReviewStatus(sha, criticalCount) {
const state = criticalCount > 0 ? 'failure' : 'success';
const description = criticalCount > 0
? `found ${criticalCount} critical issue${criticalCount === 1 ? '' : 's'}`
: 'no critical issues found';
await setCommitStatus(sha, state, description, REVIEW_STATUS_CONTEXT, `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}`);
}
async function main() {
console.log('='.repeat(60));
console.log('🚀 Step1: Pipeline 啟動');
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
section('AI Code Review Pipeline');
step('Step1', 'Pipeline 啟動');
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
const headMessage = await getCommitMessageBySha(headSha);
const headOutcome = getBotReviewOutcome(headMessage);
line(`head check: sha=${headSha || 'empty'} outcome=${headOutcome}`);
if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') {
error('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗');
section('Pipeline 結束');
process.exit(1);
}
if (await shouldSkipBotCommit()) {
console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action');
let criticalCount = 0;
try {
const repoDir = cloneRepo(WORKSPACE);
const findings = loadOldFindings(repoDir || WORKSPACE);
criticalCount = findings.filter(f => f.level === 'critical').length;
console.log(` 🔎 bot-check: current findings critical=${criticalCount}`);
await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, criticalCount);
} catch (e) {
console.error(` ❌ bot-check: 無法回報 status: ${e.message}`);
process.exit(1);
}
console.log('='.repeat(60));
ok('偵測到 [ai-review-bot] 自動提交,直接完成 action');
section('Pipeline 結束');
process.exit(0);
}
const { provider, baseURL, model } = getLLMConfig();
if (!provider) {
console.error('未設定任何 LLM API Key,請檢查 action inputs');
error('未設定任何 LLM API Key,請檢查 action inputs');
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();
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
let diff;
try {
diff = await getPRDiff();
console.log(` diff 長度: ${diff.length} 字元`);
line(`diff 長度: ${diff.length} 字元`);
} catch (e) {
console.error(`取得 diff 失敗: ${e.message}`);
error(`取得 diff 失敗: ${e.message}`);
process.exit(1);
}
if (!diff.trim()) {
console.log(' ⚠️ diff 為空,無需審查');
await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, 0);
warn('diff 為空,無需審查');
process.exit(0);
}
try {
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
await postComment(intro);
console.log('角色介紹 comment 發布成功');
ok('角色介紹 comment 發布成功');
} catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
warn(`comment 發布失敗(繼續執行): ${e.message}`);
}
// Step2: 各角色分析 diff 產生新 findings
console.log('\n📊 Step2: Findings 產生');
step('Step2', 'Findings 產生');
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
const newFindings = [];
for (let i = 0; i < results.length; i++) {
if (results[i].status === 'fulfilled') {
newFindings.push(...results[i].value);
} 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 語意去重)
console.log('\n🔀 Step3: Findings 合併');
// Clone repo 以讀取舊 findings 與排除清單
step('Step3', 'Findings 合併與語意去重');
let repoDir;
try {
repoDir = cloneRepo(WORKSPACE);
} catch (e) {
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`);
warn(`clone repo 失敗(繼續執行): ${e.message}`);
}
const repoState = repoDir ? getRepoState(repoDir) : null;
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 mergedFindings = mergeFindings(oldFindings, newFindings);
console.log(` Step3 merged findings total=${mergedFindings.length}`);
console.log('\n🤖 Step3b: AI 語意去重');
ok(`Step3 merged findings total=${mergedFindings.length}`);
const deduped = await deduplicateWithAI(mergedFindings);
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 判斷誤報
console.log('\n🚫 Step4: AI 排除問題過濾');
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
step('Step4', 'AI 排除問題過濾');
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
console.log(` Step4 完成: findings total=${filtered.length}`);
ok(`Step4 完成: findings total=${filtered.length}`);
// Step6: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
step('Step5', 'Findings 寫入與 Comment 發布');
const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir);
try {
await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered);
await postNewCriticalComments(filtered);
console.log(' Step5 完成');
ok('Step5 完成');
} catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
warn(`comment 發布失敗(繼續執行): ${e.message}`);
}
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
console.log('\n🔎 Step6: JSON 格式驗證');
step('Step6', 'JSON 格式驗證');
const missingPaths = [];
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(reviewDir, relPath);
@@ -147,25 +128,24 @@ async function main() {
ensureJSONArrayFileExists(fullPath, relPath);
}
// Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step7: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE, repoDir || WORKSPACE);
step('Step7', '記憶區 Commit/Push');
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
line(`review outcome=${reviewOutcome}`);
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
// Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step8: 嚴重問題檢查');
step('Step8', '嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length;
await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, criticalCount);
if (criticalCount > 0) {
console.log(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60));
error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
section('Pipeline 結束');
process.exit(1);
}
console.log('無嚴重問題');
console.log('\n✅ Pipeline 完成');
console.log('='.repeat(60));
ok('無嚴重問題');
ok('Pipeline 完成');
section('Pipeline 結束');
}
main().catch(e => {
console.error('❌ Runner failed:', e.message);
error(`Runner failed: ${e.message}`);
process.exit(1);
});