Compare commits

..

31 Commits

Author SHA1 Message Date
jiantw83 07e38f9d45 Merge pull request 'feat: 前置驗證納入 git push 認證檢查' (#11) from develop into master
CD / 計算版本號 (push) Successful in 2s
CD / 發布專案 (push) Successful in 7s
Reviewed-on: #11
2026-06-16 06:23:51 +00:00
jiantw83 7caf3d0490 Merge pull request 'feat: 前置驗證納入 git push 認證檢查' (#10) from feat/preflight-auth-check into develop
Reviewed-on: #10
2026-06-16 06:20:09 +00:00
AI Review Bot fce2cd3c45 chore: update ai-review findings [ai-review-bot][success]
AI / 計算版本號 (pull_request) Successful in 3s
AI / Code Review (pull_request) Successful in 4s
2026-06-16 06:19:36 +00:00
Jeffery 33f1291a0f chore: triage preflight TLS finding 為誤報並寫入 exclusions
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Successful in 4m16s
Maya critical(app/preflight.js:107):verifyLLM 的 axios.post 未帶
httpsAgent,認為 GITEA_SKIP_TLS_VERIFY 未套用到 LLM 請求。

判定為誤報並移入 exclusions:
- GITEA_SKIP_TLS_VERIFY 為 Gitea 端(內網自簽憑證)專用設定,外部 LLM
  服務(Gemini/OpenAI/Claude)應維持 TLS 驗證,套用此 flag 屬安全降級
- 與既有 app/llm.js 排除一致(已刻意移除 rejectUnauthorized:false 還原
  TLS 驗證)

findings.json 清空(已排除)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:15:00 +08:00
AI Review Bot cedcb04424 chore: update ai-review findings [ai-review-bot][failure]
AI / 計算版本號 (pull_request) Successful in 3s
AI / Code Review (pull_request) Failing after 5s
2026-06-16 05:52:21 +00:00
Jeffery 9d780788e9 test: 補齊 runPreflight 測試並 triage preflight findings
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Failing after 1m42s
triage 6 筆 review findings:1 筆修正、5 筆移入 exclusions。

修正(Maya, warning):runPreflight 僅測過 env 缺失早退,缺成功路徑與
各失敗點覆蓋。將其驗證步驟改為可注入的 deps 參數(預設沿用原函式,
行為不變),並補上完整成功、comment 略過、各失敗點早停、workspace
傳遞共 8 個測試。

移入 exclusions(誤報,保留原文):
- Rex critical:GITEA_SKIP_TLS_VERIFY 為預設開啟驗證的 opt-in 設定,
  與既有 gitea.js 排除一致,非漏洞
- Leo warning:verifyLLM 內聚清楚,拆分屬主觀重構
- Zara warning:每把 key 30s timeout 為刻意的可靠性下限,僅失敗時累積
- Rex info:axios 錯誤訊息不含認證標頭/內容
- Aria info:預設參數引用 config 常數為刻意且利於測試的 pattern

findings.json 清空(全部已修正或排除)。app/ 測試 112 pass。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:49:30 +08:00
AI Review Bot 7ba9a4e223 chore: update ai-review findings [ai-review-bot][failure]
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Failing after 5s
2026-06-16 04:04:57 +00:00
Jeffery 7339145641 fix: withAskpass 等待非同步 callback 完成才清理 askpass 腳本
AI / 計算版本號 (pull_request) Successful in 2s
AI / Code Review (pull_request) Failing after 2m24s
commitAndPush 傳入 async callback,但 withAskpass 是同步 try/finally,
會在第一個 await(LLM 合併指令檔)時就刪除 .git-askpass.sh,導致後續
git push 因 GIT_ASKPASS 指向已刪除腳本而失敗(cannot exec .git-askpass.sh /
could not read Username)。前置驗證的 verifyRemoteAccess 用同步 callback
所以 ls-remote 通過,造成前置驗證過但 push 失敗的落差。

改為當 callback 回傳 thenable 時以 result.finally(cleanup) 延後清理,
同步 callback 維持立即清理與原樣回傳,不影響 verifyRemoteAccess / cloneRepo。

新增回歸測試斷言 git push 執行當下 askpass 腳本仍存在。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:40:58 +08:00
Jeffery 40ebfe99a8 feat: 前置驗證納入 git push 認證檢查
AI / 計算版本號 (pull_request) Successful in 2s
AI / Code Review (pull_request) Failing after 1m28s
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) <noreply@anthropic.com>
2026-06-15 13:39:04 +08:00
Jeffery 00f5bc7dae fix: update GITEA_COMMENT_TOKEN to use RUNNER_TOKEN for code review action
AI / 計算版本號 (pull_request) Successful in 3s
AI / Code Review (pull_request) Failing after 1m6s
2026-06-15 11:57:02 +08:00
jiantw83 49f190e944 Merge pull request 'feat: implement Git integration for automated repository instruction syncing and commit management' (#131) from develop into master
Reviewed-on: #131
2026-05-21 04:00:28 +00:00
jiantw83 72701dee0a Merge pull request 'feat: add SKILL.md for triage-findings documentation' (#128) from develop into master
Reviewed-on: #128
2026-05-20 09:11:03 +00:00
jiantw83 503e50a2d0 Merge pull request 'feat: 將 ANTIGRAVITY 加入程式與技能' (#126) from develop into master
Reviewed-on: #126
2026-05-20 02:56:21 +00:00
jiantw83 dddcc9031b Merge pull request 'develop' (#124) from develop into master
Reviewed-on: #124
2026-05-18 03:32:00 +00:00
jiantw83 ace50037ba Merge pull request 'feat: 優化AI排除問題與過濾' (#122) from develop into master
Reviewed-on: #122
2026-05-18 02:59:46 +00:00
jiantw83 76eaff7788 Merge pull request '版本 0.1.6' (#120) from develop into master
Reviewed-on: #120
2026-05-15 15:57:20 +00:00
jiantw83 6ac8512dbc Merge pull request 'fix: remove GITEA_TOKEN from AI Code Review step and ensure master branch is ignored in pull requestsfix: remove GITEA_TOKEN from AI Code Review step and ensure master branch is ignored in pull requests' (#116) from develop into master
Reviewed-on: #116
2026-05-15 09:56:51 +00:00
jiantw83 3b8e942e7f Merge pull request 'feat: enhance findings and exclusions handling with repo state logging' (#114) from develop into master
Reviewed-on: #114
2026-05-15 09:52:26 +00:00
jiantw83 051457b11b Merge pull request 'fix: clarify stage seven push failures' (#112) from develop into master
Reviewed-on: #112
2026-05-15 06:55:50 +00:00
jiantw83 92f1c6fe82 Merge pull request 'fix: support wrapped exclusions schema' (#111) from develop into master
Reviewed-on: #111
2026-05-15 06:46:28 +00:00
jiantw83 27df6894a4 Merge pull request 'fix: write findings to review dir' (#110) from develop into master
Reviewed-on: #110
2026-05-15 06:25:29 +00:00
jiantw83 1afd978059 Merge pull request 'fix: stage generated review files' (#109) from develop into master
Reviewed-on: #109
2026-05-15 05:53:55 +00:00
jiantw83 146faca7cb Merge pull request 'docs: preserve original text in exclusions' (#108) from develop into master
Reviewed-on: #108
2026-05-15 04:51:23 +00:00
jiantw83 4c99247566 Merge pull request 'fix: sync codex skill assets' (#107) from develop into master
Reviewed-on: #107
2026-05-15 04:24:32 +00:00
jiantw83 81cbb83340 Merge pull request 'fix: package triage skills into the action image' (#106) from develop into master
Reviewed-on: #106
2026-05-15 04:00:55 +00:00
jiantw83 3f65b72cf0 Merge pull request 'fix: restore triage skill files and keep sync non-destructive' (#104) from develop into master
Reviewed-on: #104
2026-05-15 03:34:26 +00:00
jiantw83 2eb94c8f74 Merge pull request 'feat: 解決階段七commit失敗的問題' (#102) from develop into master
Reviewed-on: #102
2026-05-15 03:18:55 +00:00
jiantw83 6354c0987c Merge pull request 'chore: refine stage 7 json validation' (#98) from develop into master
Reviewed-on: #98
2026-05-14 02:42:13 +00:00
jiantw83 7df34eb1d0 Merge pull request '版本 0.0.4' (#97) from develop into master
Reviewed-on: #97
2026-05-13 06:31:30 +00:00
jiantw83 ca5d54882f Merge pull request '版本 0.0.2' (#94) from develop into master
Reviewed-on: #94
2026-05-13 02:43:10 +00:00
jiantw83 ca4664e0cc Merge pull request '發布 0.0.1' (#86) from develop into master
Reviewed-on: #86
2026-05-12 10:09:32 +00:00
10 changed files with 250 additions and 16 deletions
+30
View File
@@ -351,5 +351,35 @@
"location": "Dockerfile, app/git.js, app/gitea.js", "location": "Dockerfile, app/git.js, app/gitea.js",
"suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。", "suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。",
"is_new": true "is_new": true
},
{
"role": "Rex",
"location": "app/preflight.js:12",
"suggestion": "程式碼中根據 `GITEA_SKIP_TLS_VERIFY` 環境變數來禁用 TLS 憑證驗證 (`rejectUnauthorized: false`),這會使應用程式容易受到中間人 (Man-in-the-Middle, MITM) 攻擊。攻擊者可能在不被察覺的情況下攔截和修改與 Gitea 伺服器的通訊。建議移除此功能,或確保在任何生產環境中永不啟用。如果 Gitea 伺服器使用自簽憑證,應將其憑證加入信任儲存區,而非禁用驗證。"
},
{
"role": "Leo",
"location": "app/preflight.js:56",
"suggestion": "函式 `verifyLLM` 處理了多種 LLM 供應商的驗證邏輯(Ollama、Claude、OpenAI 相容等),導致其長度較長且複雜度較高。建議將不同供應商的驗證邏輯拆分成獨立的輔助函式(例如 `_verifyOllama`、`_verifyOpenAICompatible`),以提高模組化程度和可讀性。"
},
{
"role": "Zara",
"location": "app/preflight.js:70-82",
"suggestion": "在 `verifyLLM` 函式中,當配置了多個 LLM API Key 時,系統會依序嘗試驗證每個 Key,每個嘗試都有 30 秒的逾時時間。如果前幾個 Key 驗證失敗,這可能導致顯著的累積延遲。雖然這是為了找到一個可用的 Key,但若 Key 數量多且網路不穩定,可能會造成啟動時間過長。可以考慮縮短單次 Key 驗證的逾時時間,或在特定情況下提供更快的失敗機制。"
},
{
"role": "Rex",
"location": "app/preflight.js:100",
"suggestion": "在記錄 LLM API 驗證失敗時,直接輸出了錯誤訊息 `e.message`。雖然通常情況下 `e.message` 不會包含敏感資訊,但為了最佳安全實踐,建議審查 LLM 服務提供商的錯誤訊息格式,確保其中不會意外洩漏 API 金鑰或其他敏感請求內容。若有疑慮,應對錯誤訊息進行消毒或僅記錄高層次的錯誤類型。"
},
{
"role": "Aria",
"location": "app/preflight.js:30",
"suggestion": "在 `checkRequiredEnv`、`verifyGiteaToken` 和 `verifyCommentToken` 等函式中,預設參數直接引用了從 `config.js` 匯入的常數。雖然這在功能上可行,但為了提高程式碼的清晰度和一致性,建議考慮以下兩種方式之一:1. 將所有配置值作為明確的參數從呼叫端傳入。2. 讓函式直接從 `config.js` 模組中讀取這些值,而不是透過預設參數。"
},
{
"role": "Maya",
"location": "app/preflight.js:107",
"suggestion": "在 `verifyLLM` 函數中,呼叫 `axios.post` 時缺少 `httpsAgent` 選項。這會導致即使設定了 `GITEA_SKIP_TLS_VERIFY`LLM 的 API 請求仍可能因 TLS 憑證問題而失敗。請將 `httpsAgent` 傳遞給 `axios.post` 的選項物件,例如:`await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000, httpsAgent });`"
} }
] ]
+37 -1
View File
@@ -1 +1,37 @@
[] [
{
"level": "warning",
"role": "Aria",
"location": "app/preflight.test.js:25",
"suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 `app/preflight.js` 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "app/preflight.test.js:1-4",
"suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "app/preflight.test.js:7-12",
"suggestion": "此陣列字面量較長。雖然已分行,但可以考慮將每個元素獨立一行並保持一致的縮排,以提高可讀性。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "app/preflight.test.js:14",
"suggestion": "函數名稱 `clearLLMEnv` 雖然可理解,但可以更具描述性,例如 `clearLlmEnvironmentVariables` 或 `resetLlmEnv`。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "app/preflight.test.js:149",
"suggestion": "此單行註解風格與其他部分可能不一致。建議遵循專案統一的註解風格指南。",
"is_new": true
}
]
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_COMMENT_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_1_1 }},${{ secrets.GEMINI_API_KEY_1_2 }},${{ secrets.GEMINI_API_KEY_1_3 }},${{ secrets.GEMINI_API_KEY_1_4 }},${{ secrets.GEMINI_API_KEY_1_5 }},${{ secrets.GEMINI_API_KEY_1_6 }},${{ secrets.GEMINI_API_KEY_1_7 }},${{ secrets.GEMINI_API_KEY_1_8 }},${{ secrets.GEMINI_API_KEY_1_9 }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_1_1 }},${{ secrets.GEMINI_API_KEY_1_2 }},${{ secrets.GEMINI_API_KEY_1_3 }},${{ secrets.GEMINI_API_KEY_1_4 }},${{ secrets.GEMINI_API_KEY_1_5 }},${{ secrets.GEMINI_API_KEY_1_6 }},${{ secrets.GEMINI_API_KEY_1_7 }},${{ secrets.GEMINI_API_KEY_1_8 }},${{ secrets.GEMINI_API_KEY_1_9 }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
+2 -1
View File
@@ -8,6 +8,7 @@
- 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗) - 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗)
- Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效) - Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效)
- 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷 - 若有提供 `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` 可連線 - 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
@@ -31,7 +32,7 @@
8. 階段七驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 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 用量 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題 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 / 認證無效而中斷
# 使用說明 # 使用說明
+4 -3
View File
@@ -63,8 +63,9 @@
1. 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗)。 1. 必要環境變數齊全:`GITEA_TOKEN``GITEA_REPOSITORY``PR_NUMBER`(缺一即失敗)。
2. Gitea API 可連線且 `GITEA_TOKEN` 能讀取此 repo`GET /api/v1/repos/{repo}`)。 2. Gitea API 可連線且 `GITEA_TOKEN` 能讀取此 repo`GET /api/v1/repos/{repo}`)。
3. 若有提供 `GITEA_COMMENT_TOKEN`,另外用它驗證可用(`GET /api/v1/user`)。 3. 若有提供 `GITEA_COMMENT_TOKEN`,另外用它驗證可用(`GET /api/v1/user`)。
4. 已選定一個 LLM provider`getLLMConfig().provider` 非 null 4. git push 認證可用:用與階段八 commit/push 相同的 askpass + remote URL 機制跑唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(`could not read Username`)的問題;此檢查為 fatal,失敗即 `exit 1`
5. LLM API Key 至少一把通過驗證:送出最小請求確認認證可用,逗號分隔多把只要一把成功即可並逐把記錄成敗;Ollama 改為檢查 `OLLAMA_BASE_URL` 可連線 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 等情境)。 - 驗收:log 中能看到 `Step1.5`(或對等)前置驗證的每一項結果(成功/失敗),任一失敗時 log 指出是哪一項與錯誤訊息,且 workflow 狀態為失敗;全部通過時 log 出「前置驗證通過」後才進入後續流程;驗證邏輯由 `app/preflight.js` 提供並有單元測試覆蓋(成功、缺環境變數、Gitea token 無效、comment token 無效、所有 LLM key 失敗、Ollama base url 等情境)。
- 補充紀錄:前置驗證不應發布任何 PR comment,只做唯讀的認證/連線確認;LLM 驗證請用最小 payload,避免浪費 token。 - 補充紀錄:前置驗證不應發布任何 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` 全數通過。
+32 -3
View File
@@ -62,11 +62,22 @@ function withAskpass(workspace, fn) {
const askpassScript = path.join(workspace, '.git-askpass.sh'); const askpassScript = path.join(workspace, '.git-askpass.sh');
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 }); fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN }; const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
let result;
try { try {
return fn(credEnv); result = fn(credEnv);
} finally { } catch (e) {
try { fs.unlinkSync(askpassScript); } catch {} cleanup();
throw e;
} }
// Defer cleanup until an async callback settles, otherwise the askpass script
// is deleted at the first `await` and later network ops (e.g. git push) fail
// with "cannot exec .git-askpass.sh". Sync callbacks clean up immediately.
if (result && typeof result.then === 'function') {
return result.finally(cleanup);
}
cleanup();
return result;
} }
function readGitOutput(run, args, cwd, env) { function readGitOutput(run, args, cwd, env) {
@@ -258,6 +269,24 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER); 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) * Clone PR head branch to workspace/repo (idempotent)
*/ */
+57 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { commitAndPush, cloneRepo, SYNC_PATHS, 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 --- // --- helpers ---
function makeTmpWorkspace() { function makeTmpWorkspace() {
@@ -93,6 +93,18 @@ describe('commitAndPush', () => {
} }
}); });
it('keeps the askpass script present while the network push runs', async () => {
let askpassExistsAtPush = null;
const spawn = makeSpawn({
push: (_args, opts) => {
askpassExistsAtPush = !!(opts?.env?.GIT_ASKPASS && fs.existsSync(opts.env.GIT_ASKPASS));
return { status: 0, stdout: '', stderr: '', error: null };
},
});
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
assert.equal(askpassExistsAtPush, true, 'askpass script must still exist when git push runs');
});
it('cleans up askpass script after successful run', async () => { it('cleans up askpass script after successful run', async () => {
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot); await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
@@ -334,3 +346,47 @@ describe('cloneRepo', () => {
assert.equal(isBotAutoCommit(workspace, spawn), true); 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');
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ async function main() {
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
if (!(await runPreflight())) { if (!(await runPreflight(WORKSPACE))) {
error('前置驗證未通過,終止流程'); error('前置驗證未通過,終止流程');
section('Pipeline 結束'); section('Pipeline 結束');
process.exit(1); process.exit(1);
+20 -5
View File
@@ -9,6 +9,7 @@ import {
PR_NUMBER, PR_NUMBER,
getLLMConfig, getLLMConfig,
} from './config.js'; } from './config.js';
import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error } from './log.js'; import { step, line, ok, error } from './log.js';
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
@@ -92,24 +93,31 @@ export async function verifyLLM() {
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。 * 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
* 僅做唯讀的認證/連線確認,不發布任何 comment。 * 僅做唯讀的認證/連線確認,不發布任何 comment。
*/ */
export async function runPreflight() { export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
const {
checkEnv = checkRequiredEnv,
verifyToken = verifyGiteaToken,
verifyComment = verifyCommentToken,
verifyRemote = verifyRemoteAccess,
verifyLLMFn = verifyLLM,
} = deps;
step('Step1.5', '前置驗證(驗證相關設定)'); step('Step1.5', '前置驗證(驗證相關設定)');
const env = checkRequiredEnv(); const env = checkEnv();
if (!env.ok) { if (!env.ok) {
error(`缺少必要環境變數: ${env.missing.join(', ')}`); error(`缺少必要環境變數: ${env.missing.join(', ')}`);
return false; return false;
} }
ok('必要環境變數齊全 (GITEA_TOKEN, GITEA_REPOSITORY, PR_NUMBER)'); ok('必要環境變數齊全 (GITEA_TOKEN, GITEA_REPOSITORY, PR_NUMBER)');
const gitea = await verifyGiteaToken(); const gitea = await verifyToken();
if (!gitea.ok) { if (!gitea.ok) {
error(`GITEA_TOKEN 驗證失敗(無法讀取 repo ${GITEA_REPOSITORY}: ${gitea.error}`); error(`GITEA_TOKEN 驗證失敗(無法讀取 repo ${GITEA_REPOSITORY}: ${gitea.error}`);
return false; return false;
} }
ok(`GITEA_TOKEN 可讀取 repo ${GITEA_REPOSITORY}`); ok(`GITEA_TOKEN 可讀取 repo ${GITEA_REPOSITORY}`);
const comment = await verifyCommentToken(); const comment = await verifyComment();
if (!comment.ok) { if (!comment.ok) {
error(`GITEA_COMMENT_TOKEN 驗證失敗: ${comment.error}`); error(`GITEA_COMMENT_TOKEN 驗證失敗: ${comment.error}`);
return false; return false;
@@ -117,7 +125,14 @@ export async function runPreflight() {
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKENcomment 將沿用 GITEA_TOKEN'); if (comment.skipped) line('未提供 GITEA_COMMENT_TOKENcomment 將沿用 GITEA_TOKEN');
else ok('GITEA_COMMENT_TOKEN 可用'); else ok('GITEA_COMMENT_TOKEN 可用');
const llm = await verifyLLM(); const remote = verifyRemote(workspace);
if (!remote.ok) {
error(`git push 認證/連線驗證失敗(ls-remote: ${remote.error}`);
return false;
}
ok('git remote 認證可用(ls-remote 成功)');
const llm = await verifyLLMFn();
if (!llm.ok) { if (!llm.ok) {
error(`LLM 驗證失敗: ${llm.error}`); error(`LLM 驗證失敗: ${llm.error}`);
return false; return false;
+66
View File
@@ -188,10 +188,76 @@ describe('verifyLLM', () => {
}); });
describe('runPreflight', () => { describe('runPreflight', () => {
// Stub deps that all succeed; individual tests override one to fail.
function makeDeps(overrides = {}) {
return {
checkEnv: () => ({ ok: true, missing: [] }),
verifyToken: async () => ({ ok: true }),
verifyComment: async () => ({ ok: true }),
verifyRemote: () => ({ ok: true }),
verifyLLMFn: async () => ({ ok: true, provider: 'openai', keyIndex: 1, total: 1 }),
...overrides,
};
}
it('returns false and stops early when required env is missing', async () => { it('returns false and stops early when required env is missing', async () => {
// Config constants default to empty in the test environment, so the // Config constants default to empty in the test environment, so the
// required-env check fails before any network call is attempted. // required-env check fails before any network call is attempted.
const result = await runPreflight(); const result = await runPreflight();
assert.equal(result, false); assert.equal(result, false);
}); });
it('returns true when every verification step succeeds', async () => {
const result = await runPreflight('/ws', makeDeps());
assert.equal(result, true);
});
it('returns true when the comment token check is skipped', async () => {
const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: true, skipped: true }),
}));
assert.equal(result, true);
});
it('returns false when the Gitea token check fails', async () => {
let remoteCalled = false;
const result = await runPreflight('/ws', makeDeps({
verifyToken: async () => ({ ok: false, error: 'HTTP 401' }),
verifyRemote: () => { remoteCalled = true; return { ok: true }; },
}));
assert.equal(result, false);
assert.equal(remoteCalled, false, 'should stop before later checks');
});
it('returns false when the comment token check fails', async () => {
const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: false, error: 'HTTP 401' }),
}));
assert.equal(result, false);
});
it('returns false when git remote access fails', async () => {
let llmCalled = false;
const result = await runPreflight('/ws', makeDeps({
verifyRemote: () => ({ ok: false, error: 'auth failed' }),
verifyLLMFn: async () => { llmCalled = true; return { ok: true }; },
}));
assert.equal(result, false);
assert.equal(llmCalled, false, 'should stop before the LLM check');
});
it('returns false when LLM verification fails', async () => {
const result = await runPreflight('/ws', makeDeps({
verifyLLMFn: async () => ({ ok: false, error: '所有 key 驗證失敗' }),
}));
assert.equal(result, false);
});
it('passes the workspace through to the remote-access check', async () => {
let captured;
await runPreflight('/custom/ws', makeDeps({
verifyRemote: (ws) => { captured = ws; return { ok: true }; },
}));
assert.equal(captured, '/custom/ws');
});
}); });