Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 480a0693f7 | |||
| 79506eb905 | |||
| 8872e7366a | |||
| 7616dd1816 | |||
| 9bef365a32 | |||
| 21b3df6d79 | |||
| cc6345c32e | |||
| c758c99a28 | |||
| 505cf6d30d | |||
| c3e57ff442 | |||
| 5876154dbb | |||
| 0e0cd252b0 | |||
| fcc8d59f7a | |||
| a92b6440ff | |||
| 8d8ace636e | |||
| fdeceee52f | |||
| fade942267 | |||
| 4834396652 | |||
| 0108a05886 | |||
| 6db660f872 | |||
| 45468d89d3 | |||
| 6c6680fd3e | |||
| 49a02ebb6b | |||
| 37cf5f82fa | |||
| fd854649db | |||
| de8de251ba | |||
| fe7381c36e | |||
| abfd594bb2 | |||
| 8878165a81 | |||
| 818342d27b | |||
| d95213334b |
@@ -149,6 +149,11 @@
|
|||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "app/main.js",
|
||||||
|
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/package.json",
|
"location": "app/package.json",
|
||||||
@@ -203,5 +208,40 @@
|
|||||||
"role": "Zara",
|
"role": "Zara",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "app/comments.js",
|
||||||
|
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "app/gitea.js",
|
||||||
|
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "TODO.md",
|
||||||
|
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Rex",
|
||||||
|
"location": "app/gitea.js",
|
||||||
|
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Zara",
|
||||||
|
"location": "app/git.js",
|
||||||
|
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Aria",
|
||||||
|
"location": "app/main.js",
|
||||||
|
"suggestion": "在 main.js 中,表達式 repoDir。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Zara",
|
||||||
|
"location": "app/gitea.js:L20-L21",
|
||||||
|
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Rex",
|
|
||||||
"location": "action.yaml",
|
|
||||||
"suggestion": "此 Action 需要 `contents: write`、`pull-requests: write` 和 `issues: write` 權限。這些權限對於 Action 的正常運作是必要的(例如寫入 findings.json、發布評論),但屬於較廣泛的權限。建議在文件或使用說明中明確指出這些權限的需求及其潛在影響,確保使用者了解並接受。",
|
|
||||||
"is_new": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ jobs:
|
|||||||
tag_name: v${{ steps.version.outputs.version }}
|
tag_name: v${{ steps.version.outputs.version }}
|
||||||
target_commitish: ${{ github.head_ref }}
|
target_commitish: ${{ github.head_ref }}
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
needs: [version]
|
needs: [version]
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@v${{ needs.version.outputs.version }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
||||||
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
||||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||||
|
7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
|
||||||
|
8. 階段五完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1
|
||||||
|
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
@@ -29,19 +32,26 @@
|
|||||||
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)三項權限,為正常運作所必要,無法縮減。
|
||||||
|
|
||||||
### 1. OpenAI
|
### 1. OpenAI
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -55,16 +65,21 @@ jobs:
|
|||||||
### 2. OpenRouter
|
### 2. OpenRouter
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -78,16 +93,21 @@ jobs:
|
|||||||
### 3. Anthropic Claude
|
### 3. Anthropic Claude
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -100,16 +120,21 @@ jobs:
|
|||||||
### 4. Google Gemini
|
### 4. Google Gemini
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -123,16 +148,21 @@ jobs:
|
|||||||
### 5. Amazon Q
|
### 5. Amazon Q
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -146,22 +176,26 @@ jobs:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
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:
|
||||||
code-review:
|
code-review:
|
||||||
name: 'Code Review'
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/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
|
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
||||||
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
@@ -3,43 +3,57 @@
|
|||||||
## 階段一:基本流程串接
|
## 階段一:基本流程串接
|
||||||
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
|
||||||
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
|
||||||
- 完成
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Findings 產生與合併
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
|
||||||
|
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
|
||||||
|
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
||||||
|
|
||||||
|
## 階段三:Findings 產生與合併
|
||||||
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
||||||
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
||||||
- 完成
|
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。
|
||||||
|
|
||||||
## 階段三:AI 去重與角色確認
|
## 階段四:AI 去重與角色確認
|
||||||
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
|
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
|
||||||
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
|
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
|
||||||
- 完成
|
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
||||||
|
|
||||||
## 階段四:AI 排除問題過濾
|
## 階段五:AI 排除問題過濾
|
||||||
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
||||||
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
||||||
- 完成
|
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆` 與 `排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
|
||||||
|
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
||||||
|
|
||||||
## 階段五:findings 寫入與 comment 發布
|
## 階段六:findings 寫入與 comment 發布
|
||||||
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
||||||
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
||||||
- 完成
|
- 部分驗收:`findings.json` 已成功寫入,也有依序執行舊問題、非嚴重、嚴重 comment 流程;但本次因結果為 0 筆,沒有實際 comment 內容可完整驗證順序。
|
||||||
|
- 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布`、`新問題(非嚴重)comment 發布`、`嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。
|
||||||
|
|
||||||
## 階段六:記憶區 commit/push 與錯誤處理
|
## 階段七:階段六後驗證 JSON 格式
|
||||||
|
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。
|
||||||
|
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。
|
||||||
|
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||||
|
|
||||||
|
## 階段八:記憶區 commit/push 與錯誤處理
|
||||||
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 完成
|
- 已驗收:log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功。
|
||||||
|
|
||||||
## 階段七:阻擋嚴重問題 PR(第 8 點)
|
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||||
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
||||||
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
||||||
- 完成
|
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證。
|
||||||
|
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log。
|
||||||
|
|
||||||
## 階段八:API Key 輪替
|
## 階段十:API Key 輪替
|
||||||
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
||||||
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
||||||
- 完成
|
- 已驗收:`review.yaml` 已以逗號串接多把 Gemini key,且 `app/llm.js` 與單元測試已覆蓋輪替與失敗退出行為。
|
||||||
|
|
||||||
---
|
## 階段十一:壓縮 AI 傳入內容減少 token 用量
|
||||||
|
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion;AI 回傳後補回原始完整欄位(含 is_new)。
|
||||||
所有階段驗收通過。
|
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
|
||||||
|
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ export async function postNewCriticalComments(findings) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const f of criticals) {
|
for (const f of criticals) {
|
||||||
const body = `## 🚨 嚴重問題\n\n| 審查員 | 位置 | 建議 |\n|--------|------|------|\n| ${f.role} | ${f.location} | ${f.suggestion} |`;
|
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
|
||||||
await postComment(body);
|
await postComment(body);
|
||||||
console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-66
@@ -11,7 +11,6 @@ const LEVELS = ['critical', 'warning', 'info'];
|
|||||||
export async function analyzeWithRole(role, diff) {
|
export async function analyzeWithRole(role, diff) {
|
||||||
console.log(` [${role.name}] 開始分析...`);
|
console.log(` [${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} 個問題`);
|
console.log(` [${role.name}] 找到 ${valid.length} 個問題`);
|
||||||
@@ -19,28 +18,33 @@ export async function analyzeWithRole(role, diff) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH)
|
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
|
||||||
*/
|
*/
|
||||||
export function loadOldFindings(workspace) {
|
function readJSONArray(fullPath, label) {
|
||||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
console.log(' 舊 findings 檔案不存在,視為空');
|
console.log(` ${label}檔案不存在,視為空`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
const old = (Array.isArray(data) ? data : []).map(f => ({ ...f, is_new: false }));
|
return Array.isArray(data) ? data : [];
|
||||||
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
|
||||||
return old;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ 讀取舊 findings 失敗: ${e.message},視為空`);
|
console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH)
|
||||||
|
*/
|
||||||
|
export function loadOldFindings(workspace) {
|
||||||
|
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false }));
|
||||||
|
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
|
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
|
||||||
* 舊問題保留,新問題若與舊問題重複則捨棄
|
|
||||||
*/
|
*/
|
||||||
export function mergeFindings(oldFindings, newFindings) {
|
export function mergeFindings(oldFindings, newFindings) {
|
||||||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
||||||
@@ -63,56 +67,49 @@ export function sortByLevel(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 呼叫 LLM 進行語意去重,回傳去重後的 findings
|
* AI 呼叫失敗時的統一降級處理
|
||||||
* 失敗時降級回傳原始 findings
|
*/
|
||||||
|
function fallback(label, findings, e) {
|
||||||
|
const status = e.response?.status;
|
||||||
|
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
|
||||||
|
console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`);
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只保留 AI 需要的欄位,減少 token 用量 */
|
||||||
|
function toAIPayload(findings) {
|
||||||
|
return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
||||||
*/
|
*/
|
||||||
export async function deduplicateWithAI(findings) {
|
export async function deduplicateWithAI(findings) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const systemPrompt = `你是一位程式碼審查問題去重專家。
|
const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`;
|
||||||
給你一份問題清單(JSON 陣列),請移除語意重複的問題(即使描述文字不同,但指的是同一個問題)。
|
|
||||||
保留等級較高的版本,優先保留 critical > warning > info。
|
|
||||||
只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
|
||||||
|
|
||||||
const userContent = `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, userContent);
|
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`);
|
console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`);
|
||||||
return result;
|
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
|
||||||
|
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||||
|
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
||||||
}
|
}
|
||||||
throw new Error('AI 回傳空陣列');
|
throw new Error('AI 回傳空陣列');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
return fallback('AI 去重', findings, e);
|
||||||
if (status === 402 || status === 429) {
|
|
||||||
console.log(` ⚠️ AI 去重失敗(${status} 額度/限流),降級:保留所有問題`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ AI 去重失敗(${e.message}),降級:保留所有問題`);
|
|
||||||
}
|
|
||||||
return findings;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH)
|
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH)
|
||||||
* 格式:[{ role, location, suggestion }],欄位可部分省略,省略表示萬用
|
|
||||||
*/
|
*/
|
||||||
export function loadExclusions(workspace) {
|
export function loadExclusions(workspace) {
|
||||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題');
|
||||||
if (!fs.existsSync(fullPath)) {
|
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
||||||
console.log(' 排除問題檔案不存在,跳過過濾');
|
return exclusions;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
||||||
const exclusions = Array.isArray(data) ? data : [];
|
|
||||||
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
|
||||||
return exclusions;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},跳過過濾`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,49 +122,33 @@ export function applyExclusions(findings, exclusions) {
|
|||||||
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.location ? String(ex.location).split(':')[0] : null;
|
||||||
return (!exPath || fPath === exPath) &&
|
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
|
||||||
(!ex.role || ex.role === f.role);
|
|
||||||
}));
|
}));
|
||||||
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
|
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 呼叫 AI 判斷哪些問題是誤報或不需處理,回傳需保留的 findings
|
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
||||||
* exclusions 為已知誤報清單,供 AI 參考判斷
|
|
||||||
* 失敗時降級回傳原始 findings
|
|
||||||
*/
|
*/
|
||||||
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const exclusionHint = exclusions.length > 0
|
const exclusionHint = exclusions.length > 0
|
||||||
? `\n\n以下是已知的誤報或不需處理的問題清單(供參考,相同檔案路徑且語意相近的問題應一併排除):\n${JSON.stringify(exclusions, null, 2)}`
|
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const systemPrompt = `你是一位資深程式碼審查專家,負責判斷審查問題是否為誤報或不需處理。
|
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
||||||
給你一份問題清單(JSON 陣列),每筆包含 level、role、location、suggestion。
|
|
||||||
請移除以下類型的問題:
|
|
||||||
1. 誤報:問題描述與實際程式碼不符(例如:程式碼已正確使用環境變數或 secrets,卻被標記為硬編碼敏感資料)
|
|
||||||
2. 不適用:問題在此專案情境下不需處理(例如:CI/CD action 本來就需要透過環境變數傳遞 token)
|
|
||||||
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
|
|
||||||
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
|
||||||
|
|
||||||
const userContent = `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, userContent);
|
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
||||||
return result;
|
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||||
|
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
||||||
}
|
}
|
||||||
throw new Error('AI 回傳空陣列或非陣列');
|
throw new Error('AI 回傳空陣列或非陣列');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
return fallback('AI 誤報過濾', findings, e);
|
||||||
if (status === 402 || status === 429) {
|
|
||||||
console.log(` ⚠️ AI 誤報過濾失敗(${status} 額度/限流),降級:保留所有問題`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ AI 誤報過濾失敗(${e.message}),降級:保留所有問題`);
|
|
||||||
}
|
|
||||||
return findings;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-48
@@ -3,6 +3,8 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
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';
|
||||||
|
|
||||||
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
|
|
||||||
function makeRunner(spawn) {
|
function makeRunner(spawn) {
|
||||||
return function run(args, cwd, env) {
|
return function run(args, cwd, env) {
|
||||||
const opts = { cwd, encoding: 'utf8' };
|
const opts = { cwd, encoding: 'utf8' };
|
||||||
@@ -14,20 +16,25 @@ function makeRunner(spawn) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withAskpass(workspace, fn) {
|
||||||
|
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||||||
|
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
||||||
|
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
||||||
|
try {
|
||||||
|
return fn(credEnv);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(askpassScript); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* Clone PR head branch to workspace/repo (idempotent)
|
||||||
*/
|
*/
|
||||||
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
const baseUrl = GITEA_SERVER_URL.replace(/\/$/, '');
|
|
||||||
const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`;
|
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
|
||||||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
return withAskpass(workspace, credEnv => {
|
||||||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
|
||||||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
|
||||||
|
|
||||||
try {
|
|
||||||
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}`);
|
console.log(` ✅ repo cloned to ${repoDir}`);
|
||||||
@@ -36,57 +43,37 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
||||||
console.log(` ✅ repo already exists, fetched latest`);
|
console.log(` ✅ repo already exists, fetched latest`);
|
||||||
}
|
}
|
||||||
} finally {
|
return repoDir;
|
||||||
try { fs.unlinkSync(askpassScript); } catch {}
|
});
|
||||||
}
|
|
||||||
return repoDir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
|
||||||
const baseUrl = GITEA_SERVER_URL.replace(/\/$/, '');
|
|
||||||
const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`;
|
|
||||||
const repoDir = path.join(workspace, 'repo');
|
|
||||||
|
|
||||||
// Write a temporary askpass script that reads the token from an env var,
|
|
||||||
// so the token value never appears in the script file itself
|
|
||||||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
|
||||||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
|
||||||
|
|
||||||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(repoDir)) {
|
await withAskpass(workspace, async credEnv => {
|
||||||
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||||
}
|
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||||
|
|
||||||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
||||||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
const destFindings = path.join(repoDir, FINDINGS_PATH);
|
||||||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
||||||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
fs.copyFileSync(srcFindings, destFindings);
|
||||||
|
|
||||||
// 將 findings.json 從 workspace 複製到 clone 的 repo
|
run(['add', FINDINGS_PATH], repoDir);
|
||||||
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
|
||||||
const destFindings = path.join(repoDir, FINDINGS_PATH);
|
|
||||||
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
|
||||||
fs.copyFileSync(srcFindings, destFindings);
|
|
||||||
|
|
||||||
run(['add', FINDINGS_PATH], repoDir);
|
const status = run(['status', '--porcelain'], repoDir);
|
||||||
|
if (!status) {
|
||||||
|
console.log(' findings.json 無變更,跳過 commit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const status = run(['status', '--porcelain'], repoDir);
|
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
|
||||||
if (!status) {
|
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||||||
console.log(' findings.json 無變更,跳過 commit');
|
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
return;
|
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
||||||
}
|
});
|
||||||
|
|
||||||
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
|
|
||||||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
|
||||||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
|
||||||
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(askpassScript); } catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -38,7 +38,6 @@ describe('commitAndPush', () => {
|
|||||||
before(() => { workspace = makeTmpWorkspace(); });
|
before(() => { workspace = makeTmpWorkspace(); });
|
||||||
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Remove leftover askpass scripts between tests
|
|
||||||
for (const f of fs.readdirSync(workspace)) {
|
for (const f of fs.readdirSync(workspace)) {
|
||||||
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
||||||
}
|
}
|
||||||
@@ -46,7 +45,7 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
it('does not embed token in any git command argument', async () => {
|
it('does not embed token in any git command argument', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
|
|
||||||
for (const { args } of spawn.calls) {
|
for (const { args } of spawn.calls) {
|
||||||
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||||
@@ -55,7 +54,7 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
|
|
||||||
const networkOps = ['fetch', 'push', 'clone'];
|
const networkOps = ['fetch', 'push', 'clone'];
|
||||||
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
||||||
@@ -67,28 +66,28 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script after successful run', async () => {
|
it('cleans up askpass script after successful run', async () => {
|
||||||
await commitAndPush(workspace, makeSpawn());
|
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script even when git fails', async () => {
|
it('cleans up askpass script even when git fails', async () => {
|
||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await commitAndPush(workspace, failSpawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips commit when status shows no changes', async () => {
|
it('skips commit when status shows no changes', async () => {
|
||||||
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
||||||
await commitAndPush(workspace, spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||||
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
||||||
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when git command fails', async () => {
|
it('does not throw when git command fails', async () => {
|
||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await assert.doesNotReject(() => commitAndPush(workspace, failSpawn));
|
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -6,9 +6,22 @@ const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized:
|
|||||||
const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
|
const headers = () => ({ Authorization: `token ${GITEA_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}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
|
||||||
|
*/
|
||||||
export async function getPRDiff() {
|
export async function getPRDiff() {
|
||||||
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
||||||
return resp.data;
|
return filterDiff(resp.data, ['.gitea/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。
|
||||||
|
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
|
||||||
|
*/
|
||||||
|
export function filterDiff(diff, excludePrefixes) {
|
||||||
|
return diff.split(/(?=^diff --git )/m)
|
||||||
|
.filter(block => !excludePrefixes.some(p => block.startsWith(`diff --git a/${p}`)))
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postComment(body) {
|
export async function postComment(body) {
|
||||||
|
|||||||
+30
-5
@@ -2,13 +2,10 @@ 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';
|
||||||
|
|
||||||
// gitea.js reads env vars at module load time (ESM cache), so we test
|
|
||||||
// the actual values baked in at import time and verify behavior via axios mocks.
|
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
describe('gitea', async () => {
|
describe('gitea', async () => {
|
||||||
const { getPRDiff, postComment } = await import('./gitea.js');
|
const { getPRDiff, filterDiff, postComment } = await import('./gitea.js');
|
||||||
|
|
||||||
it('getPRDiff calls Gitea diff API with Authorization header', async () => {
|
it('getPRDiff calls Gitea diff API with Authorization header', async () => {
|
||||||
let capturedUrl, capturedOpts;
|
let capturedUrl, capturedOpts;
|
||||||
@@ -48,7 +45,6 @@ describe('gitea', async () => {
|
|||||||
return { data: '' };
|
return { data: '' };
|
||||||
});
|
});
|
||||||
await getPRDiff();
|
await getPRDiff();
|
||||||
// httpsAgent is undefined when GITEA_SKIP_TLS_VERIFY !== 'true'
|
|
||||||
assert.equal(capturedOpts.httpsAgent, undefined);
|
assert.equal(capturedOpts.httpsAgent, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,3 +58,32 @@ describe('gitea', async () => {
|
|||||||
await assert.rejects(() => postComment('test'), /api error/);
|
await assert.rejects(() => postComment('test'), /api error/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('filterDiff', async () => {
|
||||||
|
const { filterDiff } = await import('./gitea.js');
|
||||||
|
|
||||||
|
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
|
||||||
|
|
||||||
|
it('filters out .gitea/ blocks', () => {
|
||||||
|
const diff = block('.gitea/workflows/review.yaml') + block('src/index.js');
|
||||||
|
const result = filterDiff(diff, ['.gitea/']);
|
||||||
|
assert.ok(!result.includes('.gitea/'));
|
||||||
|
assert.ok(result.includes('src/index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not filter non-.gitea/ blocks', () => {
|
||||||
|
const diff = block('src/index.js') + block('README.md');
|
||||||
|
const result = filterDiff(diff, ['.gitea/']);
|
||||||
|
assert.equal(result, diff);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when all blocks are excluded', () => {
|
||||||
|
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json');
|
||||||
|
const result = filterDiff(diff, ['.gitea/']);
|
||||||
|
assert.equal(result, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty diff', () => {
|
||||||
|
assert.equal(filterDiff('', ['.gitea/']), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+3
-4
@@ -29,12 +29,11 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function chatJSON(systemPrompt, userContent) {
|
export async function chatJSON(systemPrompt, userContent) {
|
||||||
|
const text = await chat(systemPrompt, userContent);
|
||||||
try {
|
try {
|
||||||
let text = await chat(systemPrompt, userContent);
|
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
|
||||||
text = text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim();
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` [LLM] 解析失敗: ${e.message}`);
|
console.log(` [LLM] JSON 解析失敗: ${e.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-16
@@ -1,4 +1,6 @@
|
|||||||
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig } from './config.js';
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
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 } 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';
|
||||||
@@ -13,7 +15,6 @@ async function main() {
|
|||||||
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
||||||
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
||||||
|
|
||||||
// 偵測 LLM
|
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
|
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
|
||||||
@@ -21,11 +22,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`);
|
console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`);
|
||||||
|
|
||||||
// 載入角色
|
|
||||||
const roles = loadRoles();
|
const roles = loadRoles();
|
||||||
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
|
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
|
||||||
|
|
||||||
// 取得 PR diff
|
|
||||||
let diff;
|
let diff;
|
||||||
try {
|
try {
|
||||||
diff = await getPRDiff();
|
diff = await getPRDiff();
|
||||||
@@ -40,7 +39,6 @@ async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 發布角色介紹 comment
|
|
||||||
try {
|
try {
|
||||||
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
||||||
await postComment(intro);
|
await postComment(intro);
|
||||||
@@ -48,7 +46,6 @@ async function main() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
console.log(' Step1 完成');
|
|
||||||
|
|
||||||
// Step2: 各角色分析 diff 產生新 findings
|
// Step2: 各角色分析 diff 產生新 findings
|
||||||
console.log('\n📊 Step2: Findings 產生');
|
console.log('\n📊 Step2: Findings 產生');
|
||||||
@@ -63,7 +60,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||||
|
|
||||||
// Step3: 讀取舊 findings,合併去重(含 AI 語意去重)
|
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
|
||||||
console.log('\n🔀 Step3: Findings 合併');
|
console.log('\n🔀 Step3: Findings 合併');
|
||||||
// Clone repo 以讀取舊 findings 與排除清單
|
// Clone repo 以讀取舊 findings 與排除清單
|
||||||
let repoDir;
|
let repoDir;
|
||||||
@@ -81,17 +78,17 @@ async function main() {
|
|||||||
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})`);
|
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})`);
|
||||||
|
|
||||||
// Step4: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
||||||
console.log('\n🚫 Step4: AI 排除問題過濾');
|
console.log('\n🚫 Step4: AI 排除問題過濾');
|
||||||
|
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
||||||
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
||||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||||
console.log(` Step4 完成: findings total=${filtered.length}`);
|
console.log(` Step4 完成: findings total=${filtered.length}`);
|
||||||
|
|
||||||
// Step5: 寫入 findings.json,依序發布 comment
|
// Step6: 寫入 findings.json,依序發布 comment
|
||||||
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
||||||
saveFindings(WORKSPACE, filtered);
|
saveFindings(WORKSPACE, filtered);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await postOldFindingsComment(filtered);
|
await postOldFindingsComment(filtered);
|
||||||
await postNewNonCriticalComment(filtered);
|
await postNewNonCriticalComment(filtered);
|
||||||
@@ -101,12 +98,37 @@ async function main() {
|
|||||||
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step6: commit/push findings.json 到來源分支
|
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
|
||||||
console.log('\n💾 Step6: 記憶區 Commit/Push');
|
console.log('\n🔎 Step6: JSON 格式驗證');
|
||||||
await commitAndPush(WORKSPACE);
|
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||||
|
const fullPath = path.join(repoDir || WORKSPACE, relPath);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
console.log(` ⚠️ ${relPath} 不存在,跳過驗證`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
console.log(` ✅ ${relPath} JSON 格式正確`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` ❌ ${relPath} JSON 格式錯誤: ${e.message},嘗試修正...`);
|
||||||
|
try {
|
||||||
|
const backupPath = fullPath + '.bak';
|
||||||
|
fs.copyFileSync(fullPath, backupPath);
|
||||||
|
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||||
|
console.log(` ✅ ${relPath} 已重置為空陣列(原檔備份至 ${relPath}.bak)`);
|
||||||
|
} catch (repairErr) {
|
||||||
|
console.error(` ❌ ${relPath} 修正失敗: ${repairErr.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step7: 有 critical 問題則 exit 1
|
// Step7: commit/push findings.json 到來源分支
|
||||||
console.log('\n🚦 Step7: 嚴重問題檢查');
|
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
||||||
|
await commitAndPush(WORKSPACE, repoDir);
|
||||||
|
|
||||||
|
// Step9: 有 critical 問題則 exit 1
|
||||||
|
console.log('\n🚦 Step8: 嚴重問題檢查');
|
||||||
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
||||||
if (criticalCount > 0) {
|
if (criticalCount > 0) {
|
||||||
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
||||||
@@ -114,7 +136,6 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(' ✅ 無嚴重問題');
|
console.log(' ✅ 無嚴重問題');
|
||||||
|
|
||||||
console.log('\n✅ Pipeline 完成');
|
console.log('\n✅ Pipeline 完成');
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user