Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce53c67cac | |||
| 4702f3814e | |||
| 069e43c689 | |||
| 259d0e42c4 | |||
| b0c4d5a0bc | |||
| 066b21aa5c | |||
| bfa01721e4 |
@@ -21,10 +21,10 @@
|
||||
"is_new": false
|
||||
},
|
||||
{
|
||||
"level": "warning",
|
||||
"level": "info",
|
||||
"role": "Rex",
|
||||
"location": "action.yaml:81",
|
||||
"suggestion": "在 `action.yaml` 中,`GITEA_TOKEN` 的設定從 `secrets.GITEA_TOKEN` 的 fallback 移除,現在僅從 `inputs.GITEA_TOKEN` 取得。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets.MY_GITEA_TOKEN` 安全地傳遞,但此變更將確保敏感資料安全傳遞的責任完全轉移到工作流程的配置者。請確保所有使用此 action 的工作流程都透過 GitHub/Gitea secrets 將 `GITEA_TOKEN` 傳遞給 `inputs.GITEA_TOKEN`,以避免將敏感令牌硬編碼或暴露在日誌中。",
|
||||
"is_new": true
|
||||
"location": "action.yaml:7-9, app/gitea.js:100-104",
|
||||
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 並在 `postComment` 函數中優先使用它,這是一個很好的安全實踐,遵循最小權限原則。建議為此 token 配置僅限於發布評論的權限,以降低潛在洩漏的風險。",
|
||||
"is_new": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -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)三項權限,為正常運作所必要,無法縮減。若你想讓 comment 用不同權限的 token,可額外傳 `GITEA_COMMENT_TOKEN`,其餘 Gitea 操作仍使用 `GITEA_TOKEN`。
|
||||
|
||||
### 1. OpenAI
|
||||
```yaml
|
||||
|
||||
@@ -6,6 +6,9 @@ inputs:
|
||||
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'
|
||||
required: false
|
||||
@@ -82,6 +85,7 @@ runs:
|
||||
env:
|
||||
# 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 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+14
-23
@@ -1,9 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } 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) {
|
||||
@@ -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/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
|
||||
@@ -128,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;
|
||||
}
|
||||
|
||||
+5
-21
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-25
@@ -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, 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';
|
||||
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));
|
||||
@@ -24,19 +15,18 @@ 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');
|
||||
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 +52,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 +138,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));
|
||||
|
||||
Reference in New Issue
Block a user