Compare commits

...

14 Commits

Author SHA1 Message Date
Jeffery 40ebfe99a8 feat: 前置驗證納入 git push 認證檢查
AI / Code Review (pull_request) Failing after 2m48s
AI / 計算版本號 (pull_request) Successful in 2s
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
Jeffery 69371eb993 feat: update GEMINI_API_KEY configuration for AI Code Review
AI / 計算版本號 (pull_request) Successful in 2s
AI / Code Review (pull_request) Failing after 2s
2026-06-15 10:44:19 +08:00
Jeffery 766f2ddf40 feat: 啟動時前置驗證所有驗證相關設定
AI / 計算版本號 (pull_request) Failing after 1s
AI / Code Review (pull_request) Has been skipped
新增 app/preflight.js,在 action 啟動(Step1 之後、其餘步驟之前)集中
檢查必要環境變數、GITEA_TOKEN 讀 repo、GITEA_COMMENT_TOKEN、以及 LLM
provider/API Key(多把只要一把通過即可,Ollama 改檢查 base URL 連線)。
任一項失敗即印出原因並 exit 1,避免分析到一半或發 comment 時才失敗。

main.js 在 Step1 後呼叫 runPreflight();新增 preflight.test.js 覆蓋
成功、缺環境變數、token 無效、所有 LLM key 失敗、Ollama 等情境。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:41:40 +08:00
Jeffery 1b34298d4b docs: 規劃 action 啟動前置驗證所有驗證相關設定
在 README 流程新增第 0 點與設計第 11 點,並在 TODO 新增階段十二,
說明 action 啟動時集中驗證 Gitea token、comment token 與 LLM API Key
是否可用,任一失敗即 exit 1。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:37:11 +08:00
jiantw83 9af09de0d3 Merge pull request 'feat: implement Git integration for automated repository instruction syncing and commit management' (#130) from feat/ai_merge into develop
Reviewed-on: #130
2026-05-21 03:56:41 +00:00
Jeffery fbff9b3a86 chore: initialize ai-review exclusion and findings configuration files 2026-05-21 11:52:18 +08:00
jiantw83 7a01b7e3f4 Merge pull request 'feat: 加入 Codex 的 Triage Findings 技能' (#129) from feat/codex into develop
Reviewed-on: #129
2026-05-21 03:36:41 +00:00
Jeffery 097b6fb721 feat: implement Git integration for automated repository instruction syncing and commit management 2026-05-21 11:36:11 +08:00
AI Review Bot adf37520cb chore: update ai-review findings [ai-review-bot][success] 2026-05-21 03:35:13 +00:00
Jeffery e99236b893 feat: implement git repository synchronization and automated commit functionality for AI review findings 2026-05-21 10:17:01 +08:00
Jeffery 43ebc81f1d feat: add triage-findings agent skill and documentation for issue resolution workflow 2026-05-21 09:34:47 +08:00
jiantw83 f55264bb18 Merge pull request 'feat: add SKILL.md for triage-findings documentation' (#127) from feat/amazon_q into develop
Reviewed-on: #127
2026-05-20 09:10:16 +00:00
Jeffery 0d4776888f feat: add SKILL.md for triage-findings documentation 2026-05-20 17:09:11 +08:00
15 changed files with 1118 additions and 400 deletions
+46
View File
@@ -0,0 +1,46 @@
---
name: triage-findings
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
---
# Triage Findings
## When To Use
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
It is also used when some findings are false positives and should be moved into the exclusions list.
## Workflow
1. Collect all findings into one list.
2. Merge duplicates into a single finding when they describe the same issue.
3. Sort the final list by severity:
- critical
- warning
- info
4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
## Resolution Flow
After the list is merged and ordered, resolve the remaining findings one by one.
1. Start from the highest severity item.
2. Identify the root cause in the relevant file or context.
3. Apply the smallest safe change that fixes the issue.
4. Add or update tests when behavior changes.
5. Re-check the issue after the change.
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
7. Continue until the list is either fixed or explicitly excluded.
## Output Rules
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
- Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
+41
View File
@@ -0,0 +1,41 @@
# Triage Findings
## When To Use
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
It is also used when some findings are false positives and should be moved into the exclusions list.
## Workflow
1. Collect all findings into one list.
2. Merge duplicates into a single finding when they describe the same issue.
3. Sort the final list by severity:
- critical
- warning
- info
4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
## Resolution Flow
After the list is merged and ordered, resolve the remaining findings one by one.
1. Start from the highest severity item.
2. Identify the root cause in the relevant file or context.
3. Apply the smallest safe change that fixes the issue.
4. Add or update tests when behavior changes.
5. Re-check the issue after the change.
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
7. Continue until the list is either fixed or explicitly excluded.
## Output Rules
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
- Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
+8 -1
View File
@@ -97,7 +97,7 @@
{ {
"role": "Leo", "role": "Leo",
"location": "app/llm.js", "location": "app/llm.js",
"suggestion": "Authorization 標頭已有 provider !== 'ollama' 判斷,不會無條件加入,已正確處理" "suggestion": "Authorization 標頭已有 provider !== \u0027ollama\u0027 判斷,不會無條件加入,已正確處理"
}, },
{ {
"role": "Zara", "role": "Zara",
@@ -344,5 +344,12 @@
"role": "Leo", "role": "Leo",
"location": "app/log.js", "location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。" "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
},
{
"level": "warning",
"role": "Leo",
"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` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。",
"is_new": true
} }
] ]
+2 -2
View File
@@ -31,8 +31,8 @@ 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 }},${{ 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_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 }}
permissions: permissions:
@@ -11,3 +11,6 @@ When the task is to triage review findings, follow this workflow:
7. Add or update tests when behavior changes. 7. Add or update tests when behavior changes.
8. Re-check the issue after each fix. 8. Re-check the issue after each fix.
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
Trigger it with `/triage-findings`.
+2
View File
@@ -12,9 +12,11 @@ RUN cd /action/app && npm install
COPY .amazonq/ /action/.amazonq/ COPY .amazonq/ /action/.amazonq/
COPY .codex/ /action/.codex/ COPY .codex/ /action/.codex/
COPY .agents/ /action/.agents/
COPY .claude/ /action/.claude/ COPY .claude/ /action/.claude/
COPY .gemini/ /action/.gemini/ COPY .gemini/ /action/.gemini/
COPY .github/ /action/.github/ COPY .github/ /action/.github/
COPY AGENTS.md /action/
COPY CLAUDE.md /action/ COPY CLAUDE.md /action/
COPY GEMINI.md /action/ COPY GEMINI.md /action/
+9 -2
View File
@@ -4,6 +4,12 @@
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過) # 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
0. 前置驗證(action 最開始執行、做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1`
- 必要環境變數齊全:`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 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案 3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
@@ -22,10 +28,11 @@
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行 4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
5. 將提示詞放到 ./app/prompts 內供程式讀取 5. 將提示詞放到 ./app/prompts 內供程式讀取
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.antigravity/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `ANTIGRAVITY.md``CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.agents/``.antigravity/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `AGENTS.md``ANTIGRAVITY.md``CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
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 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 / 認證無效而中斷
# 使用說明 # 使用說明
@@ -243,4 +250,4 @@ Antigravity:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
### 版本包含 ### 版本包含
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。 提交時一併包含 `triage-findings` skill 與各平台入口檔;其中 `AGENTS.md``ANTIGRAVITY.md``CLAUDE.md``GEMINI.md` 會在目標專案已存在時先做規則化合併,並在可用 LLM 時再用 AI 輔助檢查是否有遺失任何 skill、command 或規則;其餘同步檔則以來源覆蓋;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
+14 -2
View File
@@ -38,8 +38,8 @@
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確` - 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`
## 階段八:記憶區 commit/push 與錯誤處理 ## 階段八:記憶區 commit/push 與錯誤處理
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。 - 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;`AGENTS.md``ANTIGRAVITY.md``CLAUDE.md``GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。 - 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。 - 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
## 階段九:阻擋嚴重問題 PR(第 8 點) ## 階段九:阻擋嚴重問題 PR(第 8 點)
@@ -57,3 +57,15 @@
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestionAI 回傳後補回原始完整欄位(含 is_new)。 - 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestionAI 回傳後補回原始完整欄位(含 is_new)。
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。 - 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。 - 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
## 階段十二:啟動前置驗證所有驗證相關設定
- 目標:action 一開始(Step1 之後、其餘步驟之前)就集中檢查所有「驗證相關設定」是否可用,全部通過才繼續,任何一項失敗就印出明確原因並 `exit 1`。檢查項目:
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. 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`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` 全數通過。
+193 -26
View File
@@ -2,8 +2,8 @@ import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH, getLLMConfig } from './config.js';
import { line, ok, warn } from './log.js'; import { line, ok, warn, error } from './log.js';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
@@ -11,6 +11,7 @@ const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.gi
export const BOT_COMMIT_MARKER = '[ai-review-bot]'; export const BOT_COMMIT_MARKER = '[ai-review-bot]';
export const SYNC_PATHS = [ export const SYNC_PATHS = [
'.amazonq/rules/triage-findings.md', '.amazonq/rules/triage-findings.md',
'.agents/skills/triage-findings/SKILL.md',
'.antigravity/skills/triage-findings/SKILL.md', '.antigravity/skills/triage-findings/SKILL.md',
'.codex/skills/triage-findings/SKILL.md', '.codex/skills/triage-findings/SKILL.md',
'.codex/skills/triage-findings/agents/openai.yaml', '.codex/skills/triage-findings/agents/openai.yaml',
@@ -18,17 +19,27 @@ export const SYNC_PATHS = [
'.gemini/skills/triage-findings/SKILL.md', '.gemini/skills/triage-findings/SKILL.md',
'.github/copilot-instructions.md', '.github/copilot-instructions.md',
'.github/skills/triage-findings/SKILL.md', '.github/skills/triage-findings/SKILL.md',
'AGENTS.md',
'ANTIGRAVITY.md', 'ANTIGRAVITY.md',
'CLAUDE.md', 'CLAUDE.md',
'GEMINI.md', 'GEMINI.md',
]; ];
const FORCE_SYNC_FILE_PATHS = [ const FORCE_SYNC_FILE_PATHS = [
'.github/copilot-instructions.md', '.github/copilot-instructions.md',
'AGENTS.md',
'ANTIGRAVITY.md', 'ANTIGRAVITY.md',
'CLAUDE.md', 'CLAUDE.md',
'GEMINI.md', 'GEMINI.md',
]; ];
const MERGE_SYNC_FILE_PATHS = new Set([
'AGENTS.md',
'ANTIGRAVITY.md',
'CLAUDE.md',
'GEMINI.md',
]);
let instructionMergeAssistantPromise = null;
const SYNC_TREE_PATHS = [ const SYNC_TREE_PATHS = [
'.agents/skills/triage-findings',
'.antigravity/skills/triage-findings', '.antigravity/skills/triage-findings',
'.codex/skills/triage-findings', '.codex/skills/triage-findings',
'.claude/skills/triage-findings', '.claude/skills/triage-findings',
@@ -66,35 +77,169 @@ function readGitOutput(run, args, cwd, env) {
} }
} }
function copyTree(sourceRoot, repoDir, relDir) { function normalizeText(text) {
return text.replace(/\r\n/g, '\n');
}
function splitTextBlocks(text) {
const normalized = normalizeText(text).replace(/\n+$/, '');
if (!normalized) return [];
return normalized.split(/\n{2,}/).map(block => block.trimEnd()).filter(Boolean);
}
function mergeText(existingText, sourceText) {
const existing = normalizeText(existingText);
const source = normalizeText(sourceText);
if (existing === source) return existing;
const mergedBlocks = splitTextBlocks(existing);
const seenBlocks = new Set(mergedBlocks.map(block => block.trim()));
let changed = false;
for (const block of splitTextBlocks(source)) {
const key = block.trim();
if (seenBlocks.has(key)) continue;
seenBlocks.add(key);
mergedBlocks.push(block);
changed = true;
}
if (!changed) return existing;
return `${mergedBlocks.join('\n\n')}\n`;
}
function uniqueBlocksFromTexts(...texts) {
const seen = new Set();
const blocks = [];
for (const text of texts) {
for (const block of splitTextBlocks(text)) {
const key = block.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
blocks.push(block);
}
}
return blocks;
}
function validateMergedInstructionText(mergedText, requiredBlocks) {
const candidate = normalizeText(mergedText);
return requiredBlocks.every(block => candidate.includes(normalizeText(block).trim()));
}
class InstructionMergeError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'InstructionMergeError';
}
}
function abortInstructionMerge(message) {
error(message);
process.exit(1);
throw new InstructionMergeError(message);
}
function syncFileOverwrite(sourceRoot, repoDir, relPath) {
const src = path.join(sourceRoot, relPath);
if (!fs.existsSync(src)) return null;
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return relPath;
}
async function getInstructionMergeAssistant() {
const { provider } = getLLMConfig();
if (!provider) return null;
if (instructionMergeAssistantPromise) return instructionMergeAssistantPromise;
instructionMergeAssistantPromise = (async () => {
try {
const { chatJSON } = await import('./llm.js');
return async ({ relPath, existingText, sourceText, deterministicText }) => {
const systemPrompt = [
'You merge repository instruction files without losing any skill, command, or rule.',
'Never delete unique content from either input.',
'You may only remove exact duplicates or improve ordering/formatting.',
'Return JSON with a single field: merged_text.',
].join(' ');
const userContent = JSON.stringify({
path: relPath,
existing_text: existingText,
source_text: sourceText,
deterministic_candidate: deterministicText,
});
const result = await chatJSON(systemPrompt, userContent);
if (typeof result === 'string') return result;
if (result && typeof result.merged_text === 'string') return result.merged_text;
return null;
};
} catch (e) {
warn(`[merge] AI instruction merge unavailable: ${e.message}`);
return null;
}
})();
return instructionMergeAssistantPromise;
}
export async function mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant = null) {
const deterministic = mergeText(existingText, sourceText);
const requiredBlocks = uniqueBlocksFromTexts(existingText, sourceText);
if (!aiMergeAssistant || requiredBlocks.length === 0) return deterministic;
try {
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
}
abortInstructionMerge(`[merge] ${relPath} AI result rejected; refusing fallback`);
} catch (e) {
if (e instanceof InstructionMergeError) throw e;
abortInstructionMerge(`[merge] ${relPath} AI merge failed: ${e.message}`);
}
}
async function syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant = null) {
const src = path.join(sourceRoot, relPath);
if (!fs.existsSync(src)) return null;
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest);
return relPath;
}
const existingText = fs.readFileSync(dest, 'utf8');
const sourceText = fs.readFileSync(src, 'utf8');
const merged = await mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant);
if (merged !== existingText) {
fs.writeFileSync(dest, merged, 'utf8');
}
return relPath;
}
function syncTree(sourceRoot, repoDir, relDir) {
const srcDir = path.join(sourceRoot, relDir); const srcDir = path.join(sourceRoot, relDir);
if (!fs.existsSync(srcDir)) return []; if (!fs.existsSync(srcDir)) return [];
const copied = []; const copied = [];
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const relPath = path.join(relDir, entry.name); const relPath = path.join(relDir, entry.name);
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (entry.isDirectory()) { if (entry.isDirectory()) {
copied.push(...copyTree(sourceRoot, repoDir, relPath)); copied.push(...syncTree(sourceRoot, repoDir, relPath));
continue; continue;
} }
fs.mkdirSync(path.dirname(dest), { recursive: true }); const synced = syncFileOverwrite(sourceRoot, repoDir, relPath);
fs.copyFileSync(src, dest); if (synced) copied.push(synced);
copied.push(relPath);
} }
return copied; return copied;
} }
function copyFileOverwrite(sourceRoot, repoDir, relPath) {
const src = path.join(sourceRoot, relPath);
if (!fs.existsSync(src)) return null;
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return relPath;
}
export function getRepoState(repoDir, _spawnSync = spawnSync) { export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
@@ -113,6 +258,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)
*/ */
@@ -146,26 +309,30 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
} }
const existingSyncPaths = new Set(); const existingSyncPaths = new Set();
const aiMergeAssistant = await getInstructionMergeAssistant();
// Copy action skill trees into the target repo. Existing files are overwritten; // Copy action skill trees into the target repo. Existing files are merged with
// missing source files are ignored so we do not delete target repo content. // the action source; missing source files are ignored so we do not delete
// target repo content.
for (const relDir of SYNC_TREE_PATHS) { for (const relDir of SYNC_TREE_PATHS) {
for (const relPath of copyTree(sourceRoot, repoDir, relDir)) { for (const relPath of syncTree(sourceRoot, repoDir, relDir)) {
existingSyncPaths.add(relPath); existingSyncPaths.add(relPath);
} }
} }
// Force overwrite the direct instruction files first so the target repo always // Merge only the direct instruction files that must preserve repository-specific
// receives the action-owned versions even if the repo has drifted. // skills, commands, and rules. Everything else keeps the source copy.
for (const relPath of FORCE_SYNC_FILE_PATHS) { for (const relPath of FORCE_SYNC_FILE_PATHS) {
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath); const copied = MERGE_SYNC_FILE_PATHS.has(relPath)
? await syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant)
: syncFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied); if (copied) existingSyncPaths.add(copied);
} }
// Copy standalone action files into the target repo. Existing files are overwritten. // Merge standalone action files into the target repo.
for (const relPath of SYNC_PATHS) { for (const relPath of SYNC_PATHS) {
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue; if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath); const copied = syncFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied); if (copied) existingSyncPaths.add(copied);
} }
+108 -19
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 } from './git.js'; import { commitAndPush, cloneRepo, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
// --- helpers --- // --- helpers ---
function makeTmpWorkspace() { function makeTmpWorkspace() {
@@ -130,11 +130,13 @@ describe('commitAndPush', () => {
assert.ok(generatedAddCall, 'expected git add for generated review files'); assert.ok(generatedAddCall, 'expected git add for generated review files');
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
assert.ok(skillAddCall.args.includes('.agents/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.antigravity/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.antigravity/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md')); assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md')); assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
assert.ok(skillAddCall.args.includes('AGENTS.md'));
assert.ok(skillAddCall.args.includes('ANTIGRAVITY.md')); assert.ok(skillAddCall.args.includes('ANTIGRAVITY.md'));
assert.ok(skillAddCall.args.includes('CLAUDE.md')); assert.ok(skillAddCall.args.includes('CLAUDE.md'));
assert.ok(skillAddCall.args.includes('GEMINI.md')); assert.ok(skillAddCall.args.includes('GEMINI.md'));
@@ -157,36 +159,79 @@ describe('commitAndPush', () => {
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale'); assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
}); });
it('overwrites existing repo copies with workspace files', async () => { it('merges existing repo copies with workspace files', async () => {
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc');
fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'repo antigravity doc');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'repo claude doc');
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'repo gemini doc');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md'); const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8'), 'ANTIGRAVITY.md'); const antigravityDoc = fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md'); const claudeDoc = fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'), 'GEMINI.md'); const geminiDoc = fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
assert.ok(agentsDoc.includes('repo agents doc'));
assert.ok(agentsDoc.includes('AGENTS.md'));
assert.ok(antigravityDoc.includes('repo antigravity doc'));
assert.ok(antigravityDoc.includes('ANTIGRAVITY.md'));
assert.ok(claudeDoc.includes('repo claude doc'));
assert.ok(claudeDoc.includes('CLAUDE.md'));
assert.ok(geminiDoc.includes('repo gemini doc'));
assert.ok(geminiDoc.includes('GEMINI.md'));
assert.ok(agentsDoc.includes('repo agents doc'));
}); });
it('recursively overwrites skill tree files from the action source', async () => { it('accepts AI merged instruction text when all unique blocks are preserved', async () => {
const calls = [];
const aiMergeAssistant = async payload => {
calls.push(payload);
return ['repo block', 'source block', 'extra block'].join('\n\n');
};
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
assert.equal(calls.length, 1);
assert.ok(result.includes('repo block'));
assert.ok(result.includes('source block'));
assert.ok(result.includes('extra block'));
});
it('exits when AI output drops a block', async () => {
const originalExit = process.exit;
let exitCode = null;
process.exit = code => { exitCode = code; };
try {
const aiMergeAssistant = async () => 'source block only';
await assert.rejects(() => mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant));
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
it('overwrites non-merge sync files with workspace files', async () => {
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt'; const sourceSkillPath = path.join(sourceRoot, '.github/skills/triage-findings/SKILL.md');
const sourceNestedPath = path.join(sourceRoot, nestedRelPath); const repoSkillPath = path.join(repoDir, '.github/skills/triage-findings/SKILL.md');
const repoNestedPath = path.join(repoDir, nestedRelPath); const sourceNestedPath = path.join(sourceRoot, '.codex/skills/triage-findings/assets/example.txt');
const repoNestedPath = path.join(repoDir, '.codex/skills/triage-findings/assets/example.txt');
fs.writeFileSync(sourceSkillPath, 'fresh github skill');
fs.writeFileSync(repoSkillPath, 'stale github skill');
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true }); fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
fs.writeFileSync(sourceNestedPath, 'fresh'); fs.writeFileSync(sourceNestedPath, 'fresh nested');
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true }); fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
fs.writeFileSync(repoNestedPath, 'stale'); fs.writeFileSync(repoNestedPath, 'stale nested');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale copilot');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh'); assert.equal(fs.readFileSync(repoSkillPath, 'utf8'), 'fresh github skill');
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh nested');
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
}); });
it('does not throw when git command fails', async () => { it('does not throw when git command fails', async () => {
@@ -289,3 +334,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');
});
});
+2
View File
@@ -26,12 +26,14 @@ export async function getPRDiff() {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent }); const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
return filterDiff(resp.data, [ return filterDiff(resp.data, [
'.amazonq/', '.amazonq/',
'.agents/',
'.antigravity/', '.antigravity/',
'.claude/', '.claude/',
'.codex/', '.codex/',
'.gemini/', '.gemini/',
'.gitea/', '.gitea/',
'.github/', '.github/',
'AGENTS.md',
'ANTIGRAVITY.md', 'ANTIGRAVITY.md',
'CLAUDE.md', 'CLAUDE.md',
'GEMINI.md', 'GEMINI.md',
+2 -2
View File
@@ -119,8 +119,8 @@ describe('filterDiff', () => {
}); });
it('returns empty string when all blocks are excluded', () => { it('returns empty string when all blocks are excluded', () => {
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('CLAUDE.md'); const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('.agents/skills/triage-findings/SKILL.md');
const result = filterDiff(diff, ['.gitea/', 'CLAUDE.md']); const result = filterDiff(diff, ['.gitea/', '.agents/']);
assert.equal(result, ''); assert.equal(result, '');
}); });
+7
View File
@@ -6,6 +6,7 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { runPreflight } from './preflight.js';
import { section, step, line, ok, warn, error } from './log.js'; import { section, step, line, ok, warn, error } from './log.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
@@ -16,6 +17,12 @@ 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(WORKSPACE))) {
error('前置驗證未通過,終止流程');
section('Pipeline 結束');
process.exit(1);
}
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
const headMessage = await getCommitMessageBySha(headSha); const headMessage = await getCommitMessageBySha(headSha);
const headOutcome = getBotReviewOutcome(headMessage); const headOutcome = getBotReviewOutcome(headMessage);
+138
View File
@@ -0,0 +1,138 @@
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,
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;
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
function giteaErr(e) {
const status = e.response?.status;
return status ? `HTTP ${status} ${e.message}` : e.message;
}
/** 檢查必要環境變數是否齊全;可傳入覆寫值供測試使用 */
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
const missing = [];
if (!token) missing.push('GITEA_TOKEN');
if (!repo) missing.push('GITEA_REPOSITORY');
if (!pr) missing.push('PR_NUMBER');
return { ok: missing.length === 0, missing };
}
/** 用 GITEA_TOKEN 讀取此 repo,同時驗證 token 有效與有讀取權限 */
export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) {
try {
await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
return { ok: true };
} catch (e) {
return { ok: false, error: giteaErr(e) };
}
}
/** 若有提供 comment token,用它呼叫 /user 驗證可用;沒提供則略過 */
export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
if (!token) return { ok: true, skipped: true };
try {
await axios.get(api('/user'), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
return { ok: true };
} catch (e) {
return { ok: false, error: giteaErr(e) };
}
}
/**
* 驗證 LLM 設定可用:
* - 須已選定一個 provider
* - Ollama 檢查 base URL 是否可連線
* - 其餘 provider 以最小請求驗證認證,多把 Key 只要一把成功即可
*/
export async function verifyLLM() {
const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) return { ok: false, error: '未設定任何 LLM provider 或 API Key' };
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
const base = baseURL.replace(/\/$/, '');
if (provider === 'ollama') {
try {
await axios.get(`${base}/models`, { timeout: 30000 });
return { ok: true, provider };
} catch (e) {
return { ok: false, provider, error: `Ollama base URL 無法連線: ${e.message}` };
}
}
const headers = { 'Content-Type': 'application/json' };
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
for (let i = 0; i < apiKeys.length; i++) {
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
try {
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 });
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
} catch (e) {
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
}
}
return { ok: false, provider, error: `所有 ${apiKeys.length}${provider} API Key 驗證失敗` };
}
/**
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
* 僅做唯讀的認證/連線確認,不發布任何 comment。
*/
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace') {
step('Step1.5', '前置驗證(驗證相關設定)');
const env = checkRequiredEnv();
if (!env.ok) {
error(`缺少必要環境變數: ${env.missing.join(', ')}`);
return false;
}
ok('必要環境變數齊全 (GITEA_TOKEN, GITEA_REPOSITORY, PR_NUMBER)');
const gitea = await verifyGiteaToken();
if (!gitea.ok) {
error(`GITEA_TOKEN 驗證失敗(無法讀取 repo ${GITEA_REPOSITORY}: ${gitea.error}`);
return false;
}
ok(`GITEA_TOKEN 可讀取 repo ${GITEA_REPOSITORY}`);
const comment = await verifyCommentToken();
if (!comment.ok) {
error(`GITEA_COMMENT_TOKEN 驗證失敗: ${comment.error}`);
return false;
}
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKENcomment 將沿用 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}`);
return false;
}
if (llm.keyIndex) ok(`LLM provider=${llm.provider} 驗證通過(key ${llm.keyIndex}/${llm.total}`);
else ok(`LLM provider=${llm.provider} 連線正常`);
ok('前置驗證通過');
return true;
}
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import axios from 'axios';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from './preflight.js';
const LLM_ENV_KEYS = [
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
];
function clearLLMEnv() {
for (const k of LLM_ENV_KEYS) delete process.env[k];
}
afterEach(() => {
mock.restoreAll();
clearLLMEnv();
});
describe('checkRequiredEnv', () => {
it('reports all three missing when nothing provided', () => {
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER']);
});
it('reports only the missing ones', () => {
const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' });
assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_REPOSITORY']);
});
it('ok when all provided', () => {
const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' });
assert.equal(result.ok, true);
assert.deepEqual(result.missing, []);
});
});
describe('verifyGiteaToken', () => {
it('ok when repo endpoint returns successfully', async () => {
let capturedUrl, capturedOpts;
mock.method(axios, 'get', async (url, opts) => {
capturedUrl = url;
capturedOpts = opts;
return { data: { full_name: 'owner/repo' } };
});
const result = await verifyGiteaToken('tok', 'owner/repo');
assert.equal(result.ok, true);
assert.ok(capturedUrl.includes('/api/v1/repos/owner/repo'));
assert.equal(capturedOpts.headers['Authorization'], 'token tok');
});
it('fails with HTTP status when token is invalid', async () => {
mock.method(axios, 'get', async () => {
const e = new Error('Unauthorized');
e.response = { status: 401 };
throw e;
});
const result = await verifyGiteaToken('bad', 'owner/repo');
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/);
});
});
describe('verifyCommentToken', () => {
it('skips when no comment token provided', async () => {
const result = await verifyCommentToken('');
assert.deepEqual(result, { ok: true, skipped: true });
});
it('ok when /user returns successfully', async () => {
let capturedUrl, capturedOpts;
mock.method(axios, 'get', async (url, opts) => {
capturedUrl = url;
capturedOpts = opts;
return { data: { login: 'bot' } };
});
const result = await verifyCommentToken('ctok');
assert.equal(result.ok, true);
assert.ok(capturedUrl.endsWith('/api/v1/user'));
assert.equal(capturedOpts.headers['Authorization'], 'token ctok');
});
it('fails when comment token is invalid', async () => {
mock.method(axios, 'get', async () => {
const e = new Error('Unauthorized');
e.response = { status: 401 };
throw e;
});
const result = await verifyCommentToken('bad');
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/);
});
});
describe('verifyLLM', () => {
it('fails when no provider/key configured', async () => {
clearLLMEnv();
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /未設定/);
});
it('ok when an OpenAI-compatible key authenticates', async () => {
clearLLMEnv();
process.env.OPENAI_API_KEY = 'k1,k2';
let capturedUrl, capturedPayload, capturedHeaders;
mock.method(axios, 'post', async (url, payload, opts) => {
capturedUrl = url;
capturedPayload = payload;
capturedHeaders = opts.headers;
return { data: { choices: [{ message: { content: 'ok' } }] } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.provider, 'openai');
assert.equal(result.keyIndex, 1);
assert.equal(result.total, 2);
assert.ok(capturedUrl.endsWith('/chat/completions'));
assert.equal(capturedPayload.max_tokens, 1);
assert.equal(capturedHeaders['Authorization'], 'Bearer k1');
});
it('tries the next key when the first one fails', async () => {
clearLLMEnv();
process.env.OPENAI_API_KEY = 'bad,good';
let calls = 0;
mock.method(axios, 'post', async (_url, _payload, opts) => {
calls += 1;
if (opts.headers['Authorization'] === 'Bearer bad') throw new Error('401');
return { data: { choices: [{ message: { content: 'ok' } }] } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.keyIndex, 2);
assert.equal(calls, 2);
});
it('fails when all keys fail', async () => {
clearLLMEnv();
process.env.OPENAI_API_KEY = 'k1,k2';
mock.method(axios, 'post', async () => { throw new Error('401'); });
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /所有 2 把 openai API Key 驗證失敗/);
});
it('sets anthropic-version header for claude', async () => {
clearLLMEnv();
process.env.CLAUDE_API_KEY = 'ck';
let capturedHeaders;
mock.method(axios, 'post', async (_url, _payload, opts) => {
capturedHeaders = opts.headers;
return { data: { choices: [{ message: { content: 'ok' } }] } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.provider, 'claude');
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
});
it('checks base URL connectivity for ollama (no key)', async () => {
clearLLMEnv();
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
let capturedUrl;
mock.method(axios, 'get', async (url) => {
capturedUrl = url;
return { data: { data: [] } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.provider, 'ollama');
assert.ok(capturedUrl.endsWith('/models'));
});
it('fails when ollama base URL is unreachable', async () => {
clearLLMEnv();
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
mock.method(axios, 'get', async () => { throw new Error('ECONNREFUSED'); });
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /無法連線/);
});
});
describe('runPreflight', () => {
it('returns false and stops early when required env is missing', async () => {
// Config constants default to empty in the test environment, so the
// required-env check fails before any network call is attempted.
const result = await runPreflight();
assert.equal(result, false);
});
});