Compare commits
16 Commits
v0.0.9
...
v0.1.6-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f3a9beba | |||
| 09b7be2c40 | |||
| 647460ea87 | |||
| 9fe85c9f72 | |||
| ca9845af1d | |||
| 2061fadba9 | |||
| eccdfd0a3a | |||
| bf6c791a82 | |||
| 222de4b369 | |||
| 8bf791a829 | |||
| c88c0d02c8 | |||
| f43ba63f0f | |||
| 4a29c4aaa3 | |||
| 78ec8f6d6a | |||
| 5c5773e4fd | |||
| ece7377fc8 |
@@ -1 +1,30 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"level": "critical",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "action.yaml:6, action.yaml:81",
|
||||||
|
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true` 且移除了 `secrets.GITEA_TOKEN` 的 fallback 機制,這是一個關鍵性的行為變更。請務必新增整合測試 (integration tests) 來驗證以下情境:\n1. 當 `inputs.GITEA_TOKEN` 未提供時,Action 應如預期般失敗。\n2. 當 `inputs.GITEA_TOKEN` 有提供時,Action 應能正常執行。\n這將確保新的輸入要求和邏輯變更不會導致意外的行為或破壞現有工作流程。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "action.yaml:5",
|
||||||
|
"suggestion": "輸入 `GITEA_TOKEN` 的註解 `Gitea 相關(可從 gitea context 自動取得)` 已不再準確。由於 `GITEA_TOKEN` 現在是 `required: true` 且不再從 `secrets.GITEA_TOKEN` 取得,建議更新此註解以明確指出此 Token 必須透過 `inputs` 提供。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "action.yaml:80",
|
||||||
|
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Rex",
|
||||||
|
"location": "action.yaml:83",
|
||||||
|
"suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。此變更移除了從 `secrets.GITEA_TOKEN` 安全取得 Token 的備用機制。雖然 `inputs.GITEA_TOKEN` 可以透過 `secrets` 上下文安全傳遞(例如:`with: GITEA_TOKEN: ${{ secrets.MY_GITEA_TOKEN }}`),但若使用者不慎直接將敏感 Token 字串作為 `inputs.GITEA_TOKEN` 的值傳入,該 Token 將可能被記錄在日誌中,導致敏感資訊洩漏。保留備用機制可提供更強健的安全性,降低因使用者操作失誤而導致的風險。",
|
||||||
|
"is_new": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
name: AI
|
name: AI
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- master
|
||||||
types: [opened, synchronize]
|
types: [opened, synchronize]
|
||||||
jobs:
|
jobs:
|
||||||
|
detect-bot-commit:
|
||||||
|
name: 偵測自動提交
|
||||||
|
runs-on: ubuntu
|
||||||
|
outputs:
|
||||||
|
skip: ${{ steps.detect.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- name: 檢查 head commit marker
|
||||||
|
id: detect
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ github.api_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_SHA: ${{ github.sha }}
|
||||||
|
GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
commit_json="$(curl -fsSL -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/git/commits/${GITEA_SHA}")" || {
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if printf '%s' "$commit_json" | grep -q '\[ai-review-bot\]'; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "偵測到 AI Review Bot commit,跳過 review workflow"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
version:
|
version:
|
||||||
name: 計算版本號
|
name: 計算版本號
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
|
needs: [detect-bot-commit]
|
||||||
|
if: needs.detect-bot-commit.outputs.skip != 'true'
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
@@ -26,15 +52,17 @@ jobs:
|
|||||||
code-review:
|
code-review:
|
||||||
name: Code Review
|
name: Code Review
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
needs: [version]
|
needs: [detect-bot-commit, version]
|
||||||
|
if: needs.detect-bot-commit.outputs.skip != 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
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 }}
|
||||||
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 }},${{ 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_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:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。
|
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Push Request 中變更的內容後,將問題分級 Commnet 到 Push Request 中。
|
||||||
|
|
||||||
# 流程(新 Push Request、新 Commit (排除 AI 助理的 Commit) 觸發)
|
# 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
||||||
|
|
||||||
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
|
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
|
||||||
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
3. 讀取所有未解決的舊問題(問題檔案 `.gitea/ai-review/findings.json` 存在於使用此 Action 的專案固定位置)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
|
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
|
||||||
4. 讀取排除問題檔案(`.gitea/ai-review/exclusions.json` 存在於使用此 Action 的專案固定位置),用來過濾PR問題表格中不需要處理的問題
|
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾PR問題表格中不需要處理的問題
|
||||||
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
||||||
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
||||||
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
# 設計
|
# 設計
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@
|
|||||||
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
||||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||||
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `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 筆數,方便追查讀檔與分支內容不一致的問題
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml'
|
2. 在 `.gitea/workflows` 資料夾中建立 `ai-review.yaml'
|
||||||
3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用):
|
3. 在 `ai-review.yaml` 中填入以下內容(選擇一個使用):
|
||||||
|
|
||||||
|
> **自動提交排除說明**:此 Action 會將自己的 commit message 標記為 `[ai-review-bot]`。建議在 review workflow 的最前面先檢查 head commit 是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。
|
||||||
|
|
||||||
> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。
|
> **權限說明**:此 Action 需要 `contents: write`(寫入 findings.json)、`pull-requests: write`(發佈 PR comment)、`issues: write`(發佈 issue comment)三項權限,為正常運作所必要,無法縮減。
|
||||||
|
|
||||||
### 1. OpenAI
|
### 1. OpenAI
|
||||||
@@ -53,6 +56,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
OPENAI_BASE_URL: https://api.openai.com/v1
|
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||||
@@ -81,6 +85,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
|
OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
|
||||||
OPENAI_BASE_URL: https://openrouter.ai/api/v1
|
OPENAI_BASE_URL: https://openrouter.ai/api/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
||||||
@@ -109,6 +114,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
|
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
||||||
permissions:
|
permissions:
|
||||||
@@ -136,6 +142,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_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 }},${{ 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_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 }}
|
||||||
@@ -164,6 +171,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
|
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
AMAZONQ_BASE_URL: https://q.api.aws
|
AMAZONQ_BASE_URL: https://q.api.aws
|
||||||
permissions:
|
permissions:
|
||||||
@@ -190,10 +198,11 @@ jobs:
|
|||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
steps:
|
||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
||||||
|
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -227,4 +236,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|||||||
|
|
||||||
### 版本包含
|
### 版本包含
|
||||||
|
|
||||||
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
||||||
|
|||||||
+3
-3
@@ -5,7 +5,7 @@ inputs:
|
|||||||
# Gitea 相關(可從 gitea context 自動取得)
|
# Gitea 相關(可從 gitea context 自動取得)
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
description: 'Gitea API Token'
|
description: 'Gitea API Token'
|
||||||
required: false
|
required: true
|
||||||
GITEA_SERVER_URL:
|
GITEA_SERVER_URL:
|
||||||
description: 'Gitea Server URL'
|
description: 'Gitea Server URL'
|
||||||
required: false
|
required: false
|
||||||
@@ -80,8 +80,8 @@ runs:
|
|||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
env:
|
env:
|
||||||
# Gitea context(優先用 inputs,否則從 gitea context 取)
|
# Gitea context(改為只從 inputs 取得)
|
||||||
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }}
|
||||||
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
|
GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
|
GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }}
|
||||||
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
|
GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }}
|
||||||
|
|||||||
+12
-6
@@ -16,13 +16,19 @@ function buildTable(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寫入 findings.json 到 workspace
|
* 寫入 findings.json。
|
||||||
|
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
||||||
*/
|
*/
|
||||||
export function saveFindings(workspace, findings) {
|
export function saveFindings(workspace, findings, mirrorDir = null) {
|
||||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
const targets = [workspace];
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
||||||
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
|
||||||
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
for (const targetDir of targets) {
|
||||||
|
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
||||||
|
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { saveFindings } from './comments.js';
|
||||||
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
|
describe('saveFindings', () => {
|
||||||
|
const tempDirs = [];
|
||||||
|
const makeTempDir = prefix => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes findings to workspace and mirror dirs when provided', () => {
|
||||||
|
const workspace = makeTempDir('findings-ws-');
|
||||||
|
const mirrorDir = makeTempDir('findings-mirror-');
|
||||||
|
const findings = [{ level: 'warning', role: 'Leo', location: 'file.js:1', suggestion: 'test' }];
|
||||||
|
|
||||||
|
saveFindings(workspace, findings, mirrorDir);
|
||||||
|
|
||||||
|
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||||
|
const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
|
||||||
|
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
||||||
|
assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes only to workspace when mirrorDir is omitted', () => {
|
||||||
|
const workspace = makeTempDir('findings-ws-');
|
||||||
|
const findings = [{ level: 'info', role: 'Maya', location: 'file.js:2', suggestion: 'note' }];
|
||||||
|
|
||||||
|
saveFindings(workspace, findings);
|
||||||
|
|
||||||
|
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||||
|
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate writes when mirrorDir matches workspace', () => {
|
||||||
|
const workspace = makeTempDir('findings-same-');
|
||||||
|
const findings = [];
|
||||||
|
const writeCalls = [];
|
||||||
|
const originalWriteFileSync = fs.writeFileSync;
|
||||||
|
|
||||||
|
fs.writeFileSync = (...args) => {
|
||||||
|
writeCalls.push(args[0]);
|
||||||
|
return originalWriteFileSync(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveFindings(workspace, findings, workspace);
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync = originalWriteFileSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(writeCalls.length, 1);
|
||||||
|
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an empty JSON array when findings is empty', () => {
|
||||||
|
const workspace = makeTempDir('findings-empty-');
|
||||||
|
|
||||||
|
saveFindings(workspace, []);
|
||||||
|
|
||||||
|
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||||
|
assert.equal(workspaceText, '[]\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+53
-6
@@ -34,11 +34,30 @@ function readJSONArray(fullPath, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExclusions(data) {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileTime(mtimeMs) {
|
||||||
|
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
||||||
|
return new Date(mtimeMs).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH)
|
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
||||||
*/
|
*/
|
||||||
export function loadOldFindings(workspace) {
|
export function loadOldFindings(workspace) {
|
||||||
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false }));
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
|
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
console.log(` 讀取舊 findings 檔案: ${fullPath}`);
|
||||||
|
console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(` 舊 findings 檔案不存在: ${fullPath}`);
|
||||||
|
}
|
||||||
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
||||||
return old;
|
return old;
|
||||||
}
|
}
|
||||||
@@ -104,11 +123,39 @@ export async function deduplicateWithAI(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH)
|
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
||||||
*/
|
*/
|
||||||
export function loadExclusions(workspace) {
|
export function loadExclusions(workspace, repoState = null) {
|
||||||
const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題');
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
console.log(` 排除問題檔案不存在,視為空: ${fullPath}`);
|
||||||
|
if (repoState) {
|
||||||
|
const branch = repoState.branch || 'detached';
|
||||||
|
const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
|
||||||
|
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
|
||||||
|
}
|
||||||
|
console.log(' 讀取排除問題: raw=0 normalized=0 筆');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let exclusions = [];
|
||||||
|
let rawCount = 0;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0;
|
||||||
|
exclusions = normalizeExclusions(data);
|
||||||
|
const branch = repoState?.branch || 'detached';
|
||||||
|
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
|
||||||
|
const commitTime = repoState?.commitTime || 'unknown';
|
||||||
|
console.log(` 讀取排除問題檔案: ${fullPath}`);
|
||||||
|
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
|
||||||
|
console.log(` 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
||||||
|
exclusions = [];
|
||||||
|
}
|
||||||
|
console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`);
|
||||||
return exclusions;
|
return exclusions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js';
|
||||||
|
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
|
describe('findings exclusions', () => {
|
||||||
|
let workspace;
|
||||||
|
let logs;
|
||||||
|
let originalLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
|
||||||
|
logs = [];
|
||||||
|
originalLog = console.log;
|
||||||
|
console.log = (...args) => {
|
||||||
|
logs.push(args.join(' '));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalLog;
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads excluded_findings wrapper format', () => {
|
||||||
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify({
|
||||||
|
excluded_findings: [
|
||||||
|
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
|
||||||
|
],
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
const exclusions = loadExclusions(workspace);
|
||||||
|
|
||||||
|
assert.equal(exclusions.length, 1);
|
||||||
|
assert.equal(exclusions[0].location, 'entrypoint.sh:180');
|
||||||
|
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies exclusions loaded from wrapper format', () => {
|
||||||
|
const findings = [
|
||||||
|
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
|
||||||
|
{ location: 'README.md:12', role: 'Maya', suggestion: 'keep' },
|
||||||
|
];
|
||||||
|
const exclusions = [
|
||||||
|
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = applyExclusions(findings, exclusions);
|
||||||
|
|
||||||
|
assert.equal(filtered.length, 1);
|
||||||
|
assert.equal(filtered[0].location, 'README.md:12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs exclusions file metadata and repo state when loading exclusions', () => {
|
||||||
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify([
|
||||||
|
{ location: 'entrypoint.sh:180', suggestion: 'ignore' },
|
||||||
|
{ location: 'README.md:12', suggestion: 'ignore' },
|
||||||
|
], null, 2));
|
||||||
|
|
||||||
|
const repoState = {
|
||||||
|
branch: 'feat/test',
|
||||||
|
shortSha: 'abc1234',
|
||||||
|
commitTime: '2026-05-15T09:29:49.817Z',
|
||||||
|
repoDir: path.join(workspace, 'repo'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const exclusions = loadExclusions(workspace, repoState);
|
||||||
|
|
||||||
|
assert.equal(exclusions.length, 2);
|
||||||
|
assert.ok(logs.some(line => line.includes(`讀取排除問題檔案: ${fullPath}`)));
|
||||||
|
assert.ok(logs.some(line => line.includes('來源分支狀態: branch=feat/test commit=abc1234')));
|
||||||
|
assert.ok(logs.some(line => line.includes('raw=2 normalized=2')));
|
||||||
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs findings file metadata when loading old findings', () => {
|
||||||
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify([
|
||||||
|
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
||||||
|
], null, 2));
|
||||||
|
|
||||||
|
const findings = loadOldFindings(workspace);
|
||||||
|
|
||||||
|
assert.equal(findings.length, 1);
|
||||||
|
assert.equal(findings[0].is_new, false);
|
||||||
|
assert.ok(logs.some(line => line.includes(`讀取舊 findings 檔案: ${fullPath}`)));
|
||||||
|
assert.ok(logs.some(line => line.includes('舊 findings 檔案資訊: bytes=')));
|
||||||
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-4
@@ -5,9 +5,10 @@ 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 } from './config.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 remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
|
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||||||
export const SYNC_PATHS = [
|
export const SYNC_PATHS = [
|
||||||
FINDINGS_PATH,
|
|
||||||
'.amazonq/rules/triage-findings.md',
|
'.amazonq/rules/triage-findings.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',
|
||||||
@@ -41,6 +42,23 @@ function withAskpass(workspace, fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readGitOutput(run, args, cwd, env) {
|
||||||
|
try {
|
||||||
|
return run(args, cwd, env);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||||||
|
const run = makeRunner(_spawnSync);
|
||||||
|
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||||||
|
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
|
||||||
|
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
|
||||||
|
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
|
||||||
|
return { repoDir, branch, headSha, shortSha, commitTime };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* Clone PR head branch to workspace/repo (idempotent)
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +86,10 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
await withAskpass(workspace, async credEnv => {
|
await withAskpass(workspace, async credEnv => {
|
||||||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||||
|
if (PR_HEAD_BRANCH) {
|
||||||
|
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
|
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
||||||
|
}
|
||||||
|
|
||||||
const existingSyncPaths = [];
|
const existingSyncPaths = [];
|
||||||
|
|
||||||
@@ -86,6 +108,16 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
if (existingSyncPaths.length > 0) {
|
if (existingSyncPaths.length > 0) {
|
||||||
run(['add', ...existingSyncPaths], repoDir);
|
run(['add', ...existingSyncPaths], repoDir);
|
||||||
}
|
}
|
||||||
|
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||||||
|
if (generatedSyncPaths.length > 0) {
|
||||||
|
for (const relPath of generatedSyncPaths) {
|
||||||
|
const src = path.join(workspace, relPath);
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
run(['add', ...generatedSyncPaths], repoDir);
|
||||||
|
}
|
||||||
|
|
||||||
const status = run(['status', '--porcelain'], repoDir);
|
const status = run(['status', '--porcelain'], repoDir);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
@@ -93,10 +125,14 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
|
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}`], repoDir);
|
||||||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||||||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
try {
|
||||||
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
|
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
||||||
|
} catch (pushErr) {
|
||||||
|
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
||||||
|
|||||||
+60
-14
@@ -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 } from './git.js';
|
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -60,6 +60,15 @@ describe('commitAndPush', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tags auto commits with the bot marker for workflow filtering', async () => {
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
|
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
|
||||||
|
assert.ok(commitCall, 'expected git commit to run');
|
||||||
|
assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
@@ -94,20 +103,31 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds skill and entry files together with findings', async () => {
|
it('adds skill and entry files together with findings', async () => {
|
||||||
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
|
||||||
|
fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
const addCall = spawn.calls.find(c => c.args[0] === 'add');
|
const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
|
||||||
assert.ok(addCall, 'expected git add to run');
|
const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md'));
|
const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
|
||||||
assert.ok(addCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall, 'expected git add for synced skill files');
|
||||||
assert.ok(addCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
assert.ok(generatedAddCall, 'expected git add for generated review files');
|
||||||
assert.ok(addCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(addCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
||||||
assert.ok(addCall.args.includes('.github/copilot-instructions.md'));
|
assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(addCall.args.includes('.amazonq/rules/triage-findings.md'));
|
assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(addCall.args.includes('CLAUDE.md'));
|
assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
|
||||||
assert.ok(addCall.args.includes('GEMINI.md'));
|
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
|
||||||
assert.ok(!addCall.args.includes('README.md'));
|
assert.ok(skillAddCall.args.includes('CLAUDE.md'));
|
||||||
|
assert.ok(skillAddCall.args.includes('GEMINI.md'));
|
||||||
|
assert.ok(!skillAddCall.args.includes('README.md'));
|
||||||
|
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json'));
|
||||||
|
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps repo copies when the source sync file is missing', async () => {
|
it('keeps repo copies when the source sync file is missing', async () => {
|
||||||
@@ -139,6 +159,32 @@ describe('commitAndPush', () => {
|
|||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
|
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs push failures separately from commit failures', async () => {
|
||||||
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
|
||||||
|
fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
|
||||||
|
|
||||||
|
const spawn = makeSpawn({
|
||||||
|
push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }),
|
||||||
|
});
|
||||||
|
const logs = [];
|
||||||
|
const originalLog = console.log;
|
||||||
|
console.log = (...args) => { logs.push(args.join(' ')); };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
||||||
|
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cloneRepo', () => {
|
describe('cloneRepo', () => {
|
||||||
|
|||||||
+10
-5
@@ -4,7 +4,7 @@ import { loadRoles, getRoleIntro } from './roles.js';
|
|||||||
import { getPRDiff, postComment } from './gitea.js';
|
import { getPRDiff, postComment } from './gitea.js';
|
||||||
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
||||||
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
||||||
import { cloneRepo, commitAndPush } from './git.js';
|
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
@@ -69,6 +69,10 @@ async function main() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`);
|
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
const repoState = repoDir ? getRepoState(repoDir) : null;
|
||||||
|
if (repoState) {
|
||||||
|
console.log(` repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`);
|
||||||
|
}
|
||||||
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||||
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||||
console.log(` Step3 merged findings total=${mergedFindings.length}`);
|
console.log(` Step3 merged findings total=${mergedFindings.length}`);
|
||||||
@@ -81,14 +85,15 @@ async function main() {
|
|||||||
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
||||||
console.log('\n🚫 Step4: AI 排除問題過濾');
|
console.log('\n🚫 Step4: AI 排除問題過濾');
|
||||||
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
||||||
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
|
||||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||||
console.log(` Step4 完成: findings total=${filtered.length}`);
|
console.log(` Step4 完成: findings total=${filtered.length}`);
|
||||||
|
|
||||||
// Step6: 寫入 findings.json,依序發布 comment
|
// Step6: 寫入 findings.json,依序發布 comment
|
||||||
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
|
||||||
saveFindings(WORKSPACE, filtered);
|
const reviewDir = repoDir || WORKSPACE;
|
||||||
|
saveFindings(WORKSPACE, filtered, reviewDir);
|
||||||
try {
|
try {
|
||||||
await postOldFindingsComment(filtered);
|
await postOldFindingsComment(filtered);
|
||||||
await postNewNonCriticalComment(filtered);
|
await postNewNonCriticalComment(filtered);
|
||||||
@@ -102,7 +107,7 @@ async function main() {
|
|||||||
console.log('\n🔎 Step6: JSON 格式驗證');
|
console.log('\n🔎 Step6: JSON 格式驗證');
|
||||||
const missingPaths = [];
|
const missingPaths = [];
|
||||||
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||||
const fullPath = path.join(repoDir || WORKSPACE, relPath);
|
const fullPath = path.join(reviewDir, relPath);
|
||||||
try {
|
try {
|
||||||
const result = await validateJSONArrayFile(fullPath, relPath);
|
const result = await validateJSONArrayFile(fullPath, relPath);
|
||||||
if (!result.exists) missingPaths.push({ fullPath, relPath });
|
if (!result.exists) missingPaths.push({ fullPath, relPath });
|
||||||
@@ -117,7 +122,7 @@ async function main() {
|
|||||||
|
|
||||||
// Step7: commit/push findings.json 到來源分支
|
// Step7: commit/push findings.json 到來源分支
|
||||||
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
||||||
await commitAndPush(WORKSPACE, repoDir);
|
await commitAndPush(WORKSPACE, repoDir || WORKSPACE);
|
||||||
|
|
||||||
// Step9: 有 critical 問題則 exit 1
|
// Step9: 有 critical 問題則 exit 1
|
||||||
console.log('\n🚦 Step8: 嚴重問題檢查');
|
console.log('\n🚦 Step8: 嚴重問題檢查');
|
||||||
|
|||||||
Reference in New Issue
Block a user