From 9fe85c9f72483c2fd43dca11edce2dff2f2a07cb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 13:24:45 +0000 Subject: [PATCH 01/34] chore: require gitea token input --- action.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/action.yaml b/action.yaml index 7dce0bc..1a94944 100644 --- a/action.yaml +++ b/action.yaml @@ -5,7 +5,7 @@ inputs: # Gitea 相關(可從 gitea context 自動取得) GITEA_TOKEN: description: 'Gitea API Token' - required: false + required: true GITEA_SERVER_URL: description: 'Gitea Server URL' required: false @@ -80,8 +80,8 @@ runs: using: 'docker' image: 'Dockerfile' env: - # Gitea context(優先用 inputs,否則從 gitea context 取) - GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }} + # Gitea context(改為只從 inputs 取得) + GITEA_TOKEN: ${{ inputs.GITEA_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 }} From 647460ea8766fdc75d9167f207112efa2801c7cb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 13:25:39 +0000 Subject: [PATCH 02/34] docs: update review guidance --- .gitea/workflows/review.yaml | 4 +--- README.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index d7fcbcc..a7bee69 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -1,7 +1,4 @@ name: AI -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true on: pull_request: branches-ignore: @@ -33,6 +30,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} 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_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} diff --git a/README.md b/README.md index c29b486..83ff825 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key OPENAI_BASE_URL: https://api.openai.com/v1 OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} @@ -82,6 +83,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }} OPENAI_BASE_URL: https://openrouter.ai/api/v1 OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} @@ -110,6 +112,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key CLAUDE_BASE_URL: https://api.anthropic.com/v1 permissions: @@ -137,6 +140,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} 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_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} @@ -165,6 +169,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key AMAZONQ_BASE_URL: https://q.api.aws permissions: @@ -191,10 +196,11 @@ jobs: runs-on: ubuntu steps: - name: AI Code Review - uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} - with: - OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 - OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} + uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} + with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} + OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 + OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} permissions: contents: write pull-requests: write From 09b7be2c40202a9c4d9a3d519b3ac9312d94ca0d Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 13:27:17 +0000 Subject: [PATCH 03/34] chore: update ai-review findings [skip ci] --- .gitea/ai-review/findings.json | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..bae2059 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -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": true + }, + { + "level": "warning", + "role": "Leo", + "location": "action.yaml:5", + "suggestion": "輸入 `GITEA_TOKEN` 的註解 `Gitea 相關(可從 gitea context 自動取得)` 已不再準確。由於 `GITEA_TOKEN` 現在是 `required: true` 且不再從 `secrets.GITEA_TOKEN` 取得,建議更新此註解以明確指出此 Token 必須透過 `inputs` 提供。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "action.yaml:80", + "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。", + "is_new": true + }, + { + "level": "warning", + "role": "Rex", + "location": "action.yaml:83", + "suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。此變更移除了從 `secrets.GITEA_TOKEN` 安全取得 Token 的備用機制。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets` 上下文安全傳遞(例如:`with: GITEA_TOKEN: ${{ secrets.MY_GITEA_TOKEN }}`),但若使用者不慎直接將敏感 Token 字串作為 `inputs.GITEA_TOKEN` 的值傳入,該 Token 將可能被記錄在日誌中,導致敏感資訊洩漏。保留備用機制可提供更強健的安全性,降低因使用者操作失誤而導致的風險。", + "is_new": true + } +] From 60f3a9beba22debf9b8fe9a9f307827e3b94965f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:00:59 +0000 Subject: [PATCH 04/34] fix: skip ai review bot commits --- .gitea/workflows/review.yaml | 32 ++++++++++++++++++++++++++++++-- README.md | 8 +++++--- app/git.js | 3 ++- app/git.test.js | 11 ++++++++++- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index a7bee69..73e5f6e 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -5,9 +5,36 @@ on: - master types: [opened, synchronize] jobs: + detect-bot-commit: + name: 偵測自動提交 + runs-on: ubuntu + outputs: + skip: ${{ steps.detect.outputs.skip }} + steps: + - name: 檢查 head commit marker + id: detect + env: + GITEA_API_URL: ${{ github.api_url }} + GITEA_REPOSITORY: ${{ github.repository }} + GITEA_SHA: ${{ github.sha }} + GITEA_TOKEN: ${{ github.token }} + run: | + set -e + commit_json="$(curl -fsSL -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/git/commits/${GITEA_SHA}")" || { + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + } + if printf '%s' "$commit_json" | grep -q '\[ai-review-bot\]'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "偵測到 AI Review Bot commit,跳過 review workflow" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi version: name: 計算版本號 runs-on: ubuntu + needs: [detect-bot-commit] + if: needs.detect-bot-commit.outputs.skip != 'true' outputs: version: ${{ steps.version.outputs.version }} steps: @@ -25,7 +52,8 @@ jobs: code-review: name: Code Review runs-on: ubuntu - needs: [version] + needs: [detect-bot-commit, version] + if: needs.detect-bot-commit.outputs.skip != 'true' steps: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} @@ -37,4 +65,4 @@ jobs: permissions: contents: write pull-requests: write - issues: write \ No newline at end of file + issues: write diff --git a/README.md b/README.md index 83ff825..92b5dd9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 這是一個 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 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) @@ -11,8 +11,8 @@ 5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request 6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request 7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request -8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容 -9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) +8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑 +9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) # 設計 @@ -33,6 +33,8 @@ 2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): +> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`。建議在 review workflow 的最前面先檢查 head commit 是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。 + > **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。 ### 1. OpenAI diff --git a/app/git.js b/app/git.js index e2da47b..a8c16e2 100644 --- a/app/git.js +++ b/app/git.js @@ -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 GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; +export const BOT_COMMIT_MARKER = '[ai-review-bot]'; export const SYNC_PATHS = [ '.amazonq/rules/triage-findings.md', '.codex/skills/triage-findings/SKILL.md', @@ -124,7 +125,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, 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'; try { run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); diff --git a/app/git.test.js b/app/git.test.js index 0786aea..bfb21b7 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js'; +import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER } from './git.js'; // --- helpers --- 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 () => { const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); From 3ae08052a365e2efdbe72ba1dcd86c709ef66b8d Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:02:34 +0000 Subject: [PATCH 05/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index bae2059..00cf34a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -4,27 +4,34 @@ "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": true + "is_new": false }, { "level": "warning", "role": "Leo", "location": "action.yaml:5", "suggestion": "輸入 `GITEA_TOKEN` 的註解 `Gitea 相關(可從 gitea context 自動取得)` 已不再準確。由於 `GITEA_TOKEN` 現在是 `required: true` 且不再從 `secrets.GITEA_TOKEN` 取得,建議更新此註解以明確指出此 Token 必須透過 `inputs` 提供。", - "is_new": true + "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": true + "is_new": false }, { "level": "warning", "role": "Rex", "location": "action.yaml:83", "suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。此變更移除了從 `secrets.GITEA_TOKEN` 安全取得 Token 的備用機制。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets` 上下文安全傳遞(例如:`with: GITEA_TOKEN: ${{ secrets.MY_GITEA_TOKEN }}`),但若使用者不慎直接將敏感 Token 字串作為 `inputs.GITEA_TOKEN` 的值傳入,該 Token 將可能被記錄在日誌中,導致敏感資訊洩漏。保留備用機制可提供更強健的安全性,降低因使用者操作失誤而導致的風險。", + "is_new": false + }, + { + "level": "warning", + "role": "Maya", + "location": "action.yaml:80", + "suggestion": "GITEA_TOKEN 的來源已從 `inputs.GITEA_TOKEN || secrets.GITEA_TOKEN` 變更為僅 `inputs.GITEA_TOKEN`。雖然 `required: true` 已經設定,但仍建議在測試中明確涵蓋此邏輯變更,確保 GITEA_TOKEN 確實只從輸入取得,並且不再嘗試回溯到 secrets,以防止未來潛在的誤解或回歸。", "is_new": true } ] From 9012fe64d168ce0805487d4aa75e3407b533adcf Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:10:12 +0000 Subject: [PATCH 06/34] chore: skip ai review bot commits --- .gitea/workflows/review.yaml | 30 +----------------------------- README.md | 2 +- app/git.js | 9 +++++++++ app/git.test.js | 11 ++++++++++- app/main.js | 8 +++++++- 5 files changed, 28 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index 73e5f6e..7a0fe30 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -5,36 +5,9 @@ on: - master types: [opened, synchronize] jobs: - detect-bot-commit: - name: 偵測自動提交 - runs-on: ubuntu - outputs: - skip: ${{ steps.detect.outputs.skip }} - steps: - - name: 檢查 head commit marker - id: detect - env: - GITEA_API_URL: ${{ github.api_url }} - GITEA_REPOSITORY: ${{ github.repository }} - GITEA_SHA: ${{ github.sha }} - GITEA_TOKEN: ${{ github.token }} - run: | - set -e - commit_json="$(curl -fsSL -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/git/commits/${GITEA_SHA}")" || { - echo "skip=false" >> "$GITHUB_OUTPUT" - exit 0 - } - if printf '%s' "$commit_json" | grep -q '\[ai-review-bot\]'; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "偵測到 AI Review Bot commit,跳過 review workflow" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi version: name: 計算版本號 runs-on: ubuntu - needs: [detect-bot-commit] - if: needs.detect-bot-commit.outputs.skip != 'true' outputs: version: ${{ steps.version.outputs.version }} steps: @@ -52,8 +25,7 @@ jobs: code-review: name: Code Review runs-on: ubuntu - needs: [detect-bot-commit, version] - if: needs.detect-bot-commit.outputs.skip != 'true' + needs: [version] steps: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} diff --git a/README.md b/README.md index 92b5dd9..1de299c 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]`。建議在 review workflow 的最前面先檢查 head commit 是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。 +> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`,而且 action 執行時也會先檢查 head commit 是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。 > **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。 diff --git a/app/git.js b/app/git.js index a8c16e2..855cbca 100644 --- a/app/git.js +++ b/app/git.js @@ -59,6 +59,15 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) { 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) */ diff --git a/app/git.test.js b/app/git.test.js index bfb21b7..23c628b 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER } from './git.js'; +import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js'; // --- helpers --- function makeTmpWorkspace() { @@ -241,4 +241,13 @@ describe('cloneRepo', () => { const result = cloneRepo(workspace, spawn); 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); + }); }); diff --git a/app/main.js b/app/main.js index 7d7a568..252d456 100644 --- a/app/main.js +++ b/app/main.js @@ -4,7 +4,7 @@ import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment } 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 { cloneRepo, commitAndPush, getRepoState, isBotAutoCommit } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; @@ -15,6 +15,12 @@ async function main() { console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); + if (isBotAutoCommit(WORKSPACE)) { + console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); + console.log('='.repeat(60)); + process.exit(0); + } + const { provider, baseURL, model } = getLLMConfig(); if (!provider) { console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); From ee593418f0b05ef507d9e80d1458d44c102460cc Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:13:12 +0000 Subject: [PATCH 07/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 00cf34a..152f05b 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -26,12 +26,5 @@ "location": "action.yaml:83", "suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。此變更移除了從 `secrets.GITEA_TOKEN` 安全取得 Token 的備用機制。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets` 上下文安全傳遞(例如:`with: GITEA_TOKEN: ${{ secrets.MY_GITEA_TOKEN }}`),但若使用者不慎直接將敏感 Token 字串作為 `inputs.GITEA_TOKEN` 的值傳入,該 Token 將可能被記錄在日誌中,導致敏感資訊洩漏。保留備用機制可提供更強健的安全性,降低因使用者操作失誤而導致的風險。", "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "location": "action.yaml:80", - "suggestion": "GITEA_TOKEN 的來源已從 `inputs.GITEA_TOKEN || secrets.GITEA_TOKEN` 變更為僅 `inputs.GITEA_TOKEN`。雖然 `required: true` 已經設定,但仍建議在測試中明確涵蓋此邏輯變更,確保 GITEA_TOKEN 確實只從輸入取得,並且不再嘗試回溯到 secrets,以防止未來潛在的誤解或回歸。", - "is_new": true } ] From f3319b5ec4562cdbbd04a1cd7e2bf41df1b6b3fe Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:14:22 +0000 Subject: [PATCH 08/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 152f05b..07e36d8 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -6,25 +6,11 @@ "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": "warning", - "role": "Leo", - "location": "action.yaml:5", - "suggestion": "輸入 `GITEA_TOKEN` 的註解 `Gitea 相關(可從 gitea context 自動取得)` 已不再準確。由於 `GITEA_TOKEN` 現在是 `required: true` 且不再從 `secrets.GITEA_TOKEN` 取得,建議更新此註解以明確指出此 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:83", - "suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。此變更移除了從 `secrets.GITEA_TOKEN` 安全取得 Token 的備用機制。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets` 上下文安全傳遞(例如:`with: GITEA_TOKEN: ${{ secrets.MY_GITEA_TOKEN }}`),但若使用者不慎直接將敏感 Token 字串作為 `inputs.GITEA_TOKEN` 的值傳入,該 Token 將可能被記錄在日誌中,導致敏感資訊洩漏。保留備用機制可提供更強健的安全性,降低因使用者操作失誤而導致的風險。", - "is_new": false } ] From 82ecbd34631ecc780e89120313b52f9632589e2c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:17:55 +0000 Subject: [PATCH 09/34] 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); From 59e942f24b9d9bfc5f951a5ab55346443ee55154 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:20:01 +0000 Subject: [PATCH 10/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 07e36d8..5fda0c5 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -6,6 +6,13 @@ "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": true + }, { "level": "warning", "role": "Leo", From 888bf0b359f4e193205d39e1aed3f9061b5ef7c1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:25:08 +0000 Subject: [PATCH 11/34] test: add bot check debug logs --- app/gitea.js | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/app/gitea.js b/app/gitea.js index 6e24475..1a5c849 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -34,7 +34,8 @@ export async function getCommitMessageBySha(sha) { httpsAgent, }); return resp.data?.message || ''; - } catch { + } catch (e) { + console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`); return ''; } } @@ -49,17 +50,39 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { }); const sha = resp.data?.commit?.id || resp.data?.commit?.sha || ''; 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 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; } /** From e216ca08c53e03d35b25d67bd72d327c72674af0 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:26:45 +0000 Subject: [PATCH 12/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 5fda0c5..27b4688 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -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", From 35150cae8a877781bc208fb24216dec87a172790 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:30:39 +0000 Subject: [PATCH 13/34] chore: expand bot check diagnostics --- app/gitea.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/gitea.js b/app/gitea.js index 1a5c849..3d7bfe2 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -6,6 +6,13 @@ const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: const headers = () => ({ Authorization: `token ${GITEA_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 + || ''; +} + /** * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。 */ @@ -33,7 +40,9 @@ export async function getCommitMessageBySha(sha) { timeout: 30000, httpsAgent, }); - return resp.data?.message || ''; + 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 ''; @@ -49,6 +58,7 @@ 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 (e) { console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`); @@ -57,7 +67,7 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { } export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { - console.log(` 🔎 bot-check: start sha=${sha || 'empty'} branch=${branch || 'empty'}`); + 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) { From 93c3d0ca660f5920c0979143866c38d43fa3b1be Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:34:28 +0000 Subject: [PATCH 14/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 27b4688..0d744e4 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -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": "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": true } ] From 4fd9a22aa0b9601a918e1c16c90e78ae154e9276 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:39:15 +0000 Subject: [PATCH 15/34] feat: report ai review commit status --- README.md | 2 +- app/gitea.js | 18 ++++++++++++++++++ app/gitea.test.js | 21 ++++++++++++++++++++- app/main.js | 26 ++++++++++++++++++++++++-- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5d3f1e5..ed05b15 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ > **自動提交排除說明**:此 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)三項權限,為正常運作所必要,無法縮減。 +> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)、以及 commit status 寫入權限,為正常運作所必要,無法縮減。 ### 1. OpenAI ```yaml diff --git a/app/gitea.js b/app/gitea.js index 3d7bfe2..0a80476 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -95,6 +95,24 @@ 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 b80e2e9..751f460 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 } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, setCommitStatus } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -95,6 +95,25 @@ describe('gitea', () => { }); 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', () => { diff --git a/app/main.js b/app/main.js index 8773cce..9875806 100644 --- a/app/main.js +++ b/app/main.js @@ -1,13 +1,22 @@ 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 { getPRDiff, postComment, shouldSkipBotCommit } from './gitea.js'; +import { getPRDiff, postComment, shouldSkipBotCommit, setCommitStatus } 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)); @@ -17,6 +26,17 @@ 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); } @@ -42,6 +62,7 @@ async function main() { if (!diff.trim()) { console.log(' ⚠️ diff 為空,無需審查'); + await updateReviewStatus(process.env.PR_HEAD_SHA || process.env.GITHUB_SHA, 0); process.exit(0); } @@ -133,6 +154,7 @@ async function main() { // 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)); From bfa01721e487d3fc24a6e06e7e310f29339f1a3f Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 14:40:43 +0000 Subject: [PATCH 16/34] chore: update ai-review findings [ai-review-bot] --- .gitea/ai-review/findings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 0d744e4..387d4d1 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -25,6 +25,6 @@ "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 + "is_new": false } ] From 066b21aa5c5ac4d473eb1628f10c97938d71bbc9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:47:02 +0000 Subject: [PATCH 17/34] feat: encode ai review outcome in commit marker --- README.md | 4 ++-- app/git.js | 9 +++++---- app/git.test.js | 11 +++++++++++ app/gitea.js | 27 +++++++-------------------- app/gitea.test.js | 26 +++++--------------------- app/main.js | 30 +++++------------------------- 6 files changed, 35 insertions(+), 72 deletions(-) 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)); From b0c4d5a0bce9d7c7737bc309222ce949420d825e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 14:59:15 +0000 Subject: [PATCH 18/34] feat: split gitea comment token --- README.md | 2 +- action.yaml | 4 ++++ app/config.js | 1 + app/gitea.js | 10 +++++++--- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 98f78d6..cba1d69 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ > **自動提交排除說明**:此 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 diff --git a/action.yaml b/action.yaml index 5debe0d..485776b 100644 --- a/action.yaml +++ b/action.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 }} diff --git a/app/config.js b/app/config.js index 4d925c3..ee25071 100644 --- a/app/config.js +++ b/app/config.js @@ -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'; diff --git a/app/gitea.js b/app/gitea.js index ee1acb8..768e84d 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -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) { @@ -115,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; } From 259d0e42c41eeb84ef2f5df41bdef97b94e6a25a Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:01:06 +0000 Subject: [PATCH 19/34] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 387d4d1..1b5e8c9 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -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": false + "location": "action.yaml:7-9, app/gitea.js:100-104", + "suggestion": "引入 `GITEA_COMMENT_TOKEN` 並在 `postComment` 函數中優先使用它,這是一個很好的安全實踐,遵循最小權限原則。建議為此 token 配置僅限於發布評論的權限,以降低潛在洩漏的風險。", + "is_new": true } ] From 069e43c6898e5cec86189bc66b091c63e4b661a3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:01:06 +0000 Subject: [PATCH 20/34] chore: pass separate gitea comment token --- .gitea/workflows/review.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index 7a0fe30..35988a4 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -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 }} From 4702f3814e3894272de63dc36db7a622607c5ca7 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:02:45 +0000 Subject: [PATCH 21/34] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 1b5e8c9..42f678e 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -25,6 +25,6 @@ "role": "Rex", "location": "action.yaml:7-9, app/gitea.js:100-104", "suggestion": "引入 `GITEA_COMMENT_TOKEN` 並在 `postComment` 函數中優先使用它,這是一個很好的安全實踐,遵循最小權限原則。建議為此 token 配置僅限於發布評論的權限,以降低潛在洩漏的風險。", - "is_new": true + "is_new": false } ] From ce53c67cac04b13a1d9048b4f08287d757f7f8ec Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:05:52 +0000 Subject: [PATCH 22/34] fix: fail workflow on bot failure marker --- app/main.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/main.js b/app/main.js index 99a9b90..ea84459 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ 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, shouldSkipBotCommit } 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'; @@ -15,6 +15,16 @@ 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'); console.log('='.repeat(60)); From 140c5059f1dc0f84d115b25d51e6ed2839d73f85 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:07:27 +0000 Subject: [PATCH 23/34] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 42f678e..2ee2b5d 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -23,8 +23,8 @@ { "level": "info", "role": "Rex", - "location": "action.yaml:7-9, app/gitea.js:100-104", - "suggestion": "引入 `GITEA_COMMENT_TOKEN` 並在 `postComment` 函數中優先使用它,這是一個很好的安全實踐,遵循最小權限原則。建議為此 token 配置僅限於發布評論的權限,以降低潛在洩漏的風險。", - "is_new": false + "location": "action.yaml:18", + "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", + "is_new": true } ] From 45e875153cab2a0f7b1b8e203eccf235e5133509 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:13:07 +0000 Subject: [PATCH 24/34] chore: triage review findings --- .gitea/ai-review/exclusions.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index bfad733..365ace9 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -319,5 +319,15 @@ { "location": "app/json.test.js:10", "suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。" + }, + { + "role": "Maya", + "location": "action.yaml:6, action.yaml:12, action.yaml:81", + "suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。" + }, + { + "role": "Leo", + "location": "action.yaml:80", + "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" } ] From 7d5057cf65b6bc878b9e98c599f5810f8c708859 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:15:18 +0000 Subject: [PATCH 25/34] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 2ee2b5d..9963b48 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,30 +1,9 @@ [ - { - "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": "info", "role": "Rex", "location": "action.yaml:18", "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", - "is_new": true + "is_new": false } ] From bd4c3bce9ed1f5275fbaad970654aaa34f23643d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:20:25 +0000 Subject: [PATCH 26/34] docs: align README and TODO with action flow --- README.md | 10 +++++----- TODO.md | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cba1d69..8dd0179 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 簡介 -這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。 +這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Pull Request 中變更的內容後,將問題分級 Comment 到 Pull Request 中。 # 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過) @@ -16,7 +16,7 @@ # 設計 -1. Gitea 的相關參數如果 inputs 沒有定義,則從 ${{ gitea.* }} 取得 +1. Gitea 相關參數中,`GITEA_TOKEN` 必須由 inputs 明確提供;`GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`PR_NUMBER`、`PR_HEAD_BRANCH`、`PR_BASE_BRANCH` 等欄位若 inputs 沒有定義,則從 `${{ gitea.* }}` 取得 2. BASE_URL 如果 inputs 沒有定義,則使用預設值 3. Comment 加上些許 emoji 讓資訊有點活力 4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行 @@ -30,7 +30,7 @@ # 使用說明 1. 在 Gitea 專案中建立 `.gitea/workflows` 資料夾 -2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml' +2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml` 3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用): > **自動提交排除說明**:此 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 也能先檢查一次,效果最好。 @@ -180,7 +180,7 @@ jobs: issues: write ``` -### - Ollama +### 6. Ollama ```yaml name: AI @@ -200,7 +200,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} permissions: diff --git a/TODO.md b/TODO.md index 3419c3b..a515533 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ ## 階段一:基本流程串接 - 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。 -- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。 +- 驗收:log 中能看到每個階段(如「Step1: Pipeline 啟動」、「Step2: Findings 產生」、「Step3: Findings 合併」等)明確訊息,且流程能走完(即使還沒產生 findings)。 - 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。 ## 階段二:Git Diff 排除 .gitea/ 資料夾 @@ -15,9 +15,9 @@ - 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。 - 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。 -## 階段四:AI 去重與角色確認 -- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。 -- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。 +## 階段四:AI 語意去重 +- 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。 +- 驗收:log 中能看到 `AI 去重: N -> M 筆` 的成功訊息,或在失敗時出現 `AI 去重失敗(...),降級:保留所有問題` 之類的明確訊息。 - 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。 ## 階段五:AI 排除問題過濾 From 3fcbf788fc54e20bc20f7880fa62020c18b07711 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:25:26 +0000 Subject: [PATCH 27/34] chore: unify log formatting --- app/comments.js | 15 ++++---- app/findings.js | 43 ++++++++++++----------- app/git.js | 13 +++---- app/gitea.js | 25 +++++++------- app/json.js | 13 +++---- app/llm.js | 9 ++--- app/log.js | 23 +++++++++++++ app/main.js | 92 ++++++++++++++++++++++--------------------------- 8 files changed, 127 insertions(+), 106 deletions(-) create mode 100644 app/log.js diff --git a/app/comments.js b/app/comments.js index 286a845..73d7c25 100644 --- a/app/comments.js +++ b/app/comments.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { postComment } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; +import { ok, line } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; @@ -27,7 +28,7 @@ export function saveFindings(workspace, findings, mirrorDir = null) { const fullPath = path.join(targetDir, FINDINGS_PATH); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8'); - console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`); + ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`); } } @@ -37,12 +38,12 @@ export function saveFindings(workspace, findings, mirrorDir = null) { export async function postOldFindingsComment(findings) { const old = findings.filter(f => !f.is_new); if (old.length === 0) { - console.log(' 無舊問題,跳過'); + line('無舊問題,跳過'); return; } const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`; await postComment(body); - console.log(` ✅ 舊問題 comment 發布 (${old.length} 筆)`); + ok(`舊問題 comment 發布 (${old.length} 筆)`); } /** @@ -51,12 +52,12 @@ export async function postOldFindingsComment(findings) { export async function postNewNonCriticalComment(findings) { const items = findings.filter(f => f.is_new && f.level !== 'critical'); if (items.length === 0) { - console.log(' 無新的非嚴重問題,跳過'); + line('無新的非嚴重問題,跳過'); return; } const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`; await postComment(body); - console.log(` ✅ 新問題(非嚴重)comment 發布 (${items.length} 筆)`); + ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`); } /** @@ -65,12 +66,12 @@ export async function postNewNonCriticalComment(findings) { export async function postNewCriticalComments(findings) { const criticals = findings.filter(f => f.is_new && f.level === 'critical'); if (criticals.length === 0) { - console.log(' 無新的嚴重問題,跳過'); + line('無新的嚴重問題,跳過'); return; } for (const f of criticals) { const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; await postComment(body); - console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`); + ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`); } } diff --git a/app/findings.js b/app/findings.js index 995a9ef..7c69f50 100644 --- a/app/findings.js +++ b/app/findings.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { chatJSON } from './llm.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; +import { line, ok, warn } from './log.js'; const LEVELS = ['critical', 'warning', 'info']; @@ -9,11 +10,11 @@ const LEVELS = ['critical', 'warning', 'info']; * 用單一角色分析 diff,回傳 findings 陣列 */ export async function analyzeWithRole(role, diff) { - console.log(` [${role.name}] 開始分析...`); + line(`[${role.name}] 開始分析`); const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`); const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion) .map(f => ({ ...f, is_new: true })); - console.log(` [${role.name}] 找到 ${valid.length} 個問題`); + ok(`[${role.name}] 找到 ${valid.length} 個問題`); return valid; } @@ -22,14 +23,14 @@ export async function analyzeWithRole(role, diff) { */ function readJSONArray(fullPath, label) { if (!fs.existsSync(fullPath)) { - console.log(` ${label}檔案不存在,視為空`); + warn(`${label}檔案不存在,視為空`); return []; } try { const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); return Array.isArray(data) ? data : []; } catch (e) { - console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`); + warn(`讀取${label}失敗: ${e.message},視為空`); return []; } } @@ -53,12 +54,12 @@ export function loadOldFindings(workspace) { const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false })); if (fs.existsSync(fullPath)) { const stat = fs.statSync(fullPath); - console.log(` 讀取舊 findings 檔案: ${fullPath}`); - console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); + line(`讀取舊 findings 檔案: ${fullPath}`); + line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); } else { - console.log(` 舊 findings 檔案不存在: ${fullPath}`); + warn(`舊 findings 檔案不存在: ${fullPath}`); } - console.log(` 讀取舊 findings: ${old.length} 筆`); + ok(`讀取舊 findings: ${old.length} 筆`); return old; } @@ -74,7 +75,7 @@ export function mergeFindings(oldFindings, newFindings) { return true; }); const merged = [...oldFindings, ...deduped]; - console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); + ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); return merged; } @@ -91,7 +92,7 @@ export function sortByLevel(findings) { function fallback(label, findings, e) { const status = e.response?.status; const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message; - console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`); + warn(`${label}失敗(${reason}),降級:保留所有問題`); return findings; } @@ -111,7 +112,7 @@ export async function deduplicateWithAI(findings) { try { const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { - console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`); + ok(`AI 去重: ${findings.length} -> ${result.length} 筆`); // 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回 const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); @@ -128,13 +129,13 @@ export async function deduplicateWithAI(findings) { export function loadExclusions(workspace, repoState = null) { const fullPath = path.join(workspace, EXCLUSIONS_PATH); if (!fs.existsSync(fullPath)) { - console.log(` 排除問題檔案不存在,視為空: ${fullPath}`); + warn(`排除問題檔案不存在,視為空: ${fullPath}`); if (repoState) { const branch = repoState.branch || 'detached'; const shortSha = repoState.shortSha || repoState.headSha || 'unknown'; - console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`); + line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`); } - console.log(' 讀取排除問題: raw=0 normalized=0 筆'); + ok('讀取排除問題: raw=0 normalized=0 筆'); return []; } @@ -148,14 +149,14 @@ export function loadExclusions(workspace, repoState = null) { const branch = repoState?.branch || 'detached'; const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown'; const commitTime = repoState?.commitTime || 'unknown'; - console.log(` 讀取排除問題檔案: ${fullPath}`); - console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); - console.log(` 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`); + line(`讀取排除問題檔案: ${fullPath}`); + line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); + line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`); } catch (e) { - console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); + warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); exclusions = []; } - console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`); + ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`); return exclusions; } @@ -171,7 +172,7 @@ export function applyExclusions(findings, exclusions) { const exPath = ex.location ? String(ex.location).split(':')[0] : null; return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role); })); - console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); + ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); return filtered; } @@ -190,7 +191,7 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) { try { const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { - console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); + ok(`AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); } diff --git a/app/git.js b/app/git.js index fca4321..54a48ce 100644 --- a/app/git.js +++ b/app/git.js @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; +import { line, ok, warn } from './log.js'; const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; @@ -78,11 +79,11 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { return withAskpass(workspace, credEnv => { if (!fs.existsSync(repoDir)) { run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv); - console.log(` ✅ repo cloned to ${repoDir}`); + ok(`repo cloned to ${repoDir}`); } else { run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); run(['checkout', PR_HEAD_BRANCH], repoDir); - console.log(` ✅ repo already exists, fetched latest`); + ok('repo already exists, fetched latest'); } return repoDir; }); @@ -130,7 +131,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, const status = run(['status', '--porcelain'], repoDir); if (!status) { - console.log(' sync files 無變更,跳過 commit'); + line('sync files 無變更,跳過 commit'); return; } @@ -139,12 +140,12 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, 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} review_outcome=${reviewOutcome}`); + ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`); } catch (pushErr) { - console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); + warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); } }); } catch (e) { - console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); + warn(`Runner failed: commit/push 失敗: ${e.message}`); } } diff --git a/app/gitea.js b/app/gitea.js index 768e84d..66b484c 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,6 +1,7 @@ import axios from 'axios'; import https from 'https'; 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'; +import { line, ok, warn } from './log.js'; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); @@ -46,10 +47,10 @@ export async function getCommitMessageBySha(sha) { 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'}`); + line(`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}`); + warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`); return ''; } } @@ -63,40 +64,40 @@ 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'}`); + line(`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}`); + warn(`bot-check branch api 失敗: branch=${branch} error=${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'}`); + line(`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)}`); + line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`); if (shaMessage.includes('[ai-review-bot]')) { - console.log(' ✅ bot-check: matched commit sha marker'); + ok('bot-check matched commit sha marker'); return true; } } else { - console.log(' 🔎 bot-check: skip sha lookup because sha is empty'); + line('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)}`); + line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`); if (branchMessage.includes('[ai-review-bot]')) { - console.log(' ✅ bot-check: matched branch head marker'); + ok('bot-check matched branch head marker'); return true; } } else { - console.log(' 🔎 bot-check: skip branch lookup because branch is empty'); + line('bot-check skip branch lookup because branch is empty'); } - console.log(' ℹ️ bot-check: no [ai-review-bot] marker found'); + line('bot-check no [ai-review-bot] marker found'); return false; } diff --git a/app/json.js b/app/json.js index d2b74c0..fc56649 100644 --- a/app/json.js +++ b/app/json.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { chat } from './llm.js'; +import { ok, warn, error } from './log.js'; const MAX_JSON_BYTES = 1024 * 1024; @@ -50,25 +51,25 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS fs.mkdirSync(path.dirname(fullPath), { recursive: true }); if (!fs.existsSync(fullPath)) { - console.log(` ⚠️ ${label} 不存在,將於驗證後補建`); + warn(`${label} 不存在,將於驗證後補建`); return { exists: false, valid: false, repaired: false }; } try { JSON.parse(readJSONText(fullPath, label)); - console.log(` ✅ ${label} JSON 格式正確`); + ok(`${label} JSON 格式正確`); return { exists: true, valid: true, repaired: false }; } catch (e) { - console.error(` ❌ ${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); + error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); try { const original = readJSONText(fullPath, label); const repaired = await repairer(fullPath, label, original); fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8'); JSON.parse(readJSONText(fullPath, label)); - console.log(` ✅ ${label} 已由 AI 修正並通過再次驗證`); + ok(`${label} 已由 AI 修正並通過再次驗證`); return { exists: true, valid: true, repaired: true }; } catch (repairErr) { - console.error(` ❌ ${label} 修正失敗: ${repairErr.message}`); + error(`${label} 修正失敗: ${repairErr.message}`); throw repairErr; } } @@ -82,6 +83,6 @@ export function ensureJSONArrayFileExists(fullPath, label) { if (fs.existsSync(fullPath)) return false; fs.writeFileSync(fullPath, '[]\n', 'utf8'); - console.log(` ⚠️ ${label} 不存在,已建立空陣列`); + warn(`${label} 不存在,已建立空陣列`); return true; } diff --git a/app/llm.js b/app/llm.js index 87ace9d..e1a401a 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,11 +1,12 @@ import axios from 'axios'; import { getLLMConfig } from './config.js'; +import { line, error } from './log.js'; export async function chat(systemPrompt, userContent) { const { provider, apiKeys, baseURL, model } = getLLMConfig(); if (!provider) throw new Error('未設定任何 LLM API Key'); - console.log(` [LLM] provider=${provider} model=${model}`); + line(`[LLM] provider=${provider} model=${model}`); const headers = { 'Content-Type': 'application/json' }; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; @@ -21,10 +22,10 @@ export async function chat(systemPrompt, userContent) { ); return resp.data.choices[0].message.content; } catch (e) { - console.log(` [LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); + line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); } } - console.error(' [LLM] 所有 API Key 均失敗,終止流程'); + error('[LLM] 所有 API Key 均失敗,終止流程'); process.exit(1); } @@ -33,7 +34,7 @@ export async function chatJSON(systemPrompt, userContent) { try { return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); } catch (e) { - console.log(` [LLM] JSON 解析失敗: ${e.message}`); + line(`[LLM] JSON 解析失敗: ${e.message}`); return []; } } diff --git a/app/log.js b/app/log.js new file mode 100644 index 0000000..cde500f --- /dev/null +++ b/app/log.js @@ -0,0 +1,23 @@ +export function section(title) { + console.log(`\n=== ${title} ===`); +} + +export function step(stepName, title) { + console.log(`\n[${stepName}] ${title}`); +} + +export function line(message) { + console.log(` - ${message}`); +} + +export function ok(message) { + console.log(` ✓ ${message}`); +} + +export function warn(message) { + console.log(` ! ${message}`); +} + +export function error(message) { + console.error(` x ${message}`); +} diff --git a/app/main.js b/app/main.js index ea84459..f7fc6ba 100644 --- a/app/main.js +++ b/app/main.js @@ -6,121 +6,115 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; +import { section, step, line, ok, warn, error } from './log.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; async function main() { - console.log('='.repeat(60)); - console.log('🚀 Step1: Pipeline 啟動'); - console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); - console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); + section('AI Code Review Pipeline'); + step('Step1', 'Pipeline 啟動'); + line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); + line(`${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}`); + line(`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)); + error('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); + section('Pipeline 結束'); process.exit(1); } if (await shouldSkipBotCommit()) { - console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); - console.log('='.repeat(60)); + ok('偵測到 [ai-review-bot] 自動提交,直接完成 action'); + section('Pipeline 結束'); process.exit(0); } const { provider, baseURL, model } = getLLMConfig(); if (!provider) { - console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); + error('未設定任何 LLM API Key,請檢查 action inputs'); process.exit(1); } - console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`); + line(`LLM: provider=${provider} model=${model} base_url=${baseURL}`); const roles = loadRoles(); - console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); + line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); let diff; try { diff = await getPRDiff(); - console.log(` diff 長度: ${diff.length} 字元`); + line(`diff 長度: ${diff.length} 字元`); } catch (e) { - console.error(` ❌ 取得 diff 失敗: ${e.message}`); + error(`取得 diff 失敗: ${e.message}`); process.exit(1); } if (!diff.trim()) { - console.log(' ⚠️ diff 為空,無需審查'); + warn('diff 為空,無需審查'); process.exit(0); } try { const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; await postComment(intro); - console.log(' ✅ 角色介紹 comment 發布成功'); + ok('角色介紹 comment 發布成功'); } catch (e) { - console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); + warn(`comment 發布失敗(繼續執行): ${e.message}`); } - // Step2: 各角色分析 diff 產生新 findings - console.log('\n📊 Step2: Findings 產生'); + step('Step2', 'Findings 產生'); const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; for (let i = 0; i < results.length; i++) { if (results[i].status === 'fulfilled') { newFindings.push(...results[i].value); } else { - console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); + warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); } } - console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); + ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); - // Step4: 讀取舊 findings,合併去重(含 AI 語意去重) - console.log('\n🔀 Step3: Findings 合併'); - // Clone repo 以讀取舊 findings 與排除清單 + step('Step3', 'Findings 合併'); let repoDir; try { repoDir = cloneRepo(WORKSPACE); } catch (e) { - console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`); + warn(`clone repo 失敗(繼續執行): ${e.message}`); } const repoState = repoDir ? getRepoState(repoDir) : null; if (repoState) { - console.log(` repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); + line(`repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); } const oldFindings = loadOldFindings(repoDir || WORKSPACE); const mergedFindings = mergeFindings(oldFindings, newFindings); - console.log(` Step3 merged findings total=${mergedFindings.length}`); + ok(`Step3 merged findings total=${mergedFindings.length}`); - console.log('\n🤖 Step3b: AI 語意去重'); + step('Step3b', 'AI 語意去重'); const deduped = await deduplicateWithAI(mergedFindings); const sorted = sortByLevel(deduped); - console.log(` Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); + ok(`Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); - // Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報 - console.log('\n🚫 Step4: AI 排除問題過濾'); - // 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考 + step('Step4', 'AI 排除問題過濾'); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); const ruleFiltered = applyExclusions(sorted, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); - console.log(` Step4 完成: findings total=${filtered.length}`); + ok(`Step4 完成: findings total=${filtered.length}`); - // Step6: 寫入 findings.json,依序發布 comment - console.log('\n📝 Step5: Findings 寫入與 Comment 發布'); + step('Step5', 'Findings 寫入與 Comment 發布'); const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); try { await postOldFindingsComment(filtered); await postNewNonCriticalComment(filtered); await postNewCriticalComments(filtered); - console.log(' Step5 完成'); + ok('Step5 完成'); } catch (e) { - console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); + warn(`comment 發布失敗(繼續執行): ${e.message}`); } - // Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON - console.log('\n🔎 Step6: JSON 格式驗證'); + step('Step6', 'JSON 格式驗證'); const missingPaths = []; for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { const fullPath = path.join(reviewDir, relPath); @@ -136,26 +130,24 @@ async function main() { ensureJSONArrayFileExists(fullPath, relPath); } - // Step7: commit/push findings.json 到來源分支 - console.log('\n💾 Step7: 記憶區 Commit/Push'); + step('Step7', '記憶區 Commit/Push'); const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; - console.log(` 🔎 review outcome=${reviewOutcome}`); + line(`review outcome=${reviewOutcome}`); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); - // Step9: 有 critical 問題則 exit 1 - console.log('\n🚦 Step8: 嚴重問題檢查'); + step('Step8', '嚴重問題檢查'); const criticalCount = filtered.filter(f => f.level === 'critical').length; if (criticalCount > 0) { - console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); - console.log('='.repeat(60)); + error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); + section('Pipeline 結束'); process.exit(1); } - console.log(' ✅ 無嚴重問題'); - console.log('\n✅ Pipeline 完成'); - console.log('='.repeat(60)); + ok('無嚴重問題'); + ok('Pipeline 完成'); + section('Pipeline 結束'); } main().catch(e => { - console.error('❌ Runner failed:', e.message); + error(`Runner failed: ${e.message}`); process.exit(1); }); From 4e586158a5e81446bceca465e75334dc283f91b9 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:31:48 +0000 Subject: [PATCH 28/34] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 9963b48..b3941b3 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -5,5 +5,19 @@ "location": "action.yaml:18", "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", "is_new": false + }, + { + "level": "info", + "role": "Leo", + "location": "app/log.js", + "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/log.js:19", + "suggestion": "在 `warn` 函式中使用 `console.warn` 而非 `console.log`。雖然目前功能相同,但 `console.warn` 在某些環境下(例如瀏覽器開發者工具)會以不同的樣式呈現警告訊息,有助於區分不同嚴重程度的日誌。", + "is_new": true } ] From 38a3349e4f40cba975af7c19bb2c5fda105b0a91 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:29:59 +0000 Subject: [PATCH 29/34] chore: refine pipeline stage logs --- app/main.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/main.js b/app/main.js index f7fc6ba..03cd644 100644 --- a/app/main.js +++ b/app/main.js @@ -76,7 +76,7 @@ async function main() { } ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); - step('Step3', 'Findings 合併'); + step('Step3', 'Findings 合併與語意去重'); let repoDir; try { repoDir = cloneRepo(WORKSPACE); @@ -90,11 +90,9 @@ async function main() { const oldFindings = loadOldFindings(repoDir || WORKSPACE); const mergedFindings = mergeFindings(oldFindings, newFindings); ok(`Step3 merged findings total=${mergedFindings.length}`); - - step('Step3b', 'AI 語意去重'); const deduped = await deduplicateWithAI(mergedFindings); const sorted = sortByLevel(deduped); - ok(`Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); + ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); step('Step4', 'AI 排除問題過濾'); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); From acb3604cda5f37292b9cce66c0522140c9100e7a Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:34:01 +0000 Subject: [PATCH 30/34] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index b3941b3..1006871 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -11,13 +11,13 @@ "role": "Leo", "location": "app/log.js", "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。", - "is_new": true + "is_new": false }, { "level": "info", "role": "Leo", "location": "app/log.js:19", "suggestion": "在 `warn` 函式中使用 `console.warn` 而非 `console.log`。雖然目前功能相同,但 `console.warn` 在某些環境下(例如瀏覽器開發者工具)會以不同的樣式呈現警告訊息,有助於區分不同嚴重程度的日誌。", - "is_new": true + "is_new": false } ] From c836ec08e4b931a0dd113aaf86b2f45b90b23930 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:45:08 +0000 Subject: [PATCH 31/34] chore: triage log output suggestions --- .gitea/ai-review/exclusions.json | 10 ++++++++++ app/git.test.js | 6 +++++- app/log.js | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 365ace9..072e4bf 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -329,5 +329,15 @@ "role": "Leo", "location": "action.yaml:80", "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" + }, + { + "role": "Rex", + "location": "action.yaml:18", + "suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。" + }, + { + "role": "Leo", + "location": "app/log.js", + "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。" } ] diff --git a/app/git.test.js b/app/git.test.js index 24abf62..fd6543d 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -185,12 +185,16 @@ describe('commitAndPush', () => { }); const logs = []; const originalLog = console.log; - console.log = (...args) => { logs.push(args.join(' ')); }; + const originalWarn = console.warn; + const capture = (...args) => { logs.push(args.join(' ')); }; + console.log = capture; + console.warn = capture; try { await commitAndPush(workspace, repoDir, spawn, sourceRoot); } finally { console.log = originalLog; + console.warn = originalWarn; } assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗'))); diff --git a/app/log.js b/app/log.js index cde500f..a2155bc 100644 --- a/app/log.js +++ b/app/log.js @@ -15,7 +15,7 @@ export function ok(message) { } export function warn(message) { - console.log(` ! ${message}`); + console.warn(` ! ${message}`); } export function error(message) { From 95e90393e7fd86e60a65fc61e3c2955cd6d76182 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:46:29 +0000 Subject: [PATCH 32/34] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 1006871..67cf628 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,23 +1,9 @@ [ { "level": "info", - "role": "Rex", - "location": "action.yaml:18", - "suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。", - "is_new": false - }, - { - "level": "info", - "role": "Leo", + "role": "Maya", "location": "app/log.js", - "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。", - "is_new": false - }, - { - "level": "info", - "role": "Leo", - "location": "app/log.js:19", - "suggestion": "在 `warn` 函式中使用 `console.warn` 而非 `console.log`。雖然目前功能相同,但 `console.warn` 在某些環境下(例如瀏覽器開發者工具)會以不同的樣式呈現警告訊息,有助於區分不同嚴重程度的日誌。", - "is_new": false + "suggestion": "log.js 檔案中的 ok, warn, error 函數是應用程式的日誌工具。雖然功能簡單,但為這些工具函數編寫單元測試是一個良好的實踐,以確保它們正確地呼叫 console 對應的方法(如 console.log, console.warn, console.error)並輸出預期的格式。這有助於防止未來意外的行為變更。", + "is_new": true } ] From 86d8666cda5bd6d447c2362c71e6bd57950ff3e3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 15:51:56 +0000 Subject: [PATCH 33/34] test: cover log helpers --- app/log.test.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/log.test.js diff --git a/app/log.test.js b/app/log.test.js new file mode 100644 index 0000000..c810d77 --- /dev/null +++ b/app/log.test.js @@ -0,0 +1,59 @@ +import { describe, it, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { section, step, line, ok, warn, error } from './log.js'; + +afterEach(() => mock.restoreAll()); + +describe('log helpers', () => { + it('formats section and step messages', () => { + const calls = []; + mock.method(console, 'log', (...args) => { + calls.push(args.join(' ')); + }); + + section('Pipeline'); + step('Step1', 'Start'); + + assert.deepEqual(calls, [ + '\n=== Pipeline ===', + '\n[Step1] Start', + ]); + }); + + it('formats line and ok messages with console.log', () => { + const calls = []; + mock.method(console, 'log', (...args) => { + calls.push(args.join(' ')); + }); + + line('hello'); + ok('done'); + + assert.deepEqual(calls, [ + ' - hello', + ' ✓ done', + ]); + }); + + it('formats warn messages with console.warn', () => { + const calls = []; + mock.method(console, 'warn', (...args) => { + calls.push(args.join(' ')); + }); + + warn('careful'); + + assert.deepEqual(calls, [' ! careful']); + }); + + it('formats error messages with console.error', () => { + const calls = []; + mock.method(console, 'error', (...args) => { + calls.push(args.join(' ')); + }); + + error('boom'); + + assert.deepEqual(calls, [' x boom']); + }); +}); From ce6afdd5ee3c07a6ca41aad85738c916729e7657 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 15 May 2026 15:53:01 +0000 Subject: [PATCH 34/34] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 67cf628..f5022c2 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -4,6 +4,13 @@ "role": "Maya", "location": "app/log.js", "suggestion": "log.js 檔案中的 ok, warn, error 函數是應用程式的日誌工具。雖然功能簡單,但為這些工具函數編寫單元測試是一個良好的實踐,以確保它們正確地呼叫 console 對應的方法(如 console.log, console.warn, console.error)並輸出預期的格式。這有助於防止未來意外的行為變更。", + "is_new": false + }, + { + "level": "info", + "role": "Maya", + "location": "app/log.test.js", + "suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。", "is_new": true } ]