diff --git a/README.md b/README.md index ed05b15..98f78d6 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ 2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): -> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`,而且 action 執行時會先透過 Gitea API 檢查這次觸發的 PR head commit(優先用 `pull_request.head.sha`)是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。 +> **自動提交排除說明**:此 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)、以及 commit status 寫入權限,為正常運作所必要,無法縮減。 +> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。 ### 1. OpenAI ```yaml diff --git a/app/git.js b/app/git.js index 855cbca..fca4321 100644 --- a/app/git.js +++ b/app/git.js @@ -88,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 { @@ -134,13 +134,14 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, return; } - const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}`], 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 23c628b..24abf62 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -67,6 +67,17 @@ describe('commitAndPush', () => { 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 () => { diff --git a/app/gitea.js b/app/gitea.js index 0a80476..ee1acb8 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -13,6 +13,11 @@ function extractCommitMessage(payload) { || ''; } +export function getBotReviewOutcome(message) { + const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i); + return match?.[1]?.toLowerCase() || 'unknown'; +} + /** * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。 */ @@ -71,7 +76,7 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT const shaMessage = await getCommitMessageBySha(sha); if (sha) { - console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'}`); + 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; @@ -82,7 +87,7 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT const branchMessage = await getBranchHeadCommitMessage(branch); if (branch) { - console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'}`); + 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; @@ -95,24 +100,6 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT return false; } -export async function setCommitStatus(sha, state, description, context = 'ai-review/critical', targetUrl = '') { - if (!sha) throw new Error('commit sha is required for status update'); - const payload = { - state, - context, - description, - }; - if (targetUrl) payload.target_url = targetUrl; - - const resp = await axios.post(api(`/repos/${GITEA_REPOSITORY}/statuses/${encodeURIComponent(sha)}`), payload, { - headers: headers(), - timeout: 30000, - httpsAgent, - }); - console.log(` ✅ status: sha=${sha} state=${state} context=${context} description=${description}`); - return resp.data; -} - /** * 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。 * 每個區塊以 "diff --git a/" 開頭判斷,使用 startsWith 精確比對前綴。 diff --git a/app/gitea.test.js b/app/gitea.test.js index 751f460..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, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, setCommitStatus } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -86,7 +86,7 @@ describe('gitea', () => { 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]' } }; + return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } }; } if (url.includes('/branches/feat%2Ftest')) { return { data: { commit: { id: 'sha-bot' } } }; @@ -94,25 +94,9 @@ describe('gitea', () => { return { data: { message: 'regular commit' } }; }); await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true); - }); - - it('setCommitStatus posts commit status to Gitea API', async () => { - let capturedUrl, capturedBody, capturedOpts; - mock.method(axios, 'post', async (url, body, opts) => { - capturedUrl = url; - capturedBody = body; - capturedOpts = opts; - return { data: { state: body.state } }; - }); - - const result = await setCommitStatus('sha-123', 'failure', 'found 2 critical issues', 'ai-review/critical', 'https://example.com/pr/1'); - assert.equal(result.state, 'failure'); - assert.ok(capturedUrl.includes('/statuses/sha-123')); - assert.equal(capturedBody.state, 'failure'); - assert.equal(capturedBody.context, 'ai-review/critical'); - assert.equal(capturedBody.description, 'found 2 critical issues'); - assert.equal(capturedBody.target_url, 'https://example.com/pr/1'); - assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); + 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'); }); }); diff --git a/app/main.js b/app/main.js index 9875806..99a9b90 100644 --- a/app/main.js +++ b/app/main.js @@ -1,22 +1,13 @@ import path from 'path'; -import { GITEA_REPOSITORY, GITEA_SERVER_URL, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; +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, shouldSkipBotCommit, setCommitStatus } 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 } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; -const REVIEW_STATUS_CONTEXT = 'ai-review/critical'; - -async function updateReviewStatus(sha, criticalCount) { - const state = criticalCount > 0 ? 'failure' : 'success'; - const description = criticalCount > 0 - ? `found ${criticalCount} critical issue${criticalCount === 1 ? '' : 's'}` - : 'no critical issues found'; - await setCommitStatus(sha, state, description, REVIEW_STATUS_CONTEXT, `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}`); -} async function main() { console.log('='.repeat(60)); @@ -26,17 +17,6 @@ async function main() { if (await shouldSkipBotCommit()) { console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); - let criticalCount = 0; - try { - const repoDir = cloneRepo(WORKSPACE); - const findings = loadOldFindings(repoDir || WORKSPACE); - criticalCount = findings.filter(f => f.level === 'critical').length; - console.log(` 🔎 bot-check: current findings critical=${criticalCount}`); - await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, criticalCount); - } catch (e) { - console.error(` ❌ bot-check: 無法回報 status: ${e.message}`); - process.exit(1); - } console.log('='.repeat(60)); process.exit(0); } @@ -62,7 +42,6 @@ async function main() { if (!diff.trim()) { console.log(' ⚠️ diff 為空,無需審查'); - await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, 0); process.exit(0); } @@ -149,12 +128,13 @@ 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: 嚴重問題檢查'); const criticalCount = filtered.filter(f => f.level === 'critical').length; - await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, criticalCount); if (criticalCount > 0) { console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); console.log('='.repeat(60));