Compare commits
53 Commits
v0.1.3
...
v0.1.7-beta.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 09584f4f93 | |||
| ed061f85ce | |||
| b4c54124ec | |||
| b51ab78a5e | |||
| 1129f37384 | |||
| b8294d5ca7 | |||
| 915e9cc2da | |||
| b1ed236720 | |||
| d18c4a4a8e | |||
| b06a89f2b9 | |||
| bb0158dadd | |||
| ce6afdd5ee | |||
| 86d8666cda | |||
| 95e90393e7 | |||
| c836ec08e4 | |||
| acb3604cda | |||
| 38a3349e4f | |||
| f382667946 | |||
| 4e586158a5 | |||
| 3fcbf788fc | |||
| bd4c3bce9e | |||
| d7fb174fc6 | |||
| 7d5057cf65 | |||
| 45e875153c | |||
| 140c5059f1 | |||
| ce53c67cac | |||
| 4702f3814e | |||
| 069e43c689 | |||
| 259d0e42c4 | |||
| b0c4d5a0bc | |||
| 066b21aa5c | |||
| bfa01721e4 | |||
| 4fd9a22aa0 | |||
| 93c3d0ca66 | |||
| 35150cae8a | |||
| e216ca08c5 | |||
| 888bf0b359 | |||
| 59e942f24b | |||
| 82ecbd3463 | |||
| f3319b5ec4 | |||
| ee593418f0 | |||
| 9012fe64d1 | |||
| 3ae08052a3 | |||
| 60f3a9beba | |||
| 09b7be2c40 | |||
| 647460ea87 | |||
| 9fe85c9f72 | |||
| fba54c9c8d | |||
| ca9845af1d | |||
| 2061fadba9 | |||
| eccdfd0a3a | |||
| bf6c791a82 | |||
| 222de4b369 |
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
|
|||||||
- info
|
- info
|
||||||
3. Renumber from 1.
|
3. Renumber from 1.
|
||||||
4. Fix real issues.
|
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.
|
6. Add tests when behavior changes.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
|
|||||||
- Keep the final list short.
|
- Keep the final list short.
|
||||||
- Keep numbering contiguous.
|
- Keep numbering contiguous.
|
||||||
- Preserve file path, location, and fix.
|
- 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.
|
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
||||||
|
|||||||
@@ -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.
|
4. Renumber the sorted list from 1 upward.
|
||||||
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
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.
|
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
|
## 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.
|
- Keep numbering contiguous after filtering and merging.
|
||||||
- Preserve useful details like file path, location, and suggested fix.
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
- Keep exclusions entries minimal and consistent with the project schema.
|
- 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.
|
- 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.
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Triage Findings"
|
display_name: "Triage Findings"
|
||||||
short_description: "Triage, sort, fix, and exclude review 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."
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
|
|||||||
- info
|
- info
|
||||||
3. Renumber from 1.
|
3. Renumber from 1.
|
||||||
4. Fix real issues.
|
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.
|
6. Add tests when behavior changes.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
|
|||||||
- Keep the final list short.
|
- Keep the final list short.
|
||||||
- Keep numbering contiguous.
|
- Keep numbering contiguous.
|
||||||
- Preserve file path, location, and fix.
|
- 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.
|
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
||||||
|
|||||||
@@ -154,6 +154,11 @@
|
|||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "app/log.test.js",
|
||||||
|
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/package.json",
|
"location": "app/package.json",
|
||||||
@@ -319,5 +324,25 @@
|
|||||||
{
|
{
|
||||||
"location": "app/json.test.js:10",
|
"location": "app/json.test.js:10",
|
||||||
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
"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": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
name: AI
|
name: AI
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- master
|
||||||
types: [opened, synchronize]
|
types: [opened, synchronize]
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
@@ -31,10 +30,12 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
||||||
with:
|
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_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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
|
|||||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
4. Renumber from 1.
|
4. Renumber from 1.
|
||||||
5. Fix real issues with the smallest safe change.
|
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.
|
7. Add or update tests when behavior changes.
|
||||||
8. Re-check after each fix.
|
8. Re-check after each fix.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 簡介
|
# 簡介
|
||||||
|
|
||||||
這是一個 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 助理的 Commit) 觸發)
|
# 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
||||||
|
|
||||||
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
|
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
|
||||||
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
||||||
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
||||||
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
# 設計
|
# 設計
|
||||||
|
|
||||||
1. Gitea 的相關參數如果 inputs 沒有定義,則從 ${{ gitea.* }} 取得
|
1. Gitea 相關參數中,`GITEA_TOKEN` 必須由 inputs 明確提供;`GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`PR_NUMBER`、`PR_HEAD_BRANCH`、`PR_BASE_BRANCH` 等欄位若 inputs 沒有定義,則從 `${{ gitea.* }}` 取得
|
||||||
2. BASE_URL 如果 inputs 沒有定義,則使用預設值
|
2. BASE_URL 如果 inputs 沒有定義,則使用預設值
|
||||||
3. Comment 加上些許 emoji 讓資訊有點活力
|
3. Comment 加上些許 emoji 讓資訊有點活力
|
||||||
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
||||||
@@ -25,14 +25,17 @@
|
|||||||
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
||||||
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
||||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||||
|
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾
|
1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾
|
||||||
2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml'
|
2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml`
|
||||||
3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用):
|
3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用):
|
||||||
|
|
||||||
> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。
|
> **自動提交排除說明**:此 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)三項權限,為正常運作所必要,無法縮減。若你想讓 comment 用不同權限的 token,可額外傳 `GITEA_COMMENT_TOKEN`,其餘 Gitea 操作仍使用 `GITEA_TOKEN`。
|
||||||
|
|
||||||
### 1. OpenAI
|
### 1. OpenAI
|
||||||
```yaml
|
```yaml
|
||||||
@@ -53,6 +56,8 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
OPENAI_BASE_URL: https://api.openai.com/v1
|
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||||
@@ -81,6 +86,8 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
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_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
|
||||||
OPENAI_BASE_URL: https://openrouter.ai/api/v1
|
OPENAI_BASE_URL: https://openrouter.ai/api/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
||||||
@@ -109,6 +116,8 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
|
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
||||||
permissions:
|
permissions:
|
||||||
@@ -136,6 +145,8 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
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_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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
@@ -164,6 +175,8 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
|
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
AMAZONQ_BASE_URL: https://q.api.aws
|
AMAZONQ_BASE_URL: https://q.api.aws
|
||||||
permissions:
|
permissions:
|
||||||
@@ -172,7 +185,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
### - Ollama
|
### 6. Ollama
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: AI
|
name: AI
|
||||||
@@ -190,10 +203,12 @@ jobs:
|
|||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
||||||
|
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 階段一:基本流程串接
|
## 階段一:基本流程串接
|
||||||
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
||||||
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
- 驗收:log 中能看到每個階段(如「Step1: Pipeline 啟動」、「Step2: Findings 產生」、「Step3: Findings 合併」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
||||||
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
||||||
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。
|
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。
|
||||||
|
|
||||||
## 階段四:AI 去重與角色確認
|
## 階段四:AI 語意去重
|
||||||
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
|
- 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。
|
||||||
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
|
- 驗收:log 中能看到 `AI 去重: N -> M 筆` 的成功訊息,或在失敗時出現 `AI 去重失敗(...),降級:保留所有問題` 之類的明確訊息。
|
||||||
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
||||||
|
|
||||||
## 階段五:AI 排除問題過濾
|
## 階段五:AI 排除問題過濾
|
||||||
- 目標:讀取排除問題檔案(`.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 筆` 的正向路徑。
|
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆` 與 `排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
|
||||||
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -5,6 +5,9 @@ inputs:
|
|||||||
# Gitea 相關(可從 gitea context 自動取得)
|
# Gitea 相關(可從 gitea context 自動取得)
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
description: 'Gitea API Token'
|
description: 'Gitea API Token'
|
||||||
|
required: true
|
||||||
|
GITEA_COMMENT_TOKEN:
|
||||||
|
description: 'Gitea API Token for posting comments only'
|
||||||
required: false
|
required: false
|
||||||
GITEA_SERVER_URL:
|
GITEA_SERVER_URL:
|
||||||
description: 'Gitea Server URL'
|
description: 'Gitea Server URL'
|
||||||
@@ -80,12 +83,14 @@ runs:
|
|||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
env:
|
env:
|
||||||
# Gitea context(優先用 inputs,否則從 gitea context 取)
|
# Gitea context(改為只從 inputs 取得)
|
||||||
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ inputs.GITEA_COMMENT_TOKEN }}
|
||||||
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
|
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
|
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
|
||||||
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
|
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
|
||||||
PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }}
|
||||||
|
PR_HEAD_SHA: ${{ inputs.PR_HEAD_SHA || gitea.event.pull_request.head.sha }}
|
||||||
PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }}
|
PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }}
|
||||||
PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }}
|
PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }}
|
||||||
# LLM
|
# LLM
|
||||||
|
|||||||
+8
-7
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment } from './gitea.js';
|
import { postComment } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
import { ok, line } from './log.js';
|
||||||
|
|
||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
||||||
@@ -27,7 +28,7 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
|
|||||||
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
||||||
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +38,12 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
|
|||||||
export async function postOldFindingsComment(findings) {
|
export async function postOldFindingsComment(findings) {
|
||||||
const old = findings.filter(f => !f.is_new);
|
const old = findings.filter(f => !f.is_new);
|
||||||
if (old.length === 0) {
|
if (old.length === 0) {
|
||||||
console.log(' 無舊問題,跳過');
|
line('無舊問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
|
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
|
||||||
await postComment(body);
|
await postComment(body);
|
||||||
console.log(` ✅ 舊問題 comment 發布 (${old.length} 筆)`);
|
ok(`舊問題 comment 發布 (${old.length} 筆)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,12 +52,12 @@ export async function postOldFindingsComment(findings) {
|
|||||||
export async function postNewNonCriticalComment(findings) {
|
export async function postNewNonCriticalComment(findings) {
|
||||||
const items = findings.filter(f => f.is_new && f.level !== 'critical');
|
const items = findings.filter(f => f.is_new && f.level !== 'critical');
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
console.log(' 無新的非嚴重問題,跳過');
|
line('無新的非嚴重問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
|
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
|
||||||
await postComment(body);
|
await postComment(body);
|
||||||
console.log(` ✅ 新問題(非嚴重)comment 發布 (${items.length} 筆)`);
|
ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,12 +66,12 @@ export async function postNewNonCriticalComment(findings) {
|
|||||||
export async function postNewCriticalComments(findings) {
|
export async function postNewCriticalComments(findings) {
|
||||||
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
||||||
if (criticals.length === 0) {
|
if (criticals.length === 0) {
|
||||||
console.log(' 無新的嚴重問題,跳過');
|
line('無新的嚴重問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const f of criticals) {
|
for (const f of criticals) {
|
||||||
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
|
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
|
||||||
await postComment(body);
|
await postComment(body);
|
||||||
console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
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_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com';
|
||||||
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || '';
|
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || '';
|
||||||
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
|
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
|
||||||
export const PR_NUMBER = process.env.PR_NUMBER || '';
|
export const PR_NUMBER = process.env.PR_NUMBER || '';
|
||||||
|
export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || '';
|
||||||
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
|
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
|
||||||
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
|
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
|
||||||
|
|
||||||
|
|||||||
+215
-23
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chatJSON } from './llm.js';
|
import { chatJSON } from './llm.js';
|
||||||
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
|
import { line, ok, warn } from './log.js';
|
||||||
|
|
||||||
const LEVELS = ['critical', 'warning', 'info'];
|
const LEVELS = ['critical', 'warning', 'info'];
|
||||||
|
|
||||||
@@ -9,11 +10,11 @@ const LEVELS = ['critical', 'warning', 'info'];
|
|||||||
* 用單一角色分析 diff,回傳 findings 陣列
|
* 用單一角色分析 diff,回傳 findings 陣列
|
||||||
*/
|
*/
|
||||||
export async function analyzeWithRole(role, diff) {
|
export async function analyzeWithRole(role, diff) {
|
||||||
console.log(` [${role.name}] 開始分析...`);
|
line(`[${role.name}] 開始分析`);
|
||||||
const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`);
|
const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`);
|
||||||
const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
|
const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
|
||||||
.map(f => ({ ...f, is_new: true }));
|
.map(f => ({ ...f, is_new: true }));
|
||||||
console.log(` [${role.name}] 找到 ${valid.length} 個問題`);
|
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,30 +23,189 @@ export async function analyzeWithRole(role, diff) {
|
|||||||
*/
|
*/
|
||||||
function readJSONArray(fullPath, label) {
|
function readJSONArray(fullPath, label) {
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
console.log(` ${label}檔案不存在,視為空`);
|
warn(`${label}檔案不存在,視為空`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`);
|
warn(`讀取${label}失敗: ${e.message},視為空`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExclusions(data) {
|
function normalizeExclusions(data) {
|
||||||
if (Array.isArray(data)) return data;
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.exclusions)) return data.exclusions;
|
||||||
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
|
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
|
||||||
return [];
|
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)
|
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
||||||
*/
|
*/
|
||||||
export function loadOldFindings(workspace) {
|
export function loadOldFindings(workspace) {
|
||||||
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false }));
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||||
|
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
} else {
|
||||||
|
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
||||||
|
}
|
||||||
|
ok(`讀取舊 findings: ${old.length} 筆`);
|
||||||
return old;
|
return old;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +221,7 @@ export function mergeFindings(oldFindings, newFindings) {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const merged = [...oldFindings, ...deduped];
|
const merged = [...oldFindings, ...deduped];
|
||||||
console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
|
ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +238,7 @@ export function sortByLevel(findings) {
|
|||||||
function fallback(label, findings, e) {
|
function fallback(label, findings, e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
|
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
|
||||||
console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`);
|
warn(`${label}失敗(${reason}),降級:保留所有問題`);
|
||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +258,7 @@ export async function deduplicateWithAI(findings) {
|
|||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`);
|
ok(`AI 去重: ${findings.length} -> ${result.length} 筆`);
|
||||||
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
|
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
|
||||||
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||||
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
||||||
@@ -112,23 +272,49 @@ export async function deduplicateWithAI(findings) {
|
|||||||
/**
|
/**
|
||||||
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
||||||
*/
|
*/
|
||||||
export function loadExclusions(workspace) {
|
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
|
||||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
console.log(' 排除問題檔案不存在,視為空');
|
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
|
||||||
console.log(' 讀取排除問題: 0 筆');
|
if (repoState) {
|
||||||
|
const branch = repoState.branch || 'detached';
|
||||||
|
const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
|
||||||
|
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
|
||||||
|
}
|
||||||
|
ok('讀取排除問題: raw=0 normalized=0 筆');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let exclusions = [];
|
let exclusions = [];
|
||||||
|
let rawCount = 0;
|
||||||
try {
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
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';
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空`);
|
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
||||||
exclusions = [];
|
exclusions = [];
|
||||||
}
|
}
|
||||||
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
const summary = buildExclusionContext(exclusions);
|
||||||
|
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount} 筆`);
|
||||||
return exclusions;
|
return exclusions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,29 +327,35 @@ export function applyExclusions(findings, exclusions) {
|
|||||||
const before = findings.length;
|
const before = findings.length;
|
||||||
const filtered = findings.filter(f => !exclusions.some(ex => {
|
const filtered = findings.filter(f => !exclusions.some(ex => {
|
||||||
const fPath = String(f.location).split(':')[0];
|
const fPath = String(f.location).split(':')[0];
|
||||||
const exPath = ex.location ? String(ex.location).split(':')[0] : null;
|
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
|
||||||
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
|
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;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
||||||
*/
|
*/
|
||||||
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const exclusionHint = exclusions.length > 0
|
const exclusionContext = buildExclusionContext(exclusions);
|
||||||
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
|
const exclusionHint = exclusionContext.prompt
|
||||||
|
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
||||||
|
|
||||||
try {
|
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) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
ok(`AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
||||||
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||||
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-2
@@ -3,17 +3,25 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { loadExclusions, applyExclusions } from './findings.js';
|
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
||||||
import { EXCLUSIONS_PATH } from './config.js';
|
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
describe('findings exclusions', () => {
|
describe('findings exclusions', () => {
|
||||||
let workspace;
|
let workspace;
|
||||||
|
let logs;
|
||||||
|
let originalLog;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
|
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
|
||||||
|
logs = [];
|
||||||
|
originalLog = console.log;
|
||||||
|
console.log = (...args) => {
|
||||||
|
logs.push(args.join(' '));
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
console.log = originalLog;
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +41,47 @@ describe('findings exclusions', () => {
|
|||||||
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
|
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', () => {
|
it('applies exclusions loaded from wrapper format', () => {
|
||||||
const findings = [
|
const findings = [
|
||||||
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
|
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
|
||||||
@@ -47,4 +96,88 @@ describe('findings exclusions', () => {
|
|||||||
assert.equal(filtered.length, 1);
|
assert.equal(filtered.length, 1);
|
||||||
assert.equal(filtered[0].location, 'README.md:12');
|
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 });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify([
|
||||||
|
{ location: 'entrypoint.sh:180', suggestion: 'ignore' },
|
||||||
|
{ location: 'README.md:12', suggestion: 'ignore' },
|
||||||
|
], null, 2));
|
||||||
|
|
||||||
|
const repoState = {
|
||||||
|
branch: 'feat/test',
|
||||||
|
shortSha: 'abc1234',
|
||||||
|
commitTime: '2026-05-15T09:29:49.817Z',
|
||||||
|
repoDir: path.join(workspace, 'repo'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const exclusions = loadExclusions(workspace, repoState);
|
||||||
|
|
||||||
|
assert.equal(exclusions.length, 2);
|
||||||
|
assert.ok(logs.some(line => line.includes(`讀取排除問題檔案: ${fullPath}`)));
|
||||||
|
assert.ok(logs.some(line => line.includes('來源分支狀態: branch=feat/test commit=abc1234')));
|
||||||
|
assert.ok(logs.some(line => line.includes('raw=2 normalized=2')));
|
||||||
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs findings file metadata when loading old findings', () => {
|
||||||
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify([
|
||||||
|
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
||||||
|
], null, 2));
|
||||||
|
|
||||||
|
const findings = loadOldFindings(workspace);
|
||||||
|
|
||||||
|
assert.equal(findings.length, 1);
|
||||||
|
assert.equal(findings[0].is_new, false);
|
||||||
|
assert.ok(logs.some(line => line.includes(`讀取舊 findings 檔案: ${fullPath}`)));
|
||||||
|
assert.ok(logs.some(line => line.includes('舊 findings 檔案資訊: bytes=')));
|
||||||
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+97
-18
@@ -3,10 +3,12 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||||||
|
import { line, ok, warn } from './log.js';
|
||||||
|
|
||||||
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||||||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
|
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||||||
export const SYNC_PATHS = [
|
export const SYNC_PATHS = [
|
||||||
'.amazonq/rules/triage-findings.md',
|
'.amazonq/rules/triage-findings.md',
|
||||||
'.codex/skills/triage-findings/SKILL.md',
|
'.codex/skills/triage-findings/SKILL.md',
|
||||||
@@ -18,6 +20,17 @@ export const SYNC_PATHS = [
|
|||||||
'CLAUDE.md',
|
'CLAUDE.md',
|
||||||
'GEMINI.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) {
|
function makeRunner(spawn) {
|
||||||
return function run(args, cwd, env) {
|
return function run(args, cwd, env) {
|
||||||
@@ -41,6 +54,61 @@ function withAskpass(workspace, fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readGitOutput(run, args, cwd, env) {
|
||||||
|
try {
|
||||||
|
return run(args, cwd, env);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
|
||||||
|
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
|
||||||
|
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
|
||||||
|
return { repoDir, branch, headSha, shortSha, commitTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
||||||
|
const run = makeRunner(_spawnSync);
|
||||||
|
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||||||
|
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* Clone PR head branch to workspace/repo (idempotent)
|
||||||
*/
|
*/
|
||||||
@@ -51,17 +119,17 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
return withAskpass(workspace, credEnv => {
|
return withAskpass(workspace, credEnv => {
|
||||||
if (!fs.existsSync(repoDir)) {
|
if (!fs.existsSync(repoDir)) {
|
||||||
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
||||||
console.log(` ✅ repo cloned to ${repoDir}`);
|
ok(`repo cloned to ${repoDir}`);
|
||||||
} else {
|
} else {
|
||||||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
||||||
console.log(` ✅ repo already exists, fetched latest`);
|
ok('repo already exists, fetched latest');
|
||||||
}
|
}
|
||||||
return repoDir;
|
return repoDir;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const run = makeRunner(_spawnSync);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,21 +141,31 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
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.
|
// missing source files are ignored so we do not delete target repo content.
|
||||||
for (const relPath of SYNC_PATHS) {
|
for (const relDir of SYNC_TREE_PATHS) {
|
||||||
const src = path.join(sourceRoot, relPath);
|
for (const relPath of copyTree(sourceRoot, repoDir, relDir)) {
|
||||||
const dest = path.join(repoDir, relPath);
|
existingSyncPaths.add(relPath);
|
||||||
if (fs.existsSync(src)) {
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
existingSyncPaths.push(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);
|
run(['add', ...existingSyncPaths], repoDir);
|
||||||
}
|
}
|
||||||
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||||||
@@ -103,20 +181,21 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
|
|
||||||
const status = run(['status', '--porcelain'], repoDir);
|
const status = run(['status', '--porcelain'], repoDir);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
console.log(' sync files 無變更,跳過 commit');
|
line('sync files 無變更,跳過 commit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], 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';
|
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||||||
try {
|
try {
|
||||||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
|
||||||
} catch (pushErr) {
|
} catch (pushErr) {
|
||||||
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`);
|
warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
warn(`Runner failed: commit/push 失敗: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-2
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js';
|
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -60,6 +60,26 @@ describe('commitAndPush', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tags auto commits with the bot marker for workflow filtering', async () => {
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
|
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 () => {
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
@@ -139,11 +159,30 @@ describe('commitAndPush', () => {
|
|||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
|
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, '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);
|
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, '.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, '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 () => {
|
it('does not throw when git command fails', async () => {
|
||||||
@@ -165,12 +204,16 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
const logs = [];
|
const logs = [];
|
||||||
const originalLog = console.log;
|
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 {
|
try {
|
||||||
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
} finally {
|
} finally {
|
||||||
console.log = originalLog;
|
console.log = originalLog;
|
||||||
|
console.warn = originalWarn;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
||||||
@@ -232,4 +275,13 @@ describe('cloneRepo', () => {
|
|||||||
const result = cloneRepo(workspace, spawn);
|
const result = cloneRepo(workspace, spawn);
|
||||||
assert.equal(result, path.join(workspace, 'repo'));
|
assert.equal(result, path.join(workspace, 'repo'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads head commit message and detects bot auto commits', () => {
|
||||||
|
const spawn = makeSpawn({
|
||||||
|
show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER));
|
||||||
|
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+83
-3
@@ -1,11 +1,24 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER } from './config.js';
|
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js';
|
||||||
|
import { line, ok, warn } from './log.js';
|
||||||
|
|
||||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||||
const headers = () => ({ 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}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
|
|
||||||
|
function extractCommitMessage(payload) {
|
||||||
|
return payload?.message
|
||||||
|
|| payload?.commit?.message
|
||||||
|
|| payload?.commit?.commit?.message
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotReviewOutcome(message) {
|
||||||
|
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
|
||||||
|
return match?.[1]?.toLowerCase() || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
|
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +38,69 @@ export async function getPRDiff() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCommitMessageBySha(sha) {
|
||||||
|
if (!sha) return '';
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/git/commits/${encodeURIComponent(sha)}`), {
|
||||||
|
headers: headers(),
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent,
|
||||||
|
});
|
||||||
|
const message = extractCommitMessage(resp.data);
|
||||||
|
line(`bot-check commit api: sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`);
|
||||||
|
return message;
|
||||||
|
} catch (e) {
|
||||||
|
warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
||||||
|
if (!branch) return '';
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/branches/${encodeURIComponent(branch)}`), {
|
||||||
|
headers: headers(),
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent,
|
||||||
|
});
|
||||||
|
const sha = resp.data?.commit?.id || resp.data?.commit?.sha || '';
|
||||||
|
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) {
|
||||||
|
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 } = {}) {
|
||||||
|
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) {
|
||||||
|
line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`);
|
||||||
|
if (shaMessage.includes('[ai-review-bot]')) {
|
||||||
|
ok('bot-check matched commit sha marker');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line('bot-check skip sha lookup because sha is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchMessage = await getBranchHeadCommitMessage(branch);
|
||||||
|
if (branch) {
|
||||||
|
line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`);
|
||||||
|
if (branchMessage.includes('[ai-review-bot]')) {
|
||||||
|
ok('bot-check matched branch head marker');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line('bot-check skip branch lookup because branch is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
line('bot-check no [ai-review-bot] marker found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。
|
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。
|
||||||
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
|
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
|
||||||
@@ -40,6 +116,10 @@ export function filterDiff(diff, excludePrefixes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function postComment(body) {
|
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;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getPRDiff, filterDiff, postComment } from './gitea.js';
|
import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -56,6 +56,48 @@ describe('gitea', () => {
|
|||||||
mock.method(axios, 'post', async () => { throw new Error('api error'); });
|
mock.method(axios, 'post', async () => { throw new Error('api error'); });
|
||||||
await assert.rejects(() => postComment('test'), /api error/);
|
await assert.rejects(() => postComment('test'), /api error/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
||||||
|
let capturedUrl;
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
|
||||||
|
});
|
||||||
|
const message = await getCommitMessageBySha('abc123');
|
||||||
|
assert.ok(capturedUrl.includes('/git/commits/abc123'));
|
||||||
|
assert.ok(message.includes('[ai-review-bot]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getBranchHeadCommitMessage reads branch head commit message from Gitea API', async () => {
|
||||||
|
const urls = [];
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
urls.push(url);
|
||||||
|
if (url.includes('/branches/feat%2Ftest')) {
|
||||||
|
return { data: { commit: { id: 'abc123' } } };
|
||||||
|
}
|
||||||
|
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
|
||||||
|
});
|
||||||
|
const message = await getBranchHeadCommitMessage('feat/test');
|
||||||
|
assert.ok(urls.some(url => url.includes('/branches/feat%2Ftest')));
|
||||||
|
assert.ok(urls.some(url => url.includes('/git/commits/abc123')));
|
||||||
|
assert.ok(message.includes('[ai-review-bot]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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][failure]' } };
|
||||||
|
}
|
||||||
|
if (url.includes('/branches/feat%2Ftest')) {
|
||||||
|
return { data: { commit: { id: 'sha-bot' } } };
|
||||||
|
}
|
||||||
|
return { data: { message: 'regular commit' } };
|
||||||
|
});
|
||||||
|
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true);
|
||||||
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterDiff', () => {
|
describe('filterDiff', () => {
|
||||||
|
|||||||
+7
-6
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chat } from './llm.js';
|
import { chat } from './llm.js';
|
||||||
|
import { ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
@@ -50,25 +51,25 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
console.log(` ⚠️ ${label} 不存在,將於驗證後補建`);
|
warn(`${label} 不存在,將於驗證後補建`);
|
||||||
return { exists: false, valid: false, repaired: false };
|
return { exists: false, valid: false, repaired: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JSON.parse(readJSONText(fullPath, label));
|
JSON.parse(readJSONText(fullPath, label));
|
||||||
console.log(` ✅ ${label} JSON 格式正確`);
|
ok(`${label} JSON 格式正確`);
|
||||||
return { exists: true, valid: true, repaired: false };
|
return { exists: true, valid: true, repaired: false };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(` ❌ ${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
|
error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
|
||||||
try {
|
try {
|
||||||
const original = readJSONText(fullPath, label);
|
const original = readJSONText(fullPath, label);
|
||||||
const repaired = await repairer(fullPath, label, original);
|
const repaired = await repairer(fullPath, label, original);
|
||||||
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
|
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
|
||||||
JSON.parse(readJSONText(fullPath, label));
|
JSON.parse(readJSONText(fullPath, label));
|
||||||
console.log(` ✅ ${label} 已由 AI 修正並通過再次驗證`);
|
ok(`${label} 已由 AI 修正並通過再次驗證`);
|
||||||
return { exists: true, valid: true, repaired: true };
|
return { exists: true, valid: true, repaired: true };
|
||||||
} catch (repairErr) {
|
} catch (repairErr) {
|
||||||
console.error(` ❌ ${label} 修正失敗: ${repairErr.message}`);
|
error(`${label} 修正失敗: ${repairErr.message}`);
|
||||||
throw repairErr;
|
throw repairErr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,6 @@ export function ensureJSONArrayFileExists(fullPath, label) {
|
|||||||
if (fs.existsSync(fullPath)) return false;
|
if (fs.existsSync(fullPath)) return false;
|
||||||
|
|
||||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||||
console.log(` ⚠️ ${label} 不存在,已建立空陣列`);
|
warn(`${label} 不存在,已建立空陣列`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -1,11 +1,12 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getLLMConfig } from './config.js';
|
import { getLLMConfig } from './config.js';
|
||||||
|
import { line, error } from './log.js';
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) throw new Error('未設定任何 LLM API Key');
|
if (!provider) throw new Error('未設定任何 LLM API Key');
|
||||||
|
|
||||||
console.log(` [LLM] provider=${provider} model=${model}`);
|
line(`[LLM] provider=${provider} model=${model}`);
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
||||||
@@ -21,10 +22,10 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
);
|
);
|
||||||
return resp.data.choices[0].message.content;
|
return resp.data.choices[0].message.content;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` [LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error(' [LLM] 所有 API Key 均失敗,終止流程');
|
error('[LLM] 所有 API Key 均失敗,終止流程');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export async function chatJSON(systemPrompt, userContent) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
|
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` [LLM] JSON 解析失敗: ${e.message}`);
|
line(`[LLM] JSON 解析失敗: ${e.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+60
-48
@@ -1,106 +1,118 @@
|
|||||||
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, postComment } 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 { 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, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
|
import { section, step, line, ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('='.repeat(60));
|
section('AI Code Review Pipeline');
|
||||||
console.log('🚀 Step1: Pipeline 啟動');
|
step('Step1', 'Pipeline 啟動');
|
||||||
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
||||||
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
||||||
|
|
||||||
|
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
|
||||||
|
const 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()) {
|
||||||
|
ok('偵測到 [ai-review-bot] 自動提交,直接完成 action');
|
||||||
|
section('Pipeline 結束');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
|
error('未設定任何 LLM API Key,請檢查 action inputs');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`);
|
line(`LLM: provider=${provider} model=${model} base_url=${baseURL}`);
|
||||||
|
|
||||||
const roles = loadRoles();
|
const roles = loadRoles();
|
||||||
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
|
line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
|
||||||
|
|
||||||
let diff;
|
let diff;
|
||||||
try {
|
try {
|
||||||
diff = await getPRDiff();
|
diff = await getPRDiff();
|
||||||
console.log(` diff 長度: ${diff.length} 字元`);
|
line(`diff 長度: ${diff.length} 字元`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(` ❌ 取得 diff 失敗: ${e.message}`);
|
error(`取得 diff 失敗: ${e.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
console.log(' ⚠️ diff 為空,無需審查');
|
warn('diff 為空,無需審查');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
||||||
await postComment(intro);
|
await postComment(intro);
|
||||||
console.log(' ✅ 角色介紹 comment 發布成功');
|
ok('角色介紹 comment 發布成功');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step2: 各角色分析 diff 產生新 findings
|
step('Step2', 'Findings 產生');
|
||||||
console.log('\n📊 Step2: Findings 產生');
|
|
||||||
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
||||||
const newFindings = [];
|
const newFindings = [];
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
if (results[i].status === 'fulfilled') {
|
if (results[i].status === 'fulfilled') {
|
||||||
newFindings.push(...results[i].value);
|
newFindings.push(...results[i].value);
|
||||||
} else {
|
} else {
|
||||||
console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
|
warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||||
|
|
||||||
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
|
step('Step3', 'Findings 合併與語意去重');
|
||||||
console.log('\n🔀 Step3: Findings 合併');
|
|
||||||
// Clone repo 以讀取舊 findings 與排除清單
|
|
||||||
let repoDir;
|
let repoDir;
|
||||||
try {
|
try {
|
||||||
repoDir = cloneRepo(WORKSPACE);
|
repoDir = cloneRepo(WORKSPACE);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`);
|
warn(`clone repo 失敗(繼續執行): ${e.message}`);
|
||||||
|
}
|
||||||
|
const repoState = repoDir ? getRepoState(repoDir) : null;
|
||||||
|
if (repoState) {
|
||||||
|
line(`repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`);
|
||||||
}
|
}
|
||||||
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||||
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||||
console.log(` Step3 merged findings total=${mergedFindings.length}`);
|
ok(`Step3 merged findings total=${mergedFindings.length}`);
|
||||||
|
|
||||||
console.log('\n🤖 Step3b: AI 語意去重');
|
|
||||||
const deduped = await deduplicateWithAI(mergedFindings);
|
const deduped = await deduplicateWithAI(mergedFindings);
|
||||||
const sorted = sortByLevel(deduped);
|
const sorted = sortByLevel(deduped);
|
||||||
console.log(` Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
||||||
|
|
||||||
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
step('Step4', 'AI 排除問題過濾');
|
||||||
console.log('\n🚫 Step4: AI 排除問題過濾');
|
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
|
||||||
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
|
||||||
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(` Step4 完成: findings total=${filtered.length}`);
|
ok(`Step4 完成: findings total=${filtered.length}`);
|
||||||
|
|
||||||
// Step6: 寫入 findings.json,依序發布 comment
|
step('Step5', 'Findings 寫入與 Comment 發布');
|
||||||
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
|
||||||
const reviewDir = repoDir || WORKSPACE;
|
const reviewDir = repoDir || WORKSPACE;
|
||||||
saveFindings(WORKSPACE, filtered, reviewDir);
|
saveFindings(WORKSPACE, filtered, reviewDir);
|
||||||
try {
|
try {
|
||||||
await postOldFindingsComment(filtered);
|
await postOldFindingsComment(filtered);
|
||||||
await postNewNonCriticalComment(filtered);
|
await postNewNonCriticalComment(filtered);
|
||||||
await postNewCriticalComments(filtered);
|
await postNewCriticalComments(filtered);
|
||||||
console.log(' Step5 完成');
|
ok('Step5 完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
|
step('Step6', 'JSON 格式驗證');
|
||||||
console.log('\n🔎 Step6: JSON 格式驗證');
|
|
||||||
const missingPaths = [];
|
const missingPaths = [];
|
||||||
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||||
const fullPath = path.join(reviewDir, relPath);
|
const fullPath = path.join(reviewDir, relPath);
|
||||||
@@ -116,24 +128,24 @@ async function main() {
|
|||||||
ensureJSONArrayFileExists(fullPath, relPath);
|
ensureJSONArrayFileExists(fullPath, relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step7: commit/push findings.json 到來源分支
|
step('Step7', '記憶區 Commit/Push');
|
||||||
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
|
||||||
await commitAndPush(WORKSPACE, repoDir || WORKSPACE);
|
line(`review outcome=${reviewOutcome}`);
|
||||||
|
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
|
||||||
|
|
||||||
// Step9: 有 critical 問題則 exit 1
|
step('Step8', '嚴重問題檢查');
|
||||||
console.log('\n🚦 Step8: 嚴重問題檢查');
|
|
||||||
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
||||||
if (criticalCount > 0) {
|
if (criticalCount > 0) {
|
||||||
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
||||||
console.log('='.repeat(60));
|
section('Pipeline 結束');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(' ✅ 無嚴重問題');
|
ok('無嚴重問題');
|
||||||
console.log('\n✅ Pipeline 完成');
|
ok('Pipeline 完成');
|
||||||
console.log('='.repeat(60));
|
section('Pipeline 結束');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(e => {
|
main().catch(e => {
|
||||||
console.error('❌ Runner failed:', e.message);
|
error(`Runner failed: ${e.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user