Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e216ca08c5 | |||
| 888bf0b359 | |||
| 59e942f24b | |||
| 82ecbd3463 | |||
| f3319b5ec4 | |||
| ee593418f0 | |||
| 9012fe64d1 | |||
| 3ae08052a3 | |||
| 60f3a9beba | |||
| 09b7be2c40 | |||
| 647460ea87 | |||
| 9fe85c9f72 | |||
| ca9845af1d | |||
| 2061fadba9 | |||
| eccdfd0a3a | |||
| bf6c791a82 | |||
| 222de4b369 | |||
| 8bf791a829 | |||
| c88c0d02c8 | |||
| f43ba63f0f | |||
| 4a29c4aaa3 | |||
| 78ec8f6d6a | |||
| 5c5773e4fd | |||
| ece7377fc8 | |||
| 68cd124f59 | |||
| e9f3baf95f | |||
| 33d5cdde7c | |||
| ae96ead6cf | |||
| d502393745 | |||
| e5539c377c | |||
| 109048e604 | |||
| f241f70898 | |||
| 7186098edf | |||
| 46da713fa7 | |||
| 515ccb0509 | |||
| 69e3b33558 | |||
| c70a818986 | |||
| 684c35bc00 | |||
| 93c602b86a | |||
| b397b76a7a | |||
| c5c3f1d7e1 | |||
| 12980d6ca4 | |||
| aa8b3ae89a | |||
| 1ad87ac4a4 | |||
| fb5c28114d | |||
| c871a27c9a | |||
| 4492fcbdd6 | |||
| 3338a518fe | |||
| 098d4aea97 | |||
| 850b2d770e | |||
| 5478918e25 | |||
| fd49610838 | |||
| 92d32766b9 |
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
When the task is to triage review findings, follow this workflow:
|
||||||
|
|
||||||
|
1. Merge all findings into one list.
|
||||||
|
2. Remove duplicates.
|
||||||
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
|
4. Renumber from 1 after sorting.
|
||||||
|
5. Fix real issues with the smallest safe change.
|
||||||
|
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
7. Add or update tests when behavior changes.
|
||||||
|
8. Re-check the issue after each fix.
|
||||||
|
|
||||||
|
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: triage-findings
|
||||||
|
description: Triage findings, fix real issues, and exclude false positives.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Merge all findings.
|
||||||
|
2. Sort by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
3. Renumber from 1.
|
||||||
|
4. Fix real issues.
|
||||||
|
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
6. Add tests when behavior changes.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final list short.
|
||||||
|
- Keep numbering contiguous.
|
||||||
|
- Preserve file path, location, and fix.
|
||||||
|
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
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 using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics.
|
||||||
|
|
||||||
|
## 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, 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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Triage Findings"
|
||||||
|
short_description: "Triage, sort, fix, and exclude review findings"
|
||||||
|
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to exclusions."
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: triage-findings
|
||||||
|
description: Triage findings, fix real issues, and exclude false positives.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Merge all findings.
|
||||||
|
2. Sort by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
3. Renumber from 1.
|
||||||
|
4. Fix real issues.
|
||||||
|
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
6. Add tests when behavior changes.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final list short.
|
||||||
|
- Keep numbering contiguous.
|
||||||
|
- Preserve file path, location, and fix.
|
||||||
|
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
||||||
@@ -164,6 +164,18 @@
|
|||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"location": "Dockerfile, app/git.js, app/git.test.js",
|
||||||
|
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "Dockerfile",
|
||||||
|
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "Dockerfile",
|
||||||
|
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
@@ -279,5 +291,33 @@
|
|||||||
{
|
{
|
||||||
"location": "app/json.js",
|
"location": "app/json.js",
|
||||||
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/json.test.js",
|
||||||
|
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/gitea.test.js:64",
|
||||||
|
"suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/git.test.js:13",
|
||||||
|
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/gitea.js:32",
|
||||||
|
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/json.test.js:76",
|
||||||
|
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/json.test.js:7",
|
||||||
|
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/json.test.js:10",
|
||||||
|
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1,23 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"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": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "critical",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "action.yaml:12",
|
||||||
|
"suggestion": "建議將 `GITEA_TOKEN` 的環境變數設定改回 `GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }}`。目前將其設定為 `required: true` 並移除 `secrets.GITEA_TOKEN` 的 fallback 機制,會導致現有依賴 `secrets.GITEA_TOKEN` 的工作流程中斷,並降低配置的彈性。如果目的是強制透過 `inputs` 傳遞,應在文件明確說明此重大變更及其原因。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "action.yaml:80",
|
||||||
|
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 現在只從 `inputs` 取得,但 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制。這種處理方式的不一致性可能會造成未來的維護困擾。建議統一所有 Gitea 相關變數的取得邏輯,或提供明確的註解說明此差異的原因。",
|
||||||
|
"is_new": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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:
|
||||||
version:
|
version:
|
||||||
@@ -31,10 +30,11 @@ jobs:
|
|||||||
- 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
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
Use the triage-finding workflow for review issue lists:
|
||||||
|
|
||||||
|
1. Merge findings into one list.
|
||||||
|
2. Remove duplicates.
|
||||||
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
|
4. Renumber from 1.
|
||||||
|
5. Fix real issues with the smallest safe change.
|
||||||
|
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
7. Add or update tests when behavior changes.
|
||||||
|
8. Re-check after each fix.
|
||||||
|
|
||||||
|
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
Use the triage-finding workflow for review issue lists:
|
||||||
|
|
||||||
|
1. Merge findings into one list.
|
||||||
|
2. Remove duplicates.
|
||||||
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
|
4. Renumber from 1.
|
||||||
|
5. Fix real issues with the smallest safe change.
|
||||||
|
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
7. Add or update tests when behavior changes.
|
||||||
|
8. Re-check after each fix.
|
||||||
|
|
||||||
|
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
When the task is to triage review findings, follow this workflow:
|
||||||
|
|
||||||
|
1. Merge all findings into one list.
|
||||||
|
2. Remove duplicates.
|
||||||
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
|
4. Renumber from 1 after sorting.
|
||||||
|
5. Fix real issues with the smallest safe change.
|
||||||
|
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
7. Add or update tests when behavior changes.
|
||||||
|
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`.
|
||||||
@@ -10,6 +10,14 @@ WORKDIR /action
|
|||||||
COPY app/package.json /action/app/
|
COPY app/package.json /action/app/
|
||||||
RUN cd /action/app && npm install
|
RUN cd /action/app && npm install
|
||||||
|
|
||||||
|
COPY .amazonq/ /action/.amazonq/
|
||||||
|
COPY .codex/ /action/.codex/
|
||||||
|
COPY .claude/ /action/.claude/
|
||||||
|
COPY .gemini/ /action/.gemini/
|
||||||
|
COPY .github/ /action/.github/
|
||||||
|
COPY CLAUDE.md /action/
|
||||||
|
COPY GEMINI.md /action/
|
||||||
|
|
||||||
COPY app/ /action/app/
|
COPY app/ /action/app/
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
Use the triage-finding workflow for review issue lists:
|
||||||
|
|
||||||
|
1. Merge findings into one list.
|
||||||
|
2. Remove duplicates.
|
||||||
|
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||||
|
4. Renumber from 1.
|
||||||
|
5. Fix real issues with the smallest safe change.
|
||||||
|
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
||||||
|
7. Add or update tests when behavior changes.
|
||||||
|
8. Re-check after each fix.
|
||||||
|
|
||||||
|
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
|
||||||
@@ -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 問題檔案
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
# 設計
|
# 設計
|
||||||
|
|
||||||
@@ -22,9 +22,10 @@
|
|||||||
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/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
|
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]`,而且 action 執行時會先透過 Gitea API 檢查這次觸發的 PR head commit(優先用 `pull_request.head.sha`)是否含有這個 marker,若有就直接成功結束,避免 bot commit 造成重複觸發。若外層 workflow 也能先檢查一次,效果最好。
|
||||||
|
|
||||||
> **權限說明**:此 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,12 +198,42 @@ 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
|
||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Skill:Triage Findings
|
||||||
|
|
||||||
|
這份 skill 用來處理 review 問題清單。
|
||||||
|
|
||||||
|
### 規則
|
||||||
|
|
||||||
|
1. 合併問題。
|
||||||
|
2. 依嚴重度排序:`critical` -> `warning` -> `info`。
|
||||||
|
3. 重新編號。
|
||||||
|
4. 真問題就修。
|
||||||
|
5. 誤判就加到 `.gitea/ai-review/exclusions.json`。
|
||||||
|
6. 有變更就補測試。
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
Codex:`$triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
Copilot:`/triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
Claude:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
Gemini:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
|
||||||
|
### 適用情境
|
||||||
|
|
||||||
|
`triage-findings 問題原始檔(文字或截圖)` 用在 review 問題整併、排序、修正、排除誤判。
|
||||||
|
|
||||||
|
### 版本包含
|
||||||
|
|
||||||
|
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,以及 `.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼。
|
||||||
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
|
- 驗收:PR 中有上述路徑或檔案的變更時,diff 內容不包含該區塊,AI 分析結果不含這些路徑相關問題。
|
||||||
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
||||||
|
|
||||||
## 階段三:Findings 產生與合併
|
## 階段三:Findings 產生與合併
|
||||||
@@ -38,15 +38,15 @@
|
|||||||
- 已驗收: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,錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 已驗收:log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功。
|
- 已驗收:log 已出現 `persisted findings commit=b867eaa push=feat/解決問題`,代表 commit/push 成功;本次已補上「來源覆蓋、缺檔不刪除」的同步規則,相關單元測試也已覆蓋。
|
||||||
|
|
||||||
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||||
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
||||||
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
||||||
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證。
|
- 已驗收:這次 log 已明確出現 `❌ 發現 2 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,證明阻擋分支確實生效。
|
||||||
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log。
|
- 補充紀錄:`Step8` 的退出訊息屬於預期行為,不代表 Step7 commit/push 失敗。
|
||||||
|
|
||||||
## 階段十:API Key 輪替
|
## 階段十:API Key 輪替
|
||||||
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
||||||
|
|||||||
+4
-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,12 +80,13 @@ 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 }}
|
||||||
PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }}
|
||||||
|
PR_HEAD_SHA: ${{ inputs.PR_HEAD_SHA || gitea.event.pull_request.head.sha }}
|
||||||
PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }}
|
PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }}
|
||||||
PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }}
|
PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }}
|
||||||
# LLM
|
# LLM
|
||||||
|
|||||||
+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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.c
|
|||||||
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || '';
|
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || '';
|
||||||
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
|
export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true';
|
||||||
export const PR_NUMBER = process.env.PR_NUMBER || '';
|
export const PR_NUMBER = process.env.PR_NUMBER || '';
|
||||||
|
export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || '';
|
||||||
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
|
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
|
||||||
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
|
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
|
||||||
|
|
||||||
|
|||||||
+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)}`)));
|
||||||
|
});
|
||||||
|
});
|
||||||
+80
-10
@@ -1,9 +1,24 @@
|
|||||||
import { spawnSync } from 'child_process';
|
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 { 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 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 = [
|
||||||
|
'.amazonq/rules/triage-findings.md',
|
||||||
|
'.codex/skills/triage-findings/SKILL.md',
|
||||||
|
'.codex/skills/triage-findings/agents/openai.yaml',
|
||||||
|
'.claude/skills/triage-findings/SKILL.md',
|
||||||
|
'.gemini/skills/triage-findings/SKILL.md',
|
||||||
|
'.github/copilot-instructions.md',
|
||||||
|
'.github/skills/triage-findings/SKILL.md',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'GEMINI.md',
|
||||||
|
];
|
||||||
|
|
||||||
function makeRunner(spawn) {
|
function makeRunner(spawn) {
|
||||||
return function run(args, cwd, env) {
|
return function run(args, cwd, env) {
|
||||||
@@ -27,6 +42,32 @@ 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
||||||
|
const run = makeRunner(_spawnSync);
|
||||||
|
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||||||
|
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* Clone PR head branch to workspace/repo (idempotent)
|
||||||
*/
|
*/
|
||||||
@@ -47,31 +88,60 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
|
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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 srcFindings = path.join(workspace, FINDINGS_PATH);
|
const existingSyncPaths = [];
|
||||||
const destFindings = path.join(repoDir, FINDINGS_PATH);
|
|
||||||
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
|
||||||
fs.copyFileSync(srcFindings, destFindings);
|
|
||||||
|
|
||||||
run(['add', FINDINGS_PATH], repoDir);
|
// Copy action skill files into the target repo. Existing files are overwritten;
|
||||||
|
// missing source files are ignored so we do not delete target repo content.
|
||||||
|
for (const relPath of SYNC_PATHS) {
|
||||||
|
const src = path.join(sourceRoot, relPath);
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
existingSyncPaths.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSyncPaths.length > 0) {
|
||||||
|
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) {
|
||||||
console.log(' findings.json 無變更,跳過 commit');
|
console.log(' sync files 無變更,跳過 commit');
|
||||||
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}`);
|
||||||
|
|||||||
+117
-12
@@ -3,20 +3,25 @@ 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 } from './git.js';
|
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
|
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
|
||||||
// Pre-create repo dir so clone branch is skipped
|
|
||||||
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
|
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
|
||||||
// Create a findings.json to copy
|
|
||||||
const findingsDir = path.join(ws, '.gitea/ai-review');
|
|
||||||
fs.mkdirSync(findingsDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(findingsDir, 'findings.json'), '[]');
|
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeActionSource() {
|
||||||
|
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
|
||||||
|
for (const relPath of SYNC_PATHS) {
|
||||||
|
const fullPath = path.join(sourceRoot, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, relPath);
|
||||||
|
}
|
||||||
|
return sourceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
// Default stub: all commands succeed, status returns changes
|
// Default stub: all commands succeed, status returns changes
|
||||||
function makeSpawn(overrides = {}) {
|
function makeSpawn(overrides = {}) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -34,9 +39,12 @@ function makeSpawn(overrides = {}) {
|
|||||||
|
|
||||||
describe('commitAndPush', () => {
|
describe('commitAndPush', () => {
|
||||||
let workspace;
|
let workspace;
|
||||||
|
let sourceRoot;
|
||||||
|
|
||||||
before(() => { workspace = makeTmpWorkspace(); });
|
before(() => { workspace = makeTmpWorkspace(); });
|
||||||
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||||
|
before(() => { sourceRoot = makeActionSource(); });
|
||||||
|
after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); });
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const f of fs.readdirSync(workspace)) {
|
for (const f of fs.readdirSync(workspace)) {
|
||||||
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
||||||
@@ -45,16 +53,25 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
it('does not embed token in any git command argument', async () => {
|
it('does not embed token in any git command argument', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
for (const { args } of spawn.calls) {
|
for (const { args } of spawn.calls) {
|
||||||
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
const networkOps = ['fetch', 'push', 'clone'];
|
const networkOps = ['fetch', 'push', 'clone'];
|
||||||
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
||||||
@@ -66,28 +83,107 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script after successful run', async () => {
|
it('cleans up askpass script after successful run', async () => {
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
|
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up askpass script even when git fails', async () => {
|
it('cleans up askpass script even when git fails', async () => {
|
||||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot);
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips commit when status shows no changes', async () => {
|
it('skips commit when status shows no changes', async () => {
|
||||||
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
||||||
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
|
const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
|
||||||
|
const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md'));
|
||||||
|
const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
|
||||||
|
assert.ok(skillAddCall, 'expected git add for synced skill 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/agents/openai.yaml'));
|
||||||
|
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('.github/copilot-instructions.md'));
|
||||||
|
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.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 () => {
|
||||||
|
const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md');
|
||||||
|
fs.rmSync(missingPath, { force: true });
|
||||||
|
const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md');
|
||||||
|
fs.writeFileSync(repoPath, 'stale');
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
|
||||||
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
|
const rmCall = spawn.calls.find(c => c.args[0] === 'rm');
|
||||||
|
assert.equal(rmCall, undefined, 'git rm should not run for missing source files');
|
||||||
|
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing repo copies with workspace files', async () => {
|
||||||
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
|
||||||
|
|
||||||
|
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');
|
||||||
|
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not throw when git command fails', async () => {
|
it('does not throw when git command fails', async () => {
|
||||||
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));
|
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')));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,4 +241,13 @@ describe('cloneRepo', () => {
|
|||||||
const result = cloneRepo(workspace, spawn);
|
const result = cloneRepo(workspace, spawn);
|
||||||
assert.equal(result, path.join(workspace, 'repo'));
|
assert.equal(result, path.join(workspace, 'repo'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads head commit message and detects bot auto commits', () => {
|
||||||
|
const spawn = makeSpawn({
|
||||||
|
show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER));
|
||||||
|
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+78
-3
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER } from './config.js';
|
import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js';
|
||||||
|
|
||||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||||
const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
|
const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
|
||||||
@@ -11,7 +11,78 @@ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
|||||||
*/
|
*/
|
||||||
export async function getPRDiff() {
|
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, ['.gitea/']);
|
return filterDiff(resp.data, [
|
||||||
|
'.amazonq/',
|
||||||
|
'.claude/',
|
||||||
|
'.codex/',
|
||||||
|
'.gemini/',
|
||||||
|
'.gitea/',
|
||||||
|
'.github/',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'GEMINI.md',
|
||||||
|
'README.md',
|
||||||
|
'TODO.md',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommitMessageBySha(sha) {
|
||||||
|
if (!sha) return '';
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/git/commits/${encodeURIComponent(sha)}`), {
|
||||||
|
headers: headers(),
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent,
|
||||||
|
});
|
||||||
|
return resp.data?.message || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
||||||
|
if (!branch) return '';
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/branches/${encodeURIComponent(branch)}`), {
|
||||||
|
headers: headers(),
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent,
|
||||||
|
});
|
||||||
|
const sha = resp.data?.commit?.id || resp.data?.commit?.sha || '';
|
||||||
|
return await getCommitMessageBySha(sha);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
|
||||||
|
console.log(` 🔎 bot-check: start sha=${sha || 'empty'} branch=${branch || 'empty'}`);
|
||||||
|
|
||||||
|
const shaMessage = await getCommitMessageBySha(sha);
|
||||||
|
if (sha) {
|
||||||
|
console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'}`);
|
||||||
|
if (shaMessage.includes('[ai-review-bot]')) {
|
||||||
|
console.log(' ✅ bot-check: matched commit sha marker');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' 🔎 bot-check: skip sha lookup because sha is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchMessage = await getBranchHeadCommitMessage(branch);
|
||||||
|
if (branch) {
|
||||||
|
console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'}`);
|
||||||
|
if (branchMessage.includes('[ai-review-bot]')) {
|
||||||
|
console.log(' ✅ bot-check: matched branch head marker');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' 🔎 bot-check: skip branch lookup because branch is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' ℹ️ bot-check: no [ai-review-bot] marker found');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +91,11 @@ export async function getPRDiff() {
|
|||||||
*/
|
*/
|
||||||
export function filterDiff(diff, excludePrefixes) {
|
export function filterDiff(diff, excludePrefixes) {
|
||||||
return diff.split(/(?=^diff --git )/m)
|
return diff.split(/(?=^diff --git )/m)
|
||||||
.filter(block => !excludePrefixes.some(p => block.startsWith(`diff --git a/${p}`)))
|
.filter(block => !excludePrefixes.some(p => {
|
||||||
|
const prefix = `diff --git a/${p}`;
|
||||||
|
const singleFile = `diff --git a/${p} b/${p}`;
|
||||||
|
return block.startsWith(prefix) || block.startsWith(singleFile);
|
||||||
|
}))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-15
@@ -1,12 +1,11 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit } from './gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
describe('gitea', async () => {
|
describe('gitea', () => {
|
||||||
const { getPRDiff, filterDiff, postComment } = await import('./gitea.js');
|
|
||||||
|
|
||||||
it('getPRDiff calls Gitea diff API with Authorization header', async () => {
|
it('getPRDiff calls Gitea diff API with Authorization header', async () => {
|
||||||
let capturedUrl, capturedOpts;
|
let capturedUrl, capturedOpts;
|
||||||
mock.method(axios, 'get', async (url, opts) => {
|
mock.method(axios, 'get', async (url, opts) => {
|
||||||
@@ -57,29 +56,68 @@ describe('gitea', async () => {
|
|||||||
mock.method(axios, 'post', async () => { throw new Error('api error'); });
|
mock.method(axios, 'post', async () => { throw new Error('api error'); });
|
||||||
await assert.rejects(() => postComment('test'), /api error/);
|
await assert.rejects(() => postComment('test'), /api error/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
||||||
|
let capturedUrl;
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
|
||||||
|
});
|
||||||
|
const message = await getCommitMessageBySha('abc123');
|
||||||
|
assert.ok(capturedUrl.includes('/git/commits/abc123'));
|
||||||
|
assert.ok(message.includes('[ai-review-bot]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getBranchHeadCommitMessage reads branch head commit message from Gitea API', async () => {
|
||||||
|
const urls = [];
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
urls.push(url);
|
||||||
|
if (url.includes('/branches/feat%2Ftest')) {
|
||||||
|
return { data: { commit: { id: 'abc123' } } };
|
||||||
|
}
|
||||||
|
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
|
||||||
|
});
|
||||||
|
const message = await getBranchHeadCommitMessage('feat/test');
|
||||||
|
assert.ok(urls.some(url => url.includes('/branches/feat%2Ftest')));
|
||||||
|
assert.ok(urls.some(url => url.includes('/git/commits/abc123')));
|
||||||
|
assert.ok(message.includes('[ai-review-bot]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => {
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
if (url.includes('/git/commits/sha-bot')) {
|
||||||
|
return { data: { message: 'chore: update ai-review findings [ai-review-bot]' } };
|
||||||
|
}
|
||||||
|
if (url.includes('/branches/feat%2Ftest')) {
|
||||||
|
return { data: { commit: { id: 'sha-bot' } } };
|
||||||
|
}
|
||||||
|
return { data: { message: 'regular commit' } };
|
||||||
|
});
|
||||||
|
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterDiff', async () => {
|
describe('filterDiff', () => {
|
||||||
const { filterDiff } = await import('./gitea.js');
|
|
||||||
|
|
||||||
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
|
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
|
||||||
|
|
||||||
it('filters out .gitea/ blocks', () => {
|
it('filters out configured folder blocks', () => {
|
||||||
const diff = block('.gitea/workflows/review.yaml') + block('src/index.js');
|
const diff = block('.gitea/workflows/review.yaml') + block('.amazonq/rules/triage-findings.md') + block('src/index.js');
|
||||||
const result = filterDiff(diff, ['.gitea/']);
|
const result = filterDiff(diff, ['.gitea/', '.amazonq/']);
|
||||||
assert.ok(!result.includes('.gitea/'));
|
assert.ok(!result.includes('.gitea/'));
|
||||||
|
assert.ok(!result.includes('.amazonq/'));
|
||||||
assert.ok(result.includes('src/index.js'));
|
assert.ok(result.includes('src/index.js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not filter non-.gitea/ blocks', () => {
|
it('filters out configured top-level file blocks', () => {
|
||||||
const diff = block('src/index.js') + block('README.md');
|
const diff = block('README.md') + block('src/index.js');
|
||||||
const result = filterDiff(diff, ['.gitea/']);
|
const result = filterDiff(diff, ['README.md', 'TODO.md']);
|
||||||
assert.equal(result, diff);
|
assert.ok(!result.includes('README.md'));
|
||||||
|
assert.ok(result.includes('src/index.js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('CLAUDE.md');
|
||||||
const result = filterDiff(diff, ['.gitea/']);
|
const result = filterDiff(diff, ['.gitea/', 'CLAUDE.md']);
|
||||||
assert.equal(result, '');
|
assert.equal(result, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from 'path';
|
|||||||
import { stripCodeFence, repairJSONArrayWithAI, validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { stripCodeFence, repairJSONArrayWithAI, validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
|
|
||||||
describe('json helpers', () => {
|
describe('json helpers', () => {
|
||||||
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
let workspace;
|
let workspace;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -76,6 +77,16 @@ describe('json helpers', () => {
|
|||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads a valid JSON file whose size equals the maximum limit', async () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, `[]${' '.repeat(MAX_JSON_BYTES - 2)}`, 'utf8');
|
||||||
|
|
||||||
|
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json');
|
||||||
|
|
||||||
|
assert.deepEqual(result, { exists: true, valid: true, repaired: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('repairs invalid JSON using AI output and rewrites the file', async () => {
|
it('repairs invalid JSON using AI output and rewrites the file', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
@@ -90,6 +101,20 @@ describe('json helpers', () => {
|
|||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a trailing newline returned by AI repair', async () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||||
|
|
||||||
|
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async (_fullPath, _label, original) => {
|
||||||
|
assert.equal(original, '{broken');
|
||||||
|
return '[{"fixed":true}]\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when AI repair fails', async () => {
|
it('throws when AI repair fails', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
|||||||
+17
-6
@@ -1,10 +1,10 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
import { loadRoles, getRoleIntro } from './roles.js';
|
import { loadRoles, getRoleIntro } from './roles.js';
|
||||||
import { getPRDiff, postComment } from './gitea.js';
|
import { getPRDiff, postComment, shouldSkipBotCommit } 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';
|
||||||
@@ -15,6 +15,12 @@ async function main() {
|
|||||||
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
||||||
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
||||||
|
|
||||||
|
if (await shouldSkipBotCommit()) {
|
||||||
|
console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
|
console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs');
|
||||||
@@ -69,6 +75,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 +91,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 +113,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 +128,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