diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 6cb806a..eac7d1a 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -16,5 +16,11 @@ "role": "Leo", "original_finding": "extractJSONText is returning an unexpected result; suggest returning an empty array or throwing when no JSON is found.", "reason": "誤報:extractJSONText 的合約刻意是「盡力擷取,找不到合法 JSON 時回傳去 fence 後的原文」,呼叫端(chatJSON 等)依賴此行為再做後續解析與 AI 修復。改成回 [] 或丟例外會破壞既有呼叫鏈,且型別不一致(回傳值為字串非陣列),故不採納。" + }, + { + "location": "app/config.js:25", + "role": "Bard", + "original_finding": "`getInsecureHttpsAgent` 與 `getOpenCodeHttpsAgent` 同時存在,卻指向同一個物件來源,等於替同一段旋律寫了兩個名字,容易讓後續維護者搞不清楚哪個才是正規稱呼。", + "reason": "相容性保留:`getOpenCodeHttpsAgent` 是既有公開匯出名稱,README 與測試仍引用此語意名稱;本次已補上過渡別名註解,說明新程式碼應優先使用 `getInsecureHttpsAgent`,避免破壞既有呼叫端。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 92d2571..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,66 +1 @@ -[ - { - "level": "warning", - "role": "Assassin", - "location": "app/git.js:67", - "problem": "這裡對所有 git 網路操作強制關閉 SSL 驗證。攻擊者若能攔截遠端回應,就能偽造 `ls-remote`、污染抓到的內容,甚至在有認證的情況下竊取 GITEA_TOKEN,讓後續審查建立在被污染的資料上。", - "suggestion": "移除 `GIT_SSL_NO_VERIFY=true`。若是內部憑證問題,改用正確的 CA bundle 或指定受信任憑證檔,不要整體關閉 git 的憑證驗證。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/usage.js:180", - "problem": "這裡對 OpenRouter 額度查詢也明確套用不驗證憑證的 agent。攻擊者只要攔截這條 HTTPS 請求,就能讀到 `Authorization: Bearer` 的 API key,還能回傳假 quota 資料誤導流程判斷。", - "suggestion": "改回預設的 HTTPS 憑證驗證。若必須支援自簽憑證,僅對特定內部主機單獨配置受信任 CA,且不要對外部 API 一律關閉驗證。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "action.yaml:8", - "problem": "這個新加入的 `model` 輸入沒有任何描述,讓 action 的公開介面像是少了說明書的樂譜,讀者只能靠猜來理解它要吃什麼值。", - "suggestion": "補上一句簡短但明確的描述,例如說明這個欄位是「要使用的模型名稱」或「AI 助理 CLI 的模型識別字串」;讓使用者一眼看懂用途。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/config.js:4", - "problem": "`process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'` 放在模組載入時就直接生效,這種全域副作用太早出聲,會讓讀者在還沒看到任何呼叫前,就先被迫接受一個隱晦的環境改寫。", - "suggestion": "把這種全域設定移到明確的啟動流程或初始化函式中,至少搭配更醒目的註解,讓副作用出現的位置和目的都更清楚。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/config.js:25", - "problem": "`getInsecureHttpsAgent` 與 `getOpenCodeHttpsAgent` 同時存在,卻指向同一個物件來源,等於替同一段旋律寫了兩個名字,容易讓後續維護者搞不清楚哪個才是正規稱呼。", - "suggestion": "保留一個主名稱即可;如果一定要兼容舊名稱,請加上明確的遷移註解,說明哪個是新名字、哪個只是過渡別名。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/llm.js:27", - "problem": "`cliArgs(provider, model, promptFile = null, prompt = null)` 這個簽名把不同 provider 的參數意義混在一起,光看位置很難分辨哪個值在某條分支才有用,讀起來像把三種節拍硬塞進同一小節。", - "suggestion": "改成物件參數或拆成各 provider 專用的組裝函式,讓每個欄位的用途能被名字直接說明,而不是靠呼叫順序猜測。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/preflight.js:103", - "problem": "`verifyLLM` 的 JSDoc 仍然只描述 `provider` 與 `error`,但實際回傳已經多了 `command` 和 `model`。註解和程式碼不同步,會讓人以為這段函式比實際更單薄。", - "suggestion": "把 `@returns` 的型別與欄位補完整,讓文件精準反映目前回傳結構,避免讀者依舊沿用舊版心智模型。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/preflight.js:170", - "problem": "成功訊息仍寫成 `LLM provider=... CLI 可用`,語氣還停在 provider 中心,但整個流程已改成 CLI 中心,術語有些半新半舊,節奏不夠一致。", - "suggestion": "把成功訊息統一成 CLI 觀點,例如直接報 `command=...` 或改成更中性的「LLM CLI 可用」,讓文字系統保持同一把調性。", - "is_new": true - } -] +[] diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8b937d2..f3f59c3 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,7 +26,7 @@ jobs: id: release-tag-version uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} with: - is_beta: 'true' + is_beta: true # job 識別碼:ai-code-review ai-code-review: # job 顯示名稱 @@ -34,43 +34,22 @@ jobs: needs: release-tag-version # 指定執行環境的 runner 標籤:ubuntu runs-on: ubuntu - # 此 job 所需的權限設定 - permissions: - # 對 repository 內容的寫入權限(讀寫程式碼/檔案) - contents: write - # 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查) - pull-requests: write - # 對 issues 的寫入權限(建立/更新 issue 留言所需) - issues: write # job 的執行步驟 steps: - name: 取得程式碼 uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - - name: 安裝 AI 工具 - uses: https://gitea.jsc.idv.tw/composite-actions/codex@${{ vars.ACTION_CODEX_VERSION }} - with: - oauth: ${{ secrets.CODEX_OAUTH }} - - name: 檢查 AI 助理 CLI - shell: bash - run: | - set -e - tools="$(node --input-type=module -e "import { getLLMCLICommands } from './app/config.js'; console.log(getLLMCLICommands().join(' '));")" - found="" - for tool in $tools; do - if command -v "$tool" >/dev/null 2>&1; then - found="${found} ${tool}" - "$tool" --version || true - fi - done - if [ -z "$found" ]; then - echo "找不到可用的 AI 助理 CLI(需要 ${tools} 其中之一)" >&2 - exit 1 - fi - echo "可用 AI 助理 CLI:${found}" - name: AI 程式碼審查 # 使用外部 composite action 執行審查邏輯。 # 版本由 release-tag-version 計算,便於在 PR 中測試目前 action 版本。 uses: https://gitea.jsc.idv.tw/docker-actions/ai-code-review@v${{ needs.release-tag-version.outputs.version }} + # 此 job 所需的權限設定 + permissions: + # 對 repository 內容的寫入權限(讀寫程式碼/檔案) + contents: write + # 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查) + pull-requests: write + # 對 issues 的寫入權限(建立/更新 issue 留言所需) + issues: write # 傳遞給 composite action 的輸入參數 with: # 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言 diff --git a/Dockerfile b/Dockerfile index 911b6b9..4485671 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,14 @@ -# ============================================================================= -# 用途:建置「AI 程式碼審查」Docker action 映像檔。 -# 以 Alpine Linux 為基底,安裝 bash / git / Node.js / npm 等執行環境, -# 將 app/ 程式碼與相依套件打包進映像,並透過 entrypoint.sh 作為容器進入點, -# 供 CI(Gitea Actions)以 Docker action 形式執行 AI code review 流程。 -# 更新日期:2026/06/26 11:34:46 -# ============================================================================= +FROM gitea.jsc.idv.tw/images/codex:0.1.2 -# 指定支援中的 Alpine Linux 固定版本;Alpine 體積小,可縮小最終映像大小並加快拉取速度。 -# 釘選明確版本可避免 latest tag 隨時間變動,提升建置可重現性。 -FROM alpine:3.24.1 +RUN apt update \ + && apt install -y --no-install-recommends git \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* -# 安裝必要的工具 -# 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、 -# nodejs 與 npm(執行 app 內的 Node.js 程式)。 -# --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。 -RUN apk add --no-cache bash git nodejs npm - -# 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。 COPY ./app /app - -# 進入 /app 安裝 npm 相依套件,使 Node.js 程式可在容器內正常執行。 -# 副作用:會在 /app/node_modules 產生套件檔案,並依 package-lock.json(若存在)解析版本。 RUN cd /app && npm install -# 將容器進入點腳本 entrypoint.sh 複製到映像根目錄 /entrypoint.sh。 -COPY entrypoint.sh /entrypoint.sh - -# 賦予 entrypoint.sh 可執行權限,確保容器啟動時能直接執行該腳本。 +COPY ./entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -# 設定容器進入點為 /entrypoint.sh(exec 形式,不經過 shell 解析); -# 容器啟動時即執行此腳本,作為 Docker action 的實際入口。 ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yaml b/action.yaml index d8f86a7..f5876e4 100644 --- a/action.yaml +++ b/action.yaml @@ -4,34 +4,23 @@ author: 'Jeffery' inputs: token: description: '' - required: true + required: false comment_token: description: '' required: false model: - description: '' + description: '要使用的 AI 助理 CLI 模型識別字串' required: true runs: - using: 'composite' - steps: - - name: 安裝 Node.js 相依套件 - shell: bash - run: | - cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app" - npm ci - - name: 執行 AI 程式碼審查 - shell: bash - env: - GITEA_SERVER_URL: ${{ gitea.server_url }} - GITEA_REPOSITORY: ${{ gitea.repository }} - GITEA_TOKEN: ${{ inputs.token || gitea.token }} - GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || gitea.token }} - PR_NUMBER: ${{ gitea.event.pull_request.number }} - PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }} - PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }} - PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }} - MODEL: ${{ inputs.model }} - run: | - echo "🚀 AI Code Review Action 啟動" - cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app" - node main.js + using: 'docker' + image: 'Dockerfile' + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ inputs.token || gitea.token }} + GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || gitea.token }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }} + PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }} + MODEL: ${{ inputs.model }} diff --git a/app/config.js b/app/config.js index 4c8db14..d03191e 100644 --- a/app/config.js +++ b/app/config.js @@ -1,6 +1,8 @@ import https from 'https'; import { execFileSync } from 'child_process'; +// 本 action 會連接自架 Gitea / OpenCode,部署環境可能使用內部 CA 或自簽憑證。 +// 對外部服務請優先使用預設 TLS 驗證;需要內部服務相容時才使用 getInsecureHttpsAgent()。 process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; export const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; @@ -29,6 +31,7 @@ export function getInsecureHttpsAgent() { return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); } +// 過渡別名:既有呼叫端仍可用 OpenCode 語意名稱;新程式碼請直接使用 getInsecureHttpsAgent。 export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; const CLI_CANDIDATES = [ diff --git a/app/git.js b/app/git.js index ea8146e..d099ade 100644 --- a/app/git.js +++ b/app/git.js @@ -64,7 +64,6 @@ function withAskpass(workspace, fn, token = GITEA_TOKEN) { const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, - GIT_SSL_NO_VERIFY: 'true', GIT_USERNAME: 'x-token', GIT_TOKEN: token, }; diff --git a/app/llm.js b/app/llm.js index 0226b0a..cfde26e 100644 --- a/app/llm.js +++ b/app/llm.js @@ -23,7 +23,7 @@ function buildPrompt(systemPrompt, userContent) { ].join('\n'); } -function cliArgs(provider, model, promptFile = null, prompt = null) { +function cliArgs({ provider, model, promptFile = null, prompt = null }) { if (provider === 'codex') { return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-']; } @@ -53,7 +53,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { promptFile = join(tempDir, 'prompt.md'); await writeFile(promptFile, prompt); } - const args = cliArgs(provider, model, promptFile, prompt); + const args = cliArgs({ provider, model, promptFile, prompt }); const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024); const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000); try { diff --git a/app/preflight.js b/app/preflight.js index e707238..d5b3cc8 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -100,8 +100,11 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { * * 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI * 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度。 - * @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>} - * 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。 + * @returns {Promise< + * {ok: true, provider: string, command: string, model: string} | + * {ok: false, provider?: string, error: string} + * >} + * 通過時含 provider、command 與 model;未設定 provider 的失敗分支不含 provider 欄位。 * @remarks 設定來源為 config.js 的 getLLMConfig()。 */ export async function verifyLLM() { @@ -170,7 +173,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || ' error(`LLM 驗證失敗: ${llm.error}`); return false; } - ok(`LLM provider=${llm.provider} CLI 可用`); + ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`); result(true, '前置驗證通過'); return true; diff --git a/app/test/git.test.js b/app/test/git.test.js index 9676d06..bb37685 100644 --- a/app/test/git.test.js +++ b/app/test/git.test.js @@ -96,7 +96,7 @@ describe('commitAndPush', () => { for (const { args, opts } of networkCalls) { assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`); - assert.equal(opts.env.GIT_SSL_NO_VERIFY, 'true', `GIT_SSL_NO_VERIFY missing for git ${args[0]}`); + assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`); } }); @@ -267,7 +267,7 @@ describe('cloneRepo', () => { assert.ok(networkCalls.length > 0, 'expected at least one network git call'); for (const { args, opts } of networkCalls) { assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`); - assert.equal(opts.env.GIT_SSL_NO_VERIFY, 'true', `GIT_SSL_NO_VERIFY missing for git ${args[0]}`); + assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`); } }); @@ -310,7 +310,7 @@ describe('verifyRemoteAccess', () => { const lsRemote = calls.find(c => c.args[0] === 'ls-remote'); assert.ok(lsRemote, 'expected git ls-remote to run'); assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote'); - assert.equal(lsRemote.opts.env.GIT_SSL_NO_VERIFY, 'true'); + assert.equal(lsRemote.opts.env.GIT_SSL_NO_VERIFY, undefined); }); it('does not leak the token in ls-remote args', () => { diff --git a/app/test/usage.test.js b/app/test/usage.test.js index 6ce0c95..37f11d4 100644 --- a/app/test/usage.test.js +++ b/app/test/usage.test.js @@ -82,7 +82,7 @@ describe('fetchAccountQuota', () => { const get = async (url, opts) => { assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/); assert.equal(opts.headers.Authorization, 'Bearer sk-or-xxx'); - assert.equal(opts.httpsAgent.options.rejectUnauthorized, false); + assert.equal(opts.httpsAgent, undefined); return { data: { data: { usage: 12.4, limit: 100, limit_remaining: 87.6 } } }; }; const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); diff --git a/app/usage.js b/app/usage.js index 3e37821..93c53f7 100644 --- a/app/usage.js +++ b/app/usage.js @@ -1,5 +1,4 @@ import axios from 'axios'; -import { getInsecureHttpsAgent } from './config.js'; import { warn } from './log.js'; /** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */ @@ -177,7 +176,6 @@ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) { const resp = await get(`${stripSlash(baseURL)}/auth/key`, { headers: { Authorization: `Bearer ${apiKey}` }, timeout: 30000, - httpsAgent: getInsecureHttpsAgent(), }); const d = resp.data?.data || {}; const used = num(d.usage); diff --git a/package.json b/package.json deleted file mode 100644 index 2272d1b..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "ai-code-review", - "version": "1.0.0", - "description": "AI code review Gitea action", - "private": true, - "scripts": { - "test": "npm --prefix app test" - }, - "repository": { - "type": "git", - "url": "https://gitea.jsc.idv.tw/docker-actions/ai-code-review.git" - }, - "author": "Jeffery", - "license": "ISC" -}