From 40ebfe99a8b9abc1c923e341ec14640c343586b9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 15 Jun 2026 13:39:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=BD=AE=E9=A9=97=E8=AD=89?= =?UTF-8?q?=E7=B4=8D=E5=85=A5=20git=20push=20=E8=AA=8D=E8=AD=89=E6=AA=A2?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git push 走 askpass + HTTP 認證,與 Gitea REST API 是兩套機制,API token 有效不代表 push 能用(曾出現 askpass 無法執行、could not read Username 而 push 失敗)。新增 git.js verifyRemoteAccess() 以相同 askpass + remote URL 跑唯讀 git ls-remote,preflight 呼叫並在失敗時 exit 1,提前攔下設定問題。 新增 git.test.js 對 verifyRemoteAccess 的測試(成功、失敗不丟例外、token 不外洩、askpass 清理)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++- TODO.md | 7 ++++--- app/git.js | 18 ++++++++++++++++++ app/git.test.js | 46 +++++++++++++++++++++++++++++++++++++++++++++- app/main.js | 2 +- app/preflight.js | 10 +++++++++- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f95507d..dcda95a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - 必要環境變數齊全:`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`(缺一即失敗) - Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效) - 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷 + - git push 認證可用:用與第 8 點 commit/push 完全相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(例如 `could not read Username`)的問題。此路徑與上面的 REST API 不同,API token 有效不代表 git push 一定能用,故獨立驗證 - 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) @@ -31,7 +32,7 @@ 8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量 10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題 -11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea 與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js` 並由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半或發 comment 時才因 token / key 無效而中斷 +11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`(git push 驗證委派給 `app/git.js` 的 `verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷 # 使用說明 diff --git a/TODO.md b/TODO.md index 09239bb..17e570a 100644 --- a/TODO.md +++ b/TODO.md @@ -63,8 +63,9 @@ 1. 必要環境變數齊全:`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`(缺一即失敗)。 2. Gitea API 可連線且 `GITEA_TOKEN` 能讀取此 repo(`GET /api/v1/repos/{repo}`)。 3. 若有提供 `GITEA_COMMENT_TOKEN`,另外用它驗證可用(`GET /api/v1/user`)。 - 4. 已選定一個 LLM provider(`getLLMConfig().provider` 非 null)。 - 5. LLM API Key 至少一把通過驗證:送出最小請求確認認證可用,逗號分隔多把只要一把成功即可並逐把記錄成敗;Ollama 改為檢查 `OLLAMA_BASE_URL` 可連線。 + 4. git push 認證可用:用與階段八 commit/push 相同的 askpass + remote URL 機制跑唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(`could not read Username`)的問題;此檢查為 fatal,失敗即 `exit 1`。 + 5. 已選定一個 LLM provider(`getLLMConfig().provider` 非 null)。 + 6. LLM API Key 至少一把通過驗證:送出最小請求確認認證可用,逗號分隔多把只要一把成功即可並逐把記錄成敗;Ollama 改為檢查 `OLLAMA_BASE_URL` 可連線。 - 驗收:log 中能看到 `Step1.5`(或對等)前置驗證的每一項結果(成功/失敗),任一失敗時 log 指出是哪一項與錯誤訊息,且 workflow 狀態為失敗;全部通過時 log 出「前置驗證通過」後才進入後續流程;驗證邏輯由 `app/preflight.js` 提供並有單元測試覆蓋(成功、缺環境變數、Gitea token 無效、comment token 無效、所有 LLM key 失敗、Ollama base url 等情境)。 - 補充紀錄:前置驗證不應發布任何 PR comment,只做唯讀的認證/連線確認;LLM 驗證請用最小 payload,避免浪費 token。 -- 已驗收:`app/preflight.js` 提供 `checkRequiredEnv` / `verifyGiteaToken` / `verifyCommentToken` / `verifyLLM` / `runPreflight`,`main.js` 已在 Step1 之後、bot-check 之前呼叫 `runPreflight()`,未通過即印出原因並 `exit 1`;`app/preflight.test.js` 覆蓋上述情境,`node --test *.test.js` 全數通過。 +- 已驗收:`app/preflight.js` 提供 `checkRequiredEnv` / `verifyGiteaToken` / `verifyCommentToken` / `verifyLLM` / `runPreflight`,git push 認證驗證由 `app/git.js` 的 `verifyRemoteAccess`(`git ls-remote`)提供;`main.js` 已在 Step1 之後、bot-check 之前呼叫 `runPreflight(WORKSPACE)`,未通過即印出原因並 `exit 1`;`app/preflight.test.js` 與 `app/git.test.js` 覆蓋上述情境(含 git push 認證成功/失敗、token 不外洩、askpass 清理),`node --test *.test.js` 全數通過。 diff --git a/app/git.js b/app/git.js index ba95084..acaa58e 100644 --- a/app/git.js +++ b/app/git.js @@ -258,6 +258,24 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER); } +/** + * 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`, + * 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。 + * 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用, + * 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。 + */ +export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) { + const run = makeRunner(_spawnSync); + try { + return withAskpass(workspace, credEnv => { + run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv); + return { ok: true }; + }); + } catch (e) { + return { ok: false, error: e.message }; + } +} + /** * Clone PR head branch to workspace/repo (idempotent) */ diff --git a/app/git.test.js b/app/git.test.js index 6bd6dbb..0ebb723 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, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js'; +import { commitAndPush, cloneRepo, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js'; // --- helpers --- function makeTmpWorkspace() { @@ -334,3 +334,47 @@ describe('cloneRepo', () => { assert.equal(isBotAutoCommit(workspace, spawn), true); }); }); + +describe('verifyRemoteAccess', () => { + let workspace; + before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'git-lsremote-')); }); + after(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); + + it('runs git ls-remote with the askpass credential env and reports ok on success', () => { + const calls = []; + const spawn = (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return { status: 0, stdout: 'abc123\tHEAD', stderr: '', error: null }; + }; + const result = verifyRemoteAccess(workspace, spawn); + assert.deepEqual(result, { ok: true }); + 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'); + }); + + it('does not leak the token in ls-remote args', () => { + const calls = []; + const spawn = (cmd, args, opts) => { + calls.push({ args }); + return { status: 0, stdout: '', stderr: '', error: null }; + }; + verifyRemoteAccess(workspace, spawn); + for (const { args } of calls) { + assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`); + } + }); + + it('reports failure (not throw) when git ls-remote fails', () => { + const spawn = () => ({ status: 128, stdout: '', stderr: 'fatal: could not read Username', error: null }); + const result = verifyRemoteAccess(workspace, spawn); + assert.equal(result.ok, false); + assert.match(result.error, /could not read Username/); + }); + + it('cleans up the askpass script after running', () => { + verifyRemoteAccess(workspace, () => ({ status: 0, stdout: '', stderr: '', error: null })); + const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); + assert.equal(leftover.length, 0, 'askpass script was not cleaned up'); + }); +}); diff --git a/app/main.js b/app/main.js index 2cba174..b793148 100644 --- a/app/main.js +++ b/app/main.js @@ -17,7 +17,7 @@ async function main() { line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); - if (!(await runPreflight())) { + if (!(await runPreflight(WORKSPACE))) { error('前置驗證未通過,終止流程'); section('Pipeline 結束'); process.exit(1); diff --git a/app/preflight.js b/app/preflight.js index abe223d..62216ad 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -9,6 +9,7 @@ import { PR_NUMBER, getLLMConfig, } from './config.js'; +import { verifyRemoteAccess } from './git.js'; import { step, line, ok, error } from './log.js'; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; @@ -92,7 +93,7 @@ export async function verifyLLM() { * 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。 * 僅做唯讀的認證/連線確認,不發布任何 comment。 */ -export async function runPreflight() { +export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace') { step('Step1.5', '前置驗證(驗證相關設定)'); const env = checkRequiredEnv(); @@ -117,6 +118,13 @@ export async function runPreflight() { if (comment.skipped) line('未提供 GITEA_COMMENT_TOKEN,comment 將沿用 GITEA_TOKEN'); else ok('GITEA_COMMENT_TOKEN 可用'); + const remote = verifyRemoteAccess(workspace); + if (!remote.ok) { + error(`git push 認證/連線驗證失敗(ls-remote): ${remote.error}`); + return false; + } + ok('git remote 認證可用(ls-remote 成功)'); + const llm = await verifyLLM(); if (!llm.ok) { error(`LLM 驗證失敗: ${llm.error}`);