Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd854649db | |||
| de8de251ba | |||
| fe7381c36e | |||
| abfd594bb2 | |||
| 8878165a81 | |||
| 818342d27b | |||
| d95213334b | |||
| ea64c5f063 | |||
| 931481179a |
@@ -1,51 +1,9 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "app/gitea.js",
|
|
||||||
"suggestion": "`app/gitea.js` 模組負責與 Gitea API 進行所有互動,包括獲取 PR Diff 和發布評論。這些網路操作是 Action 運作的基礎,但目前缺乏單元測試。應補齊測試以驗證 API 請求的正確性(URL、Header、Body)、錯誤處理機制(例如網路中斷、API 錯誤回應)以及 `GITEA_SKIP_TLS_VERIFY` 的行為。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "app/gitea.js:10",
|
|
||||||
"suggestion": "在 `app/gitea.js` 中,`https.Agent` 設定了 `rejectUnauthorized: false`,這會禁用 SSL/TLS 憑證驗證。這是一個嚴重的安全漏洞,會使應用程式容易受到中間人攻擊,並嚴重影響系統的安全性與可維護性。強烈建議移除此設定,或在必要時配置正確的憑證信任鏈。",
|
|
||||||
"is_new": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "app/roles.js:4",
|
|
||||||
"suggestion": "在 `ROLES_DIR` 常數中,使用了硬編碼的路徑 `/action/app/prompts/roles`。這使得在 Docker 容器外部進行本地開發或測試時,需要額外配置才能正確載入角色定義。建議將此路徑設定為可配置的環境變數,或使用相對路徑並在 `main.js` 中傳入基礎路徑,以提高模組的彈性和可測試性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Zara",
|
|
||||||
"location": "app/main.js:60",
|
|
||||||
"suggestion": "`analyzeWithRole` 函式在迴圈中為每個角色依序呼叫 LLM 進行分析。由於 LLM 呼叫是高延遲操作,這種序列化執行會顯著增加整個 Code Review Action 的總執行時間。考慮使用 `Promise.all` 等方式平行化這些 LLM 呼叫,以縮短總等待時間。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Zara",
|
|
||||||
"location": "Dockerfile",
|
|
||||||
"suggestion": "建議調整 Dockerfile 的層次,將 `COPY app/package.json app/package-lock.json /action/app/` 放在 `npm install` 之前,然後再 `COPY app/ /action/app/`。這樣可以利用 Docker 的層次快取,當 `package.json` 或 `package-lock.json` 未變更時,`npm install` 步驟就不會重複執行,顯著加快 Docker 映像檔的建置速度。",
|
|
||||||
"is_new": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Aria",
|
|
||||||
"location": "app/package.json:5",
|
|
||||||
"suggestion": "`package.json` 中的 `test` 腳本 (`node --test *.test.js`) 僅會執行 `app/` 目錄下的測試檔案。建議修改為 `node --test app/**/*.test.js` 或使用更完善的測試框架(如 `mocha` 或 `jest`),以確保所有測試檔案都能被自動發現並執行。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"role": "Zara",
|
"role": "Rex",
|
||||||
"location": "app/main.js:72, app/main.js:78",
|
"location": "action.yaml",
|
||||||
"suggestion": "`deduplicateWithAI` 和 `filterFalsePositivesWithAI` 函式也涉及 LLM 呼叫,它們在主流程中依序執行。雖然這些是單次呼叫,但其延遲仍會累積。在設計上,可以考慮是否有可能將這些步驟與其他非依賴的任務平行化,或或評估其對整體效能的影響。",
|
"suggestion": "此 Action 需要 `contents: write`、`pull-requests: write` 和 `issues: write` 權限。這些權限對於 Action 的正常運作是必要的(例如寫入 findings.json、發布評論),但屬於較廣泛的權限。建議在文件或使用說明中明確指出這些權限的需求及其潛在影響,確保使用者了解並接受。",
|
||||||
"is_new": true
|
"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,7 @@
|
|||||||
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 設定等非業務程式碼
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
@@ -32,16 +33,21 @@
|
|||||||
### 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 +61,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 +89,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 +116,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 +144,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 +172,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
|
||||||
```
|
```
|
||||||
@@ -40,6 +40,11 @@
|
|||||||
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
||||||
- 完成
|
- 完成
|
||||||
|
|
||||||
|
## 階段九:Git Diff 排除 .gitea/ 資料夾
|
||||||
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
|
||||||
|
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
|
||||||
|
- 完成
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
所有階段驗收通過。
|
所有階段驗收通過。
|
||||||
|
|||||||
+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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-30
@@ -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} 個問題`);
|
||||||
@@ -40,7 +39,6 @@ export function loadOldFindings(workspace) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 合併新舊 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,8 +61,17 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
||||||
*/
|
*/
|
||||||
export async function deduplicateWithAI(findings) {
|
export async function deduplicateWithAI(findings) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
@@ -74,29 +81,20 @@ export async function deduplicateWithAI(findings) {
|
|||||||
保留等級較高的版本,優先保留 critical > warning > info。
|
保留等級較高的版本,優先保留 critical > warning > info。
|
||||||
只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
||||||
|
|
||||||
const userContent = `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, userContent);
|
const result = await chatJSON(systemPrompt, `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`);
|
||||||
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;
|
return result;
|
||||||
}
|
}
|
||||||
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 fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
@@ -125,17 +123,14 @@ 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;
|
||||||
@@ -152,22 +147,14 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
|||||||
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
|
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
|
||||||
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
||||||
|
|
||||||
const userContent = `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, userContent);
|
const result = await chatJSON(systemPrompt, `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`);
|
||||||
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;
|
return result;
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-29
@@ -14,20 +14,26 @@ 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 = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
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,37 +42,21 @@ 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 {
|
|
||||||
try { fs.unlinkSync(askpassScript); } catch {}
|
|
||||||
}
|
|
||||||
return repoDir;
|
return repoDir;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
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)) {
|
const repoDir = cloneRepo(workspace, _spawnSync);
|
||||||
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await withAskpass(workspace, async credEnv => {
|
||||||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
|
||||||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
|
||||||
|
|
||||||
// 將 findings.json 從 workspace 複製到 clone 的 repo
|
|
||||||
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
||||||
const destFindings = path.join(repoDir, FINDINGS_PATH);
|
const destFindings = path.join(repoDir, FINDINGS_PATH);
|
||||||
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
||||||
@@ -84,9 +74,8 @@ export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
|||||||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||||||
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}`);
|
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 {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -8,7 +8,13 @@ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
|||||||
|
|
||||||
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/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
+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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,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 +20,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 +37,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 +44,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 產生');
|
||||||
@@ -65,7 +60,6 @@ async function main() {
|
|||||||
|
|
||||||
// Step3: 讀取舊 findings,合併去重(含 AI 語意去重)
|
// Step3: 讀取舊 findings,合併去重(含 AI 語意去重)
|
||||||
console.log('\n🔀 Step3: Findings 合併');
|
console.log('\n🔀 Step3: Findings 合併');
|
||||||
// Clone repo 以讀取舊 findings 與排除清單
|
|
||||||
let repoDir;
|
let repoDir;
|
||||||
try {
|
try {
|
||||||
repoDir = cloneRepo(WORKSPACE);
|
repoDir = cloneRepo(WORKSPACE);
|
||||||
@@ -91,7 +85,6 @@ async function main() {
|
|||||||
// Step5: 寫入 findings.json,依序發布 comment
|
// Step5: 寫入 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);
|
||||||
@@ -114,7 +107,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