Compare commits

..

11 Commits

10 changed files with 93 additions and 21 deletions
+8 -1
View File
@@ -11,7 +11,7 @@
"role": "Leo",
"location": "action.yaml:12",
"suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。目前將其設定為 `required: true` 並移除 `secrets.GITEA_TOKEN` 的 fallback 機制,會導致現有依賴 `secrets.GITEA_TOKEN` 的工作流程中斷,並降低配置的彈性。如果目的是強制透過 `inputs` 傳遞,應在文件明確說明此重大變更及其原因。",
"is_new": true
"is_new": false
},
{
"level": "warning",
@@ -19,5 +19,12 @@
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。",
"is_new": false
},
{
"level": "info",
"role": "Rex",
"location": "action.yaml:7-9, app/gitea.js:100-104",
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 並在 `postComment` 函數中優先使用它,這是一個很好的安全實踐,遵循最小權限原則。建議為此 token 配置僅限於發布評論的權限,以降低潛在洩漏的風險。",
"is_new": false
}
]
+1
View File
@@ -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 }}
+2 -2
View File
@@ -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)三項權限,為正常運作所必要,無法縮減。
> **權限說明**:此 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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+11
View File
@@ -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 () => {
+53 -11
View File
@@ -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, 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) {
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/ 資料夾。
*/
@@ -33,8 +45,11 @@ export async function getCommitMessageBySha(sha) {
timeout: 30000,
httpsAgent,
});
return resp.data?.message || '';
} catch {
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 '';
}
}
@@ -48,18 +63,41 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
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 {
} 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 } = {}) {
const candidates = [
await getCommitMessageBySha(sha),
await getBranchHeadCommitMessage(branch),
].filter(Boolean);
return candidates.some(message => message.includes('[ai-review-bot]'));
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;
}
/**
@@ -77,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 -2
View File
@@ -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 } 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,6 +94,9 @@ describe('gitea', () => {
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');
});
});
+3 -1
View File
@@ -128,7 +128,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: 嚴重問題檢查');