Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f047b4473e | |||
| 8419e60848 | |||
| caebd2b112 | |||
| 7caf3d0490 | |||
| fce2cd3c45 | |||
| 33f1291a0f | |||
| cedcb04424 | |||
| 9d780788e9 | |||
| 7ba9a4e223 | |||
| 7339145641 | |||
| 40ebfe99a8 | |||
| 00f5bc7dae |
@@ -351,5 +351,53 @@
|
|||||||
"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 });`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Aria",
|
||||||
|
"location": "app/preflight.test.js:25",
|
||||||
|
"suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 app/preflight.js 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Aria",
|
||||||
|
"location": "app/preflight.test.js:1-4",
|
||||||
|
"suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Aria",
|
||||||
|
"location": "app/preflight.test.js:14",
|
||||||
|
"suggestion": "函數名稱 clearLLMEnv 雖然可理解,但可以更具描述性,例如 clearLlmEnvironmentVariables 或 resetLlmEnv。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
||||||
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
||||||
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
||||||
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Pull Request
|
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
@@ -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 / 認證無效而中斷
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
|
|||||||
@@ -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` 全數通過。
|
||||||
|
|||||||
+38
-6
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment } from './gitea.js';
|
import { postComment, postPullReviewComment } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
import { ok, line } from './log.js';
|
import { ok, line, warn } from './log.js';
|
||||||
|
|
||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
||||||
@@ -16,6 +16,26 @@ function buildTable(findings) {
|
|||||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。
|
||||||
|
* 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。
|
||||||
|
*/
|
||||||
|
export function parseLocation(location) {
|
||||||
|
if (typeof location !== 'string') return null;
|
||||||
|
const trimmed = location.trim();
|
||||||
|
if (trimmed.includes(',')) return null;
|
||||||
|
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { file: match[1], line: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行內 comment 內容:等級/審查員/建議 */
|
||||||
|
function inlineCommentBody(f) {
|
||||||
|
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寫入 findings.json。
|
* 寫入 findings.json。
|
||||||
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
||||||
@@ -61,17 +81,29 @@ export async function postNewNonCriticalComment(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每個新 critical 問題各發一個 comment
|
* 每個新 critical 問題各發一個 comment。
|
||||||
|
* 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議);
|
||||||
|
* 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。
|
||||||
*/
|
*/
|
||||||
export async function postNewCriticalComments(findings) {
|
export async function postNewCriticalComments(findings, deps = {}) {
|
||||||
|
const { postInline = postPullReviewComment, postIssue = postComment } = deps;
|
||||||
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
||||||
if (criticals.length === 0) {
|
if (criticals.length === 0) {
|
||||||
line('無新的嚴重問題,跳過');
|
line('無新的嚴重問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const f of criticals) {
|
for (const f of criticals) {
|
||||||
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
|
const loc = parseLocation(f.location);
|
||||||
await postComment(body);
|
if (loc) {
|
||||||
|
try {
|
||||||
|
await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) });
|
||||||
|
ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
warn(`行內 comment 發布失敗,改用一般 comment: [${f.role}] ${f.location} error=${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await postIssue(`## 🚨 嚴重問題\n\n${buildTable([f])}`);
|
||||||
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { saveFindings } from './comments.js';
|
import { saveFindings, parseLocation, postNewCriticalComments } from './comments.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
describe('saveFindings', () => {
|
describe('saveFindings', () => {
|
||||||
@@ -73,3 +73,81 @@ describe('saveFindings', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseLocation', () => {
|
||||||
|
it('parses file and single line', () => {
|
||||||
|
assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the start line for a line range', () => {
|
||||||
|
assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no line number', () => {
|
||||||
|
assert.equal(parseLocation('app/preflight.test.js'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when multiple files are listed', () => {
|
||||||
|
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-string input', () => {
|
||||||
|
assert.equal(parseLocation(undefined), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postNewCriticalComments', () => {
|
||||||
|
const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true };
|
||||||
|
|
||||||
|
it('posts an inline review comment annotating file/line with level/role/suggestion', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([critical], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 1);
|
||||||
|
assert.equal(issueCalls.length, 0);
|
||||||
|
assert.equal(inlineCalls[0].path, 'app/preflight.js');
|
||||||
|
assert.equal(inlineCalls[0].line, 19);
|
||||||
|
assert.match(inlineCalls[0].body, /等級/);
|
||||||
|
assert.match(inlineCalls[0].body, /審查員.*Rex/s);
|
||||||
|
assert.match(inlineCalls[0].body, /建議.*修這個/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a normal comment when the location has no line number', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 0);
|
||||||
|
assert.equal(issueCalls.length, 1);
|
||||||
|
assert.match(issueCalls[0], /嚴重問題/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a normal comment when the inline post fails', async () => {
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([critical], {
|
||||||
|
postInline: async () => { throw new Error('line not in diff'); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(issueCalls.length, 1);
|
||||||
|
assert.match(issueCalls[0], /嚴重問題/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only posts for new critical findings', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([
|
||||||
|
{ ...critical, is_new: false },
|
||||||
|
{ level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true },
|
||||||
|
], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 0);
|
||||||
|
assert.equal(issueCalls.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+32
-3
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -127,3 +127,22 @@ export async function postComment(body) {
|
|||||||
);
|
);
|
||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
|
||||||
|
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
||||||
|
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
|
||||||
|
*/
|
||||||
|
export async function postPullReviewComment({ path: filePath, line, body }) {
|
||||||
|
const resp = await axios.post(
|
||||||
|
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
|
||||||
|
{
|
||||||
|
commit_id: PR_HEAD_SHA || undefined,
|
||||||
|
event: 'COMMENT',
|
||||||
|
body: '',
|
||||||
|
comments: [{ path: filePath, body, new_position: line }],
|
||||||
|
},
|
||||||
|
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|||||||
+26
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
import { getPRDiff, filterDiff, postComment, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -57,6 +57,31 @@ describe('gitea', () => {
|
|||||||
await assert.rejects(() => postComment('test'), /api error/);
|
await assert.rejects(() => postComment('test'), /api error/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('postPullReviewComment posts an inline review comment to the pulls reviews API', async () => {
|
||||||
|
let capturedUrl, capturedBody, capturedOpts;
|
||||||
|
mock.method(axios, 'post', async (url, body, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedBody = body;
|
||||||
|
capturedOpts = opts;
|
||||||
|
return { data: { id: 7 } };
|
||||||
|
});
|
||||||
|
const result = await postPullReviewComment({ path: 'app/preflight.js', line: 19, body: 'inline body' });
|
||||||
|
assert.deepEqual(result, { id: 7 });
|
||||||
|
assert.ok(capturedUrl.includes('/api/v1/repos/'));
|
||||||
|
assert.ok(capturedUrl.endsWith('/reviews'));
|
||||||
|
assert.equal(capturedBody.event, 'COMMENT');
|
||||||
|
assert.equal(capturedBody.comments.length, 1);
|
||||||
|
assert.equal(capturedBody.comments[0].path, 'app/preflight.js');
|
||||||
|
assert.equal(capturedBody.comments[0].new_position, 19);
|
||||||
|
assert.equal(capturedBody.comments[0].body, 'inline body');
|
||||||
|
assert.ok(capturedOpts.headers['Authorization'].startsWith('token '));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('postPullReviewComment propagates axios errors', async () => {
|
||||||
|
mock.method(axios, 'post', async () => { throw new Error('not in diff'); });
|
||||||
|
await assert.rejects(() => postPullReviewComment({ path: 'a.js', line: 1, body: 'x' }), /not in diff/);
|
||||||
|
});
|
||||||
|
|
||||||
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
||||||
let capturedUrl;
|
let capturedUrl;
|
||||||
mock.method(axios, 'get', async (url) => {
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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_TOKEN,comment 將沿用 GITEA_TOKEN');
|
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKEN,comment 將沿用 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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user