diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 2ca1f18..67fdaaa 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -6,6 +6,23 @@ }, { "location": "app/git.js", - "suggestion": "GITEA_TOKEN 直接嵌入 URL 中" + "suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入" + }, + { + "role": "Rex", + "location": "README.md", + "suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減" + }, + { + "location": "app/config.js", + "suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報" + }, + { + "location": ".gitea/ai-review/exclusions.json", + "suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位" + }, + { + "location": "app/findings.js", + "suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 0637a08..749743a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,128 @@ -[] \ No newline at end of file +[ + { + "level": "warning", + "role": "Rex", + "location": ".gitea/workflows/review.yaml:33", + "suggestion": "工作流程目前授予 contents、pull‑requests、issues 三項 write 權限,過於寬鬆。建議依實際需求僅授予 read 或最小必要的 write 權限,以降低被濫用的風險。", + "is_new": false + }, + { + "level": "warning", + "role": "Rex", + "location": ".gitea/workflows/review.yaml:35", + "suggestion": "將 OPENAI_API_KEY 參數改為使用正確的 secret 名稱(如 OPENROUTER_API_KEY)時,請確保工作流程文件中不會同時暴露兩個不同的 secret 名稱,以免因名稱錯誤導致金鑰未傳入或意外洩漏。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": ".gitea/workflows/review.yaml:33", + "suggestion": "在 `OPENAI_API_KEY` 後的註解前應保留一個空格,以符合常見的 YAML 註解風格:`... ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入`。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": "README.md", + "suggestion": "文件中章節編號不連續(例如 `### 2. OpenRouter` 後直接跳到 `### 3. Anthropic Claude`),建議重新編號或使用一致的標題層級,以提升可讀性與維護性。", + "is_new": false + }, + { + "level": "warning", + "role": "Rex", + "location": ".gitea/workflows/review.yaml:33-35", + "suggestion": "工作流程目前授予 contents、pull‑requests、issues 三項 write 權限過於寬鬆。建議依實際需求僅授予 read 或最小必要的 write 權限,降低被濫用的風險。", + "is_new": false + }, + { + "level": "warning", + "role": "Rex", + "location": ".gitea/workflows/review.yaml:33", + "suggestion": "將 OPENAI_API_KEY 參數改為使用正確的 secret 名稱(如 OPENROUTER_API_KEY)時,請確保工作流程檔案中不會同時暴露兩個不同的 secret 名稱,以免因名稱錯誤導致金鑰未傳入或意外洩漏。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": ".gitea/ai-review/findings.json", + "suggestion": "檔案最後缺少換行符號,請在檔案結尾加入一個空白換行。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": "app/config.test.js", + "suggestion": "檔案最後缺少換行符號,請在檔案結尾加入空白換行。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": "README.md:28", + "suggestion": "文件中章節編號不連續(例如 `### 2. OpenRouter` 後直接跳到 `### 3. Anthropic Claude`),請重新編號或使用自動編號方式,保持編號連續。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": "README.md:42", + "suggestion": "標題層級使用不一致,部分章節使用 `### 1.`、`### 2.`,而後面的章節直接跳到 `### 3.`,建議統一使用相同層級的 Markdown 標題,並在每個標題後留一個空行以提升可讀性。", + "is_new": false + }, + { + "level": "warning", + "role": "Aria", + "location": "action.yaml:72-99", + "suggestion": "大量移除的輸入欄位留下多行空白與註解,請整理檔案結構,移除不必要的空行與註解,保持檔案整潔。", + "is_new": false + }, + { + "level": "info", + "role": "Aria", + "location": "app/config.test.js", + "suggestion": "匯入語句過長,建議改寫為多行匯入,例如:\n```js\nimport {\n describe,\n it,\n beforeEach,\n afterEach\n} from 'node:test';\n```", + "is_new": false + }, + { + "level": "info", + "role": "Aria", + "location": "app/config.test.js", + "suggestion": "`ENV_KEYS` 陣列過長,建議分行列舉,每行放置一個環境變數,以提升可讀性。", + "is_new": false + }, + { + "level": "info", + "role": "Leo", + "location": "app/config.test.js:1", + "suggestion": "匯入語句過長,建議改寫為多行匯入,以提升可讀性,例如:\n```js\nimport {\n describe,\n it,\n beforeEach,\n afterEach\n} from 'node:test';\n```", + "is_new": false + }, + { + "level": "info", + "role": "Leo", + "location": "app/config.test.js:9", + "suggestion": "`ENV_KEYS` 陣列過長,建議分行列舉,每行放置一個環境變數,以提升可讀性與維護性。", + "is_new": false + }, + { + "level": "info", + "role": "Aria", + "location": "app/config.test.js:1", + "suggestion": "匯入語句過長,建議改寫為多行匯入,例如:\n```js\nimport {\n describe,\n it,\n beforeEach,\n afterEach\n} from 'node:test';\n```", + "is_new": false + }, + { + "level": "info", + "role": "Aria", + "location": "app/config.test.js:7", + "suggestion": "`ENV_KEYS` 陣列過長,建議每行放置一個環境變數,分行列舉以提升可讀性。", + "is_new": false + }, + { + "level": "info", + "role": "Aria", + "location": ".gitea/workflows/review.yaml:33", + "suggestion": "在 `OPENAI_API_KEY` 後的註解前保留一個空格,以符合常見的 YAML 註解風格:`... ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入`。", + "is_new": false + } +] \ No newline at end of file diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index dc785d9..932f11f 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -33,8 +33,9 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@v${{ needs.version.outputs.version }} with: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入 OPENAI_BASE_URL: https://openrouter.ai/api/v1 + OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} permissions: contents: write pull-requests: write diff --git a/README.md b/README.md index 0dacb5e..dd5d383 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ 2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): -### 1. OpenAI(OpenRouter) +### 1. OpenAI ```yaml name: AI on: @@ -42,17 +42,39 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - # Github (h3285@evertrust.com.tw) - # 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 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_BASE_URL: https://openrouter.ai/api/v1 + OPENAI_BASE_URL: https://api.openai.com/v1 + OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} permissions: contents: write pull-requests: write issues: write ``` -### 2. Anthropic Claude +### 2. OpenRouter +```yaml +name: AI +on: + pull_request: + types: [opened, synchronize] +jobs: + code-review: + name: 'Code Review' + runs-on: ubuntu + steps: + - name: AI Code Review + uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} + with: + OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入 + OPENAI_BASE_URL: https://openrouter.ai/api/v1 + OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} + permissions: + contents: write + pull-requests: write + issues: write +``` + +### 3. Anthropic Claude ```yaml name: AI on: @@ -74,7 +96,7 @@ jobs: issues: write ``` -### 3. Google Gemini +### 4. Google Gemini ```yaml name: AI on: @@ -90,13 +112,14 @@ jobs: with: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta + GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} permissions: contents: write pull-requests: write issues: write ``` -### 4. Amazon Q +### 5. Amazon Q ```yaml name: AI on: @@ -118,28 +141,6 @@ jobs: issues: write ``` -### 5. SonarQube -```yaml -name: AI -on: - pull_request: - types: [opened, synchronize] -jobs: - code-review: - name: 'Code Review' - runs-on: ubuntu - steps: - - name: AI Code Review - uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} - with: - SONARQUBE_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} - SONARQUBE_URL: https://sonarqube.example.com - permissions: - contents: write - pull-requests: write - issues: write -``` - ### - Ollama ```yaml @@ -155,7 +156,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - OLLAMA_BASE_URL: ${{ vars.OLLAMA_BASE_URL }} + OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} permissions: contents: write diff --git a/action.yaml b/action.yaml index ce0e7d2..52ed03a 100644 --- a/action.yaml +++ b/action.yaml @@ -72,53 +72,7 @@ inputs: description: 'Amazon Q Base URL' required: false - # SonarQube - SONARQUBE_TOKEN: - description: 'SonarQube Token' - required: false - SONARQUBE_URL: - description: 'SonarQube URL' - required: false - # Kilo Code - KILO_API_KEY: - description: 'Kilo Code API Key' - required: false - KILO_BASE_URL: - description: 'Kilo Code Base URL' - required: false - - # Roo Code - ROO_API_KEY: - description: 'Roo Code API Key' - required: false - ROO_BASE_URL: - description: 'Roo Code Base URL' - required: false - - # Cline - CLINE_API_KEY: - description: 'Cline API Key' - required: false - CLINE_BASE_URL: - description: 'Cline Base URL' - required: false - - # Continue - CONTINUE_API_KEY: - description: 'Continue API Key' - required: false - CONTINUE_BASE_URL: - description: 'Continue Base URL' - required: false - - # Kade - KADE_API_KEY: - description: 'Kade API Key' - required: false - KADE_BASE_URL: - description: 'Kade Base URL' - required: false runs: using: 'docker' @@ -145,15 +99,3 @@ runs: OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }} AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }} AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }} - SONARQUBE_TOKEN: ${{ inputs.SONARQUBE_TOKEN }} - SONARQUBE_URL: ${{ inputs.SONARQUBE_URL }} - KILO_API_KEY: ${{ inputs.KILO_API_KEY }} - KILO_BASE_URL: ${{ inputs.KILO_BASE_URL }} - ROO_API_KEY: ${{ inputs.ROO_API_KEY }} - ROO_BASE_URL: ${{ inputs.ROO_BASE_URL }} - CLINE_API_KEY: ${{ inputs.CLINE_API_KEY }} - CLINE_BASE_URL: ${{ inputs.CLINE_BASE_URL }} - CONTINUE_API_KEY: ${{ inputs.CONTINUE_API_KEY }} - CONTINUE_BASE_URL: ${{ inputs.CONTINUE_BASE_URL }} - KADE_API_KEY: ${{ inputs.KADE_API_KEY }} - KADE_BASE_URL: ${{ inputs.KADE_BASE_URL }} diff --git a/app/config.js b/app/config.js index c5e7caa..3dc350e 100644 --- a/app/config.js +++ b/app/config.js @@ -12,14 +12,9 @@ export function getLLMConfig() { const checks = [ ['openai', process.env.OPENAI_API_KEY, process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', process.env.OPENAI_MODEL || 'gpt-4o-mini'], ['claude', process.env.CLAUDE_API_KEY, process.env.CLAUDE_BASE_URL || 'https://api.anthropic.com/v1', process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307'], - ['gemini', process.env.GEMINI_API_KEY, process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-1.5-flash'], + ['gemini', process.env.GEMINI_API_KEY, process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'], ['ollama', 'ollama', process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL], - ['amazonq', process.env.AMAZONQ_API_KEY, process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.OPENAI_MODEL || 'amazon-q'], - ['kilo', process.env.KILO_API_KEY, process.env.KILO_BASE_URL || 'https://api.kilocode.com/v1', process.env.OPENAI_MODEL || 'kilo-default'], - ['roo', process.env.ROO_API_KEY, process.env.ROO_BASE_URL || 'https://api.roocode.com/v1', process.env.OPENAI_MODEL || 'roo-default'], - ['cline', process.env.CLINE_API_KEY, process.env.CLINE_BASE_URL || 'https://api.cline.dev/v1', process.env.OPENAI_MODEL || 'cline-default'], - ['continue', process.env.CONTINUE_API_KEY, process.env.CONTINUE_BASE_URL || 'https://api.continue.dev/v1', process.env.OPENAI_MODEL || 'continue-default'], - ['kade', process.env.KADE_API_KEY, process.env.KADE_BASE_URL || 'https://api.kade.dev/v1', process.env.OPENAI_MODEL || 'kade-default'], + ['amazonq', process.env.AMAZONQ_API_KEY, process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'], ]; for (const [provider, key, baseURL, model] of checks) { if (key && baseURL) return { provider, apiKey: key, baseURL, model }; diff --git a/app/config.test.js b/app/config.test.js new file mode 100644 index 0000000..b757cec --- /dev/null +++ b/app/config.test.js @@ -0,0 +1,101 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { getLLMConfig } from './config.js'; + +const ENV_KEYS = [ + 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', + 'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL', + 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', + 'OLLAMA_BASE_URL', 'OLLAMA_MODEL', + 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', +]; + +let saved = {}; +beforeEach(() => { + saved = {}; + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe('getLLMConfig', () => { + it('returns null provider when no env vars set', () => { + const cfg = getLLMConfig(); + assert.equal(cfg.provider, null); + assert.equal(cfg.apiKey, null); + }); + + it('detects openai with defaults', () => { + process.env.OPENAI_API_KEY = 'sk-test'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'openai'); + assert.equal(cfg.apiKey, 'sk-test'); + assert.equal(cfg.baseURL, 'https://api.openai.com/v1'); + assert.equal(cfg.model, 'gpt-4o-mini'); + }); + + it('detects openai with custom base url and model', () => { + process.env.OPENAI_API_KEY = 'sk-test'; + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'; + process.env.OPENAI_MODEL = 'gpt-4o'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'openai'); + assert.equal(cfg.baseURL, 'https://openrouter.ai/api/v1'); + assert.equal(cfg.model, 'gpt-4o'); + }); + + it('detects gemini with defaults', () => { + process.env.GEMINI_API_KEY = 'gemini-key'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'gemini'); + assert.equal(cfg.model, 'gemini-2.5-flash'); + }); + + it('detects gemini with custom model', () => { + process.env.GEMINI_API_KEY = 'gemini-key'; + process.env.GEMINI_MODEL = 'gemini-2.0-flash'; + const cfg = getLLMConfig(); + assert.equal(cfg.model, 'gemini-2.0-flash'); + }); + + it('detects claude with defaults', () => { + process.env.CLAUDE_API_KEY = 'claude-key'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'claude'); + assert.equal(cfg.model, 'claude-3-haiku-20240307'); + }); + + it('detects amazonq with its own model env', () => { + process.env.AMAZONQ_API_KEY = 'aq-key'; + process.env.AMAZONQ_MODEL = 'my-amazon-model'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'amazonq'); + assert.equal(cfg.model, 'my-amazon-model'); + }); + + it('openai takes priority over gemini when both set', () => { + process.env.OPENAI_API_KEY = 'sk-test'; + process.env.GEMINI_API_KEY = 'gemini-key'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'openai'); + }); + + it('empty string api key is treated as not set', () => { + process.env.OPENAI_API_KEY = ''; + process.env.GEMINI_API_KEY = 'gemini-key'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'gemini'); + }); + + it('detects ollama without api key', () => { + process.env.OLLAMA_BASE_URL = 'http://localhost:11434'; + process.env.OLLAMA_MODEL = 'llama3'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'ollama'); + assert.equal(cfg.model, 'llama3'); + }); +}); diff --git a/app/findings.js b/app/findings.js index 7dcf060..f381cfa 100644 --- a/app/findings.js +++ b/app/findings.js @@ -117,43 +117,50 @@ export function loadExclusions(workspace) { /** * 套用排除規則,過濾掉符合排除條件的 findings - * 排除條件:role/location/suggestion 皆符合(省略的欄位視為萬用) + * location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用 */ export function applyExclusions(findings, exclusions) { if (exclusions.length === 0) return findings; const before = findings.length; - const filtered = findings.filter(f => !exclusions.some(ex => - (!ex.role || ex.role === f.role) && - (!ex.location || String(f.location).includes(ex.location)) && - (!ex.suggestion || String(f.suggestion).includes(String(ex.suggestion).slice(0, 20))) - )); + const filtered = findings.filter(f => !exclusions.some(ex => { + const fPath = String(f.location).split(':')[0]; + const exPath = ex.location ? String(ex.location).split(':')[0] : null; + return (!exPath || fPath === exPath) && + (!ex.role || ex.role === f.role); + })); console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); return filtered; } /** * 呼叫 AI 判斷哪些問題是誤報或不需處理,回傳需保留的 findings + * exclusions 為已知誤報清單,供 AI 參考判斷 * 失敗時降級回傳原始 findings */ -export async function filterFalsePositivesWithAI(findings) { +export async function filterFalsePositivesWithAI(findings, exclusions = []) { if (findings.length === 0) return findings; + const exclusionHint = exclusions.length > 0 + ? `\n\n以下是已知的誤報或不需處理的問題清單(供參考,相同檔案路徑且語意相近的問題應一併排除):\n${JSON.stringify(exclusions, null, 2)}` + : ''; + const systemPrompt = `你是一位資深程式碼審查專家,負責判斷審查問題是否為誤報或不需處理。 給你一份問題清單(JSON 陣列),每筆包含 level、role、location、suggestion。 請移除以下類型的問題: 1. 誤報:問題描述與實際程式碼不符(例如:程式碼已正確使用環境變數或 secrets,卻被標記為硬編碼敏感資料) 2. 不適用:問題在此專案情境下不需處理(例如:CI/CD action 本來就需要透過環境變數傳遞 token) -只回傳需要保留的問題 JSON 陣列,不要有其他文字。`; +3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似) +只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`; const userContent = `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`; try { const result = await chatJSON(systemPrompt, userContent); - if (Array.isArray(result)) { + if (Array.isArray(result) && result.length > 0) { console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); return result; } - throw new Error('AI 回傳非陣列'); + throw new Error('AI 回傳空陣列或非陣列'); } catch (e) { const status = e.response?.status; if (status === 402 || status === 429) { diff --git a/app/main.js b/app/main.js index 7d0eef9..6e58f60 100644 --- a/app/main.js +++ b/app/main.js @@ -85,7 +85,7 @@ async function main() { console.log('\n🚫 Step4: AI 排除問題過濾'); const exclusions = loadExclusions(repoDir || WORKSPACE); const ruleFiltered = applyExclusions(sorted, exclusions); - const filtered = await filterFalsePositivesWithAI(ruleFiltered); + const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); console.log(` Step4 完成: findings total=${filtered.length}`); // Step5: 寫入 findings.json,依序發布 comment