diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index bfad733..365ace9 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -319,5 +319,15 @@ { "location": "app/json.test.js:10", "suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。" + }, + { + "role": "Maya", + "location": "action.yaml:6, action.yaml:12, action.yaml:81", + "suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。" + }, + { + "role": "Leo", + "location": "action.yaml:80", + "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..9963b48 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,9 @@ -[] +[ + { + "level": "info", + "role": "Rex", + "location": "action.yaml:18", + "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", + "is_new": false + } +] diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index d7fcbcc..35988a4 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -1,7 +1,4 @@ name: AI -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true on: pull_request: branches-ignore: @@ -33,10 +30,12 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} + GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }} GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} permissions: contents: write pull-requests: write - issues: write \ No newline at end of file + issues: write diff --git a/README.md b/README.md index c29b486..cba1d69 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。 -# 流程(新 Push Request、新 Commit (排除 AI 助理的 Commit) 觸發) +# 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過) 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) @@ -11,8 +11,8 @@ 5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request 6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request 7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request -8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容 -9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) +8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑 +9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) # 設計 @@ -33,7 +33,9 @@ 2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): -> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。 +> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot][success]` 或 `[ai-review-bot][failure]`,而且 action 執行時會先透過 Gitea API 檢查這次觸發的 PR head commit(優先用 `pull_request.head.sha`)是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。 + +> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。若你想讓 comment 用不同權限的 token,可額外傳 `GITEA_COMMENT_TOKEN`,其餘 Gitea 操作仍使用 `GITEA_TOKEN`。 ### 1. OpenAI ```yaml @@ -54,6 +56,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key OPENAI_BASE_URL: https://api.openai.com/v1 OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} @@ -82,6 +85,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }} OPENAI_BASE_URL: https://openrouter.ai/api/v1 OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} @@ -110,6 +114,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key CLAUDE_BASE_URL: https://api.anthropic.com/v1 permissions: @@ -137,6 +142,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }} GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} @@ -165,6 +171,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key AMAZONQ_BASE_URL: https://q.api.aws permissions: @@ -191,10 +198,11 @@ jobs: runs-on: ubuntu steps: - name: AI Code Review - uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} - with: - OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 - OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} + uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} + with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} + OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 + OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} permissions: contents: write pull-requests: write diff --git a/action.yaml b/action.yaml index 7dce0bc..485776b 100644 --- a/action.yaml +++ b/action.yaml @@ -5,6 +5,9 @@ inputs: # Gitea 相關(可從 gitea context 自動取得) GITEA_TOKEN: description: 'Gitea API Token' + required: true + GITEA_COMMENT_TOKEN: + description: 'Gitea API Token for posting comments only' required: false GITEA_SERVER_URL: description: 'Gitea Server URL' @@ -80,12 +83,14 @@ runs: using: 'docker' image: 'Dockerfile' env: - # Gitea context(優先用 inputs,否則從 gitea context 取) - GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }} + # Gitea context(改為只從 inputs 取得) + GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }} + GITEA_COMMENT_TOKEN: ${{ inputs.GITEA_COMMENT_TOKEN }} GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }} GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }} GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }} PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }} + PR_HEAD_SHA: ${{ inputs.PR_HEAD_SHA || gitea.event.pull_request.head.sha }} PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }} PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }} # LLM diff --git a/app/config.js b/app/config.js index 83921d5..ee25071 100644 --- a/app/config.js +++ b/app/config.js @@ -1,8 +1,10 @@ export const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; +export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || ''; export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com'; export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || ''; export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true'; export const PR_NUMBER = process.env.PR_NUMBER || ''; +export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || ''; export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || ''; export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; diff --git a/app/git.js b/app/git.js index e2da47b..fca4321 100644 --- a/app/git.js +++ b/app/git.js @@ -7,6 +7,7 @@ import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDIN const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; +export const BOT_COMMIT_MARKER = '[ai-review-bot]'; export const SYNC_PATHS = [ '.amazonq/rules/triage-findings.md', '.codex/skills/triage-findings/SKILL.md', @@ -58,6 +59,15 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) { return { repoDir, branch, headSha, shortSha, commitTime }; } +export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) { + const run = makeRunner(_spawnSync); + return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir); +} + +export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { + return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER); +} + /** * Clone PR head branch to workspace/repo (idempotent) */ @@ -78,7 +88,7 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { }); } -export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT) { +export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT, reviewOutcome = 'success') { const run = makeRunner(_spawnSync); try { @@ -124,13 +134,14 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, return; } - const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); + const outcomeTag = reviewOutcome === 'failure' ? '[failure]' : '[success]'; + const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}${outcomeTag}`], repoDir); const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; try { 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} review_outcome=${reviewOutcome}`); } catch (pushErr) { - console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`); + console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); } }); } catch (e) { diff --git a/app/git.test.js b/app/git.test.js index 0786aea..24abf62 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js'; +import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js'; // --- helpers --- function makeTmpWorkspace() { @@ -60,6 +60,26 @@ describe('commitAndPush', () => { } }); + it('tags auto commits with the bot marker for workflow filtering', async () => { + const spawn = makeSpawn(); + await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); + + const commitCall = spawn.calls.find(c => c.args[0] === 'commit'); + assert.ok(commitCall, 'expected git commit to run'); + assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker'); + assert.ok(commitCall.args.some(arg => arg.includes('[success]')), 'expected commit message to include success outcome'); + }); + + it('tags failed reviews with the failure outcome marker', async () => { + const spawn = makeSpawn(); + await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot, 'failure'); + + const commitCall = spawn.calls.find(c => c.args[0] === 'commit'); + assert.ok(commitCall, 'expected git commit to run'); + assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker'); + assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome'); + }); + it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => { const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); @@ -232,4 +252,13 @@ describe('cloneRepo', () => { const result = cloneRepo(workspace, spawn); assert.equal(result, path.join(workspace, 'repo')); }); + + it('reads head commit message and detects bot auto commits', () => { + const spawn = makeSpawn({ + show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }), + }); + + assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER)); + assert.equal(isBotAutoCommit(workspace, spawn), true); + }); }); diff --git a/app/gitea.js b/app/gitea.js index 20113d2..768e84d 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,11 +1,23 @@ import axios from 'axios'; import https from 'https'; -import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER } from './config.js'; +import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js'; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; -const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' }); +const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; +function extractCommitMessage(payload) { + return payload?.message + || payload?.commit?.message + || payload?.commit?.commit?.message + || ''; +} + +export function getBotReviewOutcome(message) { + const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i); + return match?.[1]?.toLowerCase() || 'unknown'; +} + /** * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。 */ @@ -25,6 +37,69 @@ export async function getPRDiff() { ]); } +export async function getCommitMessageBySha(sha) { + if (!sha) return ''; + try { + const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/git/commits/${encodeURIComponent(sha)}`), { + headers: headers(), + timeout: 30000, + httpsAgent, + }); + const message = extractCommitMessage(resp.data); + console.log(` 🔎 bot-check: commit api sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`); + return message; + } catch (e) { + console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`); + return ''; + } +} + +export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { + if (!branch) return ''; + try { + const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/branches/${encodeURIComponent(branch)}`), { + headers: headers(), + timeout: 30000, + httpsAgent, + }); + const sha = resp.data?.commit?.id || resp.data?.commit?.sha || ''; + console.log(` 🔎 bot-check: branch api branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`); + return await getCommitMessageBySha(sha); + } catch (e) { + console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`); + return ''; + } +} + +export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { + console.log(` 🔎 bot-check: start PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`); + + const shaMessage = await getCommitMessageBySha(sha); + if (sha) { + console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`); + if (shaMessage.includes('[ai-review-bot]')) { + console.log(' ✅ bot-check: matched commit sha marker'); + return true; + } + } else { + console.log(' 🔎 bot-check: skip sha lookup because sha is empty'); + } + + const branchMessage = await getBranchHeadCommitMessage(branch); + if (branch) { + console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`); + if (branchMessage.includes('[ai-review-bot]')) { + console.log(' ✅ bot-check: matched branch head marker'); + return true; + } + } else { + console.log(' 🔎 bot-check: skip branch lookup because branch is empty'); + } + + console.log(' ℹ️ bot-check: no [ai-review-bot] marker found'); + return false; +} + /** * 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。 * 每個區塊以 "diff --git a/" 開頭判斷,使用 startsWith 精確比對前綴。 @@ -40,6 +115,10 @@ export function filterDiff(diff, excludePrefixes) { } export async function postComment(body) { - const resp = await axios.post(api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), { body }, { headers: headers(), timeout: 30000, httpsAgent }); + const resp = await axios.post( + api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), + { body }, + { headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent }, + ); return resp.data; } diff --git a/app/gitea.test.js b/app/gitea.test.js index 4118aca..09b202c 100644 --- a/app/gitea.test.js +++ b/app/gitea.test.js @@ -1,7 +1,7 @@ import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; -import { getPRDiff, filterDiff, postComment } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -56,6 +56,48 @@ describe('gitea', () => { mock.method(axios, 'post', async () => { throw new Error('api error'); }); await assert.rejects(() => postComment('test'), /api error/); }); + + it('getCommitMessageBySha reads commit message from Gitea API', async () => { + let capturedUrl; + mock.method(axios, 'get', async (url) => { + capturedUrl = url; + return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } }; + }); + const message = await getCommitMessageBySha('abc123'); + assert.ok(capturedUrl.includes('/git/commits/abc123')); + assert.ok(message.includes('[ai-review-bot]')); + }); + + it('getBranchHeadCommitMessage reads branch head commit message from Gitea API', async () => { + const urls = []; + mock.method(axios, 'get', async (url) => { + urls.push(url); + if (url.includes('/branches/feat%2Ftest')) { + return { data: { commit: { id: 'abc123' } } }; + } + return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } }; + }); + const message = await getBranchHeadCommitMessage('feat/test'); + assert.ok(urls.some(url => url.includes('/branches/feat%2Ftest'))); + assert.ok(urls.some(url => url.includes('/git/commits/abc123'))); + assert.ok(message.includes('[ai-review-bot]')); + }); + + it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => { + mock.method(axios, 'get', async (url) => { + if (url.includes('/git/commits/sha-bot')) { + return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } }; + } + if (url.includes('/branches/feat%2Ftest')) { + return { data: { commit: { id: 'sha-bot' } } }; + } + return { data: { message: 'regular commit' } }; + }); + await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true); + assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'), 'failure'); + assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][success]'), 'success'); + assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown'); + }); }); describe('filterDiff', () => { diff --git a/app/main.js b/app/main.js index 7d7a568..ea84459 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ 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 { getPRDiff, postComment } from './gitea.js'; +import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js'; @@ -15,6 +15,22 @@ async function main() { console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); + const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; + const headMessage = await getCommitMessageBySha(headSha); + const headOutcome = getBotReviewOutcome(headMessage); + console.log(` 🔎 head check: sha=${headSha || 'empty'} outcome=${headOutcome}`); + if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') { + console.log(' ❌ 偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); + console.log('='.repeat(60)); + process.exit(1); + } + + if (await shouldSkipBotCommit()) { + console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); + console.log('='.repeat(60)); + process.exit(0); + } + const { provider, baseURL, model } = getLLMConfig(); if (!provider) { console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); @@ -122,7 +138,9 @@ async function main() { // Step7: commit/push findings.json 到來源分支 console.log('\n💾 Step7: 記憶區 Commit/Push'); - await commitAndPush(WORKSPACE, repoDir || WORKSPACE); + const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; + console.log(` 🔎 review outcome=${reviewOutcome}`); + await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); // Step9: 有 critical 問題則 exit 1 console.log('\n🚦 Step8: 嚴重問題檢查');