Compare commits

..

7 Commits

9 changed files with 113 additions and 105 deletions
+2 -2
View File
@@ -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
+43 -13
View File
@@ -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
``` ```
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
} }
+35 -46
View File
@@ -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,57 +42,40 @@ 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, _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);
}
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); await withAskpass(workspace, async credEnv => {
run(['config', 'user.name', 'AI Review Bot'], repoDir); run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); run(['config', 'user.name', 'AI Review Bot'], repoDir);
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 }); fs.copyFileSync(srcFindings, destFindings);
fs.copyFileSync(srcFindings, destFindings);
run(['add', FINDINGS_PATH], repoDir); run(['add', FINDINGS_PATH], repoDir);
const status = run(['status', '--porcelain'], repoDir); const status = run(['status', '--porcelain'], repoDir);
if (!status) { if (!status) {
console.log(' findings.json 無變更,跳過 commit'); console.log(' findings.json 無變更,跳過 commit');
return; return;
} }
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
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
View File
@@ -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
View File
@@ -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 [];
} }
} }
-8
View File
@@ -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));
} }