From 82ecbd34631ecc780e89120313b52f9632589e2c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:17:55 +0000 Subject: [PATCH] fix: detect ai review bot commits via api --- README.md | 2 +- action.yaml | 1 + app/config.js | 1 + app/gitea.js | 39 ++++++++++++++++++++++++++++++++++++++- app/gitea.test.js | 41 ++++++++++++++++++++++++++++++++++++++++- app/main.js | 6 +++--- 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1de299c..5d3f1e5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): -> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`,而且 action 執行時也會先檢查 head commit 是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。 +> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`,而且 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)三項權限,為正常運作所必要,無法縮減。 diff --git a/action.yaml b/action.yaml index 1a94944..5debe0d 100644 --- a/action.yaml +++ b/action.yaml @@ -86,6 +86,7 @@ runs: 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..4d925c3 100644 --- a/app/config.js +++ b/app/config.js @@ -3,6 +3,7 @@ export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.c 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/gitea.js b/app/gitea.js index 20113d2..6e24475 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,6 +1,6 @@ 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_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' }); @@ -25,6 +25,43 @@ 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, + }); + return resp.data?.message || ''; + } catch { + 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 || ''; + return await getCommitMessageBySha(sha); + } catch { + return ''; + } +} + +export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { + const candidates = [ + await getCommitMessageBySha(sha), + await getBranchHeadCommitMessage(branch), + ].filter(Boolean); + return candidates.some(message => message.includes('[ai-review-bot]')); +} + /** * 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。 * 每個區塊以 "diff --git a/" 開頭判斷,使用 startsWith 精確比對前綴。 diff --git a/app/gitea.test.js b/app/gitea.test.js index 4118aca..b80e2e9 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 } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -56,6 +56,45 @@ 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]' } }; + } + 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); + }); }); describe('filterDiff', () => { diff --git a/app/main.js b/app/main.js index 252d456..8773cce 100644 --- a/app/main.js +++ b/app/main.js @@ -1,10 +1,10 @@ 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, 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, isBotAutoCommit } from './git.js'; +import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; @@ -15,7 +15,7 @@ async function main() { console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); - if (isBotAutoCommit(WORKSPACE)) { + if (await shouldSkipBotCommit()) { console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); console.log('='.repeat(60)); process.exit(0);