Compare commits

..

16 Commits

Author SHA1 Message Date
AI Review Bot bfa01721e4 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:40:43 +00:00
jiantw83 4fd9a22aa0 feat: report ai review commit status 2026-05-15 14:39:15 +00:00
AI Review Bot 93c3d0ca66 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:34:28 +00:00
jiantw83 35150cae8a chore: expand bot check diagnostics 2026-05-15 14:30:39 +00:00
AI Review Bot e216ca08c5 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:26:45 +00:00
jiantw83 888bf0b359 test: add bot check debug logs 2026-05-15 14:25:08 +00:00
AI Review Bot 59e942f24b chore: update ai-review findings [ai-review-bot] 2026-05-15 14:20:01 +00:00
jiantw83 82ecbd3463 fix: detect ai review bot commits via api 2026-05-15 14:17:55 +00:00
AI Review Bot f3319b5ec4 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:14:22 +00:00
AI Review Bot ee593418f0 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:13:12 +00:00
jiantw83 9012fe64d1 chore: skip ai review bot commits 2026-05-15 14:11:21 +00:00
AI Review Bot 3ae08052a3 chore: update ai-review findings [ai-review-bot] 2026-05-15 14:02:34 +00:00
jiantw83 60f3a9beba fix: skip ai review bot commits 2026-05-15 14:00:59 +00:00
AI Review Bot 09b7be2c40 chore: update ai-review findings [skip ci] 2026-05-15 13:27:17 +00:00
jiantw83 647460ea87 docs: update review guidance 2026-05-15 13:25:39 +00:00
jiantw83 9fe85c9f72 chore: require gitea token input 2026-05-15 13:24:45 +00:00
10 changed files with 261 additions and 22 deletions
+30 -1
View File
@@ -1 +1,30 @@
[] [
{
"level": "critical",
"role": "Maya",
"location": "action.yaml:6, action.yaml:81",
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true` 且移除了 `secrets.GITEA_TOKEN` 的 fallback 機制,這是一個關鍵性的行為變更。請務必新增整合測試 (integration tests) 來驗證以下情境:\n1. 當 `inputs.GITEA_TOKEN` 未提供時,Action 應如預期般失敗。\n2. 當 `inputs.GITEA_TOKEN` 有提供時,Action 應能正常執行。\n這將確保新的輸入要求和邏輯變更不會導致意外的行為或破壞現有工作流程。",
"is_new": false
},
{
"level": "critical",
"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": false
},
{
"level": "warning",
"role": "Leo",
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。",
"is_new": false
},
{
"level": "warning",
"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": false
}
]
+1 -3
View File
@@ -1,7 +1,4 @@
name: AI name: AI
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
on: on:
pull_request: pull_request:
branches-ignore: branches-ignore:
@@ -33,6 +30,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
with: 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_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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
+11 -3
View File
@@ -2,7 +2,7 @@
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。 這是一個 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 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
@@ -11,7 +11,7 @@
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request 5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request 6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request 7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容 8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) 9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
# 設計 # 設計
@@ -33,7 +33,9 @@
2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml' 2.`.gitea/workflows` 資料夾中建立 `ai-review.yaml'
3.`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]`,而且 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 寫入權限,為正常運作所必要,無法縮減。
### 1. OpenAI ### 1. OpenAI
```yaml ```yaml
@@ -54,6 +56,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
OPENAI_BASE_URL: https://api.openai.com/v1 OPENAI_BASE_URL: https://api.openai.com/v1
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
@@ -82,6 +85,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }} OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
OPENAI_BASE_URL: https://openrouter.ai/api/v1 OPENAI_BASE_URL: https://openrouter.ai/api/v1
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
@@ -110,6 +114,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
CLAUDE_BASE_URL: https://api.anthropic.com/v1 CLAUDE_BASE_URL: https://api.anthropic.com/v1
permissions: permissions:
@@ -137,6 +142,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: 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_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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
@@ -165,6 +171,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
AMAZONQ_BASE_URL: https://q.api.aws AMAZONQ_BASE_URL: https://q.api.aws
permissions: permissions:
@@ -193,6 +200,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
permissions: permissions:
+4 -3
View File
@@ -5,7 +5,7 @@ inputs:
# Gitea 相關(可從 gitea context 自動取得) # Gitea 相關(可從 gitea context 自動取得)
GITEA_TOKEN: GITEA_TOKEN:
description: 'Gitea API Token' description: 'Gitea API Token'
required: false required: true
GITEA_SERVER_URL: GITEA_SERVER_URL:
description: 'Gitea Server URL' description: 'Gitea Server URL'
required: false required: false
@@ -80,12 +80,13 @@ runs:
using: 'docker' using: 'docker'
image: 'Dockerfile' image: 'Dockerfile'
env: env:
# Gitea context優先用 inputs,否則從 gitea context 取) # Gitea context改為只從 inputs 取
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }} GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }} GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }} GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }} 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_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 }} PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }}
# LLM # LLM
+1
View File
@@ -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_REPOSITORY = process.env.GITEA_REPOSITORY || '';
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true'; export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
export const PR_NUMBER = process.env.PR_NUMBER || ''; 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_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
+11 -1
View File
@@ -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 ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
export const SYNC_PATHS = [ export const SYNC_PATHS = [
'.amazonq/rules/triage-findings.md', '.amazonq/rules/triage-findings.md',
'.codex/skills/triage-findings/SKILL.md', '.codex/skills/triage-findings/SKILL.md',
@@ -58,6 +59,15 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) {
return { repoDir, branch, headSha, shortSha, commitTime }; 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) * Clone PR head branch to workspace/repo (idempotent)
*/ */
@@ -124,7 +134,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
return; return;
} }
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}`], repoDir);
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
try { try {
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
+19 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; 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 --- // --- helpers ---
function makeTmpWorkspace() { function makeTmpWorkspace() {
@@ -60,6 +60,15 @@ 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');
});
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => { it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
@@ -232,4 +241,13 @@ describe('cloneRepo', () => {
const result = cloneRepo(workspace, spawn); const result = cloneRepo(workspace, spawn);
assert.equal(result, path.join(workspace, 'repo')); 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);
});
}); });
+89 -1
View File
@@ -1,11 +1,18 @@
import axios from 'axios'; import axios from 'axios';
import https from 'https'; 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 httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' }); const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
function extractCommitMessage(payload) {
return payload?.message
|| payload?.commit?.message
|| payload?.commit?.commit?.message
|| '';
}
/** /**
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。 * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
*/ */
@@ -25,6 +32,87 @@ 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'}`);
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'}`);
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;
}
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 內容,移除路徑符合 excludePrefixes 的區塊。
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。 * 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
+59 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, afterEach, mock } from 'node:test'; import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import axios from 'axios'; import axios from 'axios';
import { getPRDiff, filterDiff, postComment } from './gitea.js'; import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, setCommitStatus } from './gitea.js';
afterEach(() => mock.restoreAll()); afterEach(() => mock.restoreAll());
@@ -56,6 +56,64 @@ describe('gitea', () => {
mock.method(axios, 'post', async () => { throw new Error('api error'); }); mock.method(axios, 'post', async () => { throw new Error('api error'); });
await assert.rejects(() => postComment('test'), /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);
});
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 '));
});
}); });
describe('filterDiff', () => { describe('filterDiff', () => {
+30 -2
View File
@@ -1,13 +1,22 @@
import path from 'path'; import path from 'path';
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { GITEA_REPOSITORY, GITEA_SERVER_URL, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { loadRoles, getRoleIntro } from './roles.js'; import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, postComment } from './gitea.js'; import { getPRDiff, postComment, shouldSkipBotCommit, setCommitStatus } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; 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() { async function main() {
console.log('='.repeat(60)); console.log('='.repeat(60));
@@ -15,6 +24,23 @@ async function main() {
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
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);
}
const { provider, baseURL, model } = getLLMConfig(); const { provider, baseURL, model } = getLLMConfig();
if (!provider) { if (!provider) {
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
@@ -36,6 +62,7 @@ async function main() {
if (!diff.trim()) { if (!diff.trim()) {
console.log(' ⚠️ diff 為空,無需審查'); console.log(' ⚠️ diff 為空,無需審查');
await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, 0);
process.exit(0); process.exit(0);
} }
@@ -127,6 +154,7 @@ async function main() {
// Step9: 有 critical 問題則 exit 1 // Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step8: 嚴重問題檢查'); console.log('\n🚦 Step8: 嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length; const criticalCount = filtered.filter(f => f.level === 'critical').length;
await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, criticalCount);
if (criticalCount > 0) { if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`); console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60)); console.log('='.repeat(60));