Compare commits
35 Commits
v0.1.6-beta.23
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9af09de0d3 | |||
| fbff9b3a86 | |||
| 7a01b7e3f4 | |||
| 097b6fb721 | |||
| adf37520cb | |||
| e99236b893 | |||
| 43ebc81f1d | |||
| f55264bb18 | |||
| 0d4776888f | |||
| e3ae1bc10e | |||
| e80a462d96 | |||
| d818baffa7 | |||
| c24f2e00e2 | |||
| fc02cda577 | |||
| ed3b26ee3c | |||
| 5afe8a2119 | |||
| 09584f4f93 | |||
| ed061f85ce | |||
| b4c54124ec | |||
| b51ab78a5e | |||
| 1129f37384 | |||
| b8294d5ca7 | |||
| 915e9cc2da | |||
| b1ed236720 | |||
| d18c4a4a8e | |||
| b06a89f2b9 | |||
| bb0158dadd | |||
| ce6afdd5ee | |||
| 86d8666cda | |||
| 95e90393e7 | |||
| c836ec08e4 | |||
| acb3604cda | |||
| 38a3349e4f | |||
| f382667946 | |||
| 4e586158a5 |
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: triage-findings
|
||||||
|
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
|
||||||
|
It is also used when some findings are false positives and should be moved into the exclusions list.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Collect all findings into one list.
|
||||||
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
4. Renumber the sorted list from 1 upward.
|
||||||
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
|
- Keep numbering contiguous after filtering and merging.
|
||||||
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
|
||||||
|
It is also used when some findings are false positives and should be moved into the exclusions list.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Collect all findings into one list.
|
||||||
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
4. Renumber the sorted list from 1 upward.
|
||||||
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
|
- Keep numbering contiguous after filtering and merging.
|
||||||
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: triage-findings
|
||||||
|
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Triage Findings
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
|
||||||
|
It is also used when some findings are false positives and should be moved into the exclusions list.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Collect all findings into one list.
|
||||||
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
4. Renumber the sorted list from 1 upward.
|
||||||
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
|
- Keep numbering contiguous after filtering and merging.
|
||||||
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
@@ -1,29 +1,46 @@
|
|||||||
---
|
---
|
||||||
name: triage-findings
|
name: triage-findings
|
||||||
description: Triage findings, fix real issues, and exclude false positives.
|
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Triage Findings
|
# Triage Findings
|
||||||
|
|
||||||
## Use
|
## When To Use
|
||||||
|
|
||||||
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
|
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
|
## Workflow
|
||||||
|
|
||||||
1. Merge all findings.
|
1. Collect all findings into one list.
|
||||||
2. Sort by severity:
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
- critical
|
- critical
|
||||||
- warning
|
- warning
|
||||||
- info
|
- info
|
||||||
3. Renumber from 1.
|
4. Renumber the sorted list from 1 upward.
|
||||||
4. Fix real issues.
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
6. Add tests when behavior changes.
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
|
|
||||||
- Keep the final list short.
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
- Keep numbering contiguous.
|
- Keep numbering contiguous after filtering and merging.
|
||||||
- Preserve file path, location, and fix.
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ It is also used when some findings are false positives and should be moved into
|
|||||||
4. Renumber the sorted list from 1 upward.
|
4. Renumber the sorted list from 1 upward.
|
||||||
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
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.
|
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.
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
## Resolution Flow
|
## Resolution Flow
|
||||||
|
|
||||||
@@ -41,5 +41,6 @@ After the list is merged and ordered, resolve the remaining findings one by one.
|
|||||||
- Keep numbering contiguous after filtering and merging.
|
- Keep numbering contiguous after filtering and merging.
|
||||||
- Preserve useful details like file path, location, and suggested fix.
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
- Keep exclusions entries minimal and consistent with the project schema.
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
- 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.
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Triage Findings"
|
display_name: "Triage Findings"
|
||||||
short_description: "Triage, sort, fix, and exclude review 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."
|
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 `.gitea/ai-review/exclusions.json` as a top-level JSON array."
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
---
|
---
|
||||||
name: triage-findings
|
name: triage-findings
|
||||||
description: Triage findings, fix real issues, and exclude false positives.
|
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Triage Findings
|
# Triage Findings
|
||||||
|
|
||||||
## Use
|
## When To Use
|
||||||
|
|
||||||
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
|
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
|
## Workflow
|
||||||
|
|
||||||
1. Merge all findings.
|
1. Collect all findings into one list.
|
||||||
2. Sort by severity:
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
- critical
|
- critical
|
||||||
- warning
|
- warning
|
||||||
- info
|
- info
|
||||||
3. Renumber from 1.
|
4. Renumber the sorted list from 1 upward.
|
||||||
4. Fix real issues.
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
6. Add tests when behavior changes.
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
|
|
||||||
- Keep the final list short.
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
- Keep numbering contiguous.
|
- Keep numbering contiguous after filtering and merging.
|
||||||
- Preserve file path, location, and fix.
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
|
|||||||
+353
-331
@@ -1,333 +1,355 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
|
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
|
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "README.md",
|
"location": "README.md",
|
||||||
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/config.js",
|
"location": "app/config.js",
|
||||||
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
|
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": ".gitea/ai-review/exclusions.json",
|
"location": ".gitea/ai-review/exclusions.json",
|
||||||
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
|
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/findings.js",
|
"location": "app/findings.js",
|
||||||
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
|
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
|
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": "README.md",
|
"location": "README.md",
|
||||||
"suggestion": "章節編號連續且正確,無需調整"
|
"suggestion": "章節編號連續且正確,無需調整"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
|
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
|
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": "app/config.test.js",
|
"location": "app/config.test.js",
|
||||||
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
|
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": "action.yaml",
|
"location": "action.yaml",
|
||||||
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
|
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/",
|
"location": "app/",
|
||||||
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
|
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/",
|
"location": "app/",
|
||||||
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
|
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/config.test.js",
|
"location": "app/config.test.js",
|
||||||
"suggestion": "import 語句長度合理,無需拆分為多行"
|
"suggestion": "import 語句長度合理,無需拆分為多行"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": ".gitea/ai-review/findings.json",
|
"location": ".gitea/ai-review/findings.json",
|
||||||
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
|
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
|
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": ".gitea/ai-review/findings.json",
|
"location": ".gitea/ai-review/findings.json",
|
||||||
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
|
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
|
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "Authorization 標頭已有 provider !== 'ollama' 判斷,不會無條件加入,已正確處理"
|
"suggestion": "Authorization 標頭已有 provider !== \u0027ollama\u0027 判斷,不會無條件加入,已正確處理"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Zara",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
|
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Rex",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
|
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
|
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Zara",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
|
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
|
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": ".gitea/workflows/master.yaml",
|
"location": ".gitea/workflows/master.yaml",
|
||||||
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
|
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Maya",
|
||||||
"location": "app/package.json",
|
"location": "app/log.test.js",
|
||||||
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
|
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Rex",
|
||||||
"location": "app/llm.js",
|
"location": "app/package.json",
|
||||||
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "Dockerfile, app/git.js, app/git.test.js",
|
"role": "Aria",
|
||||||
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
"location": "app/llm.js",
|
||||||
},
|
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
||||||
{
|
},
|
||||||
"location": "Dockerfile",
|
{
|
||||||
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
"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": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
"location": "Dockerfile",
|
||||||
},
|
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
||||||
{
|
},
|
||||||
"role": "Aria",
|
{
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Aria",
|
||||||
"location": "entrypoint.sh",
|
"location": "Dockerfile",
|
||||||
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Aria",
|
||||||
"location": "app/main.js",
|
"location": "entrypoint.sh",
|
||||||
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
|
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/comments.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
|
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/roles.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
|
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Maya",
|
||||||
"location": "app/gitea.js",
|
"location": "app/roles.js",
|
||||||
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
|
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Leo",
|
||||||
"location": "Dockerfile",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
|
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Zara",
|
||||||
"location": "app/package.json",
|
"location": "Dockerfile",
|
||||||
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
|
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Aria",
|
||||||
"location": "app/main.js",
|
"location": "app/package.json",
|
||||||
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Zara",
|
||||||
"location": "app/comments.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
|
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Leo",
|
||||||
"location": "app/gitea.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Maya",
|
||||||
"location": "TODO.md",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Leo",
|
||||||
"location": "app/gitea.js",
|
"location": "TODO.md",
|
||||||
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rex",
|
||||||
"location": "app/git.js",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Zara",
|
||||||
"location": "app/main.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "在 main.js 中,表達式 repoDir。"
|
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Aria",
|
||||||
"location": "app/gitea.js:L20-L21",
|
"location": "app/main.js",
|
||||||
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
"suggestion": "在 main.js 中,表達式 repoDir。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "TODO.md",
|
"role": "Zara",
|
||||||
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
|
"location": "app/gitea.js:L20-L21",
|
||||||
},
|
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
||||||
{
|
},
|
||||||
"location": "TODO.md",
|
{
|
||||||
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
|
"location": "TODO.md",
|
||||||
},
|
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
|
||||||
{
|
},
|
||||||
"location": "app/findings.js",
|
{
|
||||||
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
|
"location": "TODO.md",
|
||||||
},
|
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
|
||||||
{
|
},
|
||||||
"location": "app/findings.js",
|
{
|
||||||
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
|
"location": "app/findings.js",
|
||||||
},
|
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
|
||||||
{
|
},
|
||||||
"location": "app/comments.js",
|
{
|
||||||
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
|
"location": "app/findings.js",
|
||||||
},
|
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
|
||||||
{
|
},
|
||||||
"location": "app/main.js",
|
{
|
||||||
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
|
"location": "app/comments.js",
|
||||||
},
|
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
|
||||||
{
|
},
|
||||||
"location": "app/git.js",
|
{
|
||||||
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
|
"location": "app/main.js",
|
||||||
},
|
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
|
||||||
{
|
},
|
||||||
"location": "app/main.js",
|
{
|
||||||
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
|
"location": "app/git.js",
|
||||||
},
|
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
|
||||||
{
|
},
|
||||||
"location": "app/json.js",
|
{
|
||||||
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
"location": "app/main.js",
|
||||||
},
|
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
|
||||||
{
|
},
|
||||||
"location": "app/json.test.js",
|
{
|
||||||
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
|
"location": "app/json.js",
|
||||||
},
|
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
||||||
{
|
},
|
||||||
"location": "app/gitea.test.js:64",
|
{
|
||||||
"suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。"
|
"location": "app/json.test.js",
|
||||||
},
|
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
|
||||||
{
|
},
|
||||||
"location": "app/git.test.js:13",
|
{
|
||||||
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
|
"location": "app/gitea.test.js:64",
|
||||||
},
|
"suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。"
|
||||||
{
|
},
|
||||||
"location": "app/gitea.js:32",
|
{
|
||||||
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
|
"location": "app/git.test.js:13",
|
||||||
},
|
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
|
||||||
{
|
},
|
||||||
"location": "app/json.test.js:76",
|
{
|
||||||
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
|
"location": "app/gitea.js:32",
|
||||||
},
|
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
|
||||||
{
|
},
|
||||||
"location": "app/json.test.js:7",
|
{
|
||||||
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
|
"location": "app/json.test.js:76",
|
||||||
},
|
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
|
||||||
{
|
},
|
||||||
"location": "app/json.test.js:10",
|
{
|
||||||
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
"location": "app/json.test.js:7",
|
||||||
},
|
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
|
||||||
{
|
},
|
||||||
"role": "Maya",
|
{
|
||||||
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
|
"location": "app/json.test.js:10",
|
||||||
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
|
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Maya",
|
||||||
"location": "action.yaml:80",
|
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
|
||||||
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
|
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "action.yaml:80",
|
||||||
|
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Rex",
|
||||||
|
"location": "action.yaml:18",
|
||||||
|
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "app/log.js",
|
||||||
|
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "Dockerfile, app/git.js, app/gitea.js",
|
||||||
|
"suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。",
|
||||||
|
"is_new": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
@@ -1,9 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Rex",
|
|
||||||
"location": "action.yaml:18",
|
|
||||||
"suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。",
|
|
||||||
"is_new": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ Use the triage-finding workflow for review issue lists:
|
|||||||
7. Add or update tests when behavior changes.
|
7. Add or update tests when behavior changes.
|
||||||
8. Re-check after each fix.
|
8. Re-check after each fix.
|
||||||
|
|
||||||
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
|
The full reusable skill lives in `.github/skills/triage-findings/SKILL.md`.
|
||||||
|
|||||||
@@ -1,14 +1,41 @@
|
|||||||
# Triage Findings
|
# Triage Findings
|
||||||
|
|
||||||
Use the triage-finding workflow for review issue lists:
|
## When To Use
|
||||||
|
|
||||||
1. Merge findings into one list.
|
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
|
||||||
2. Remove duplicates.
|
It is also used when some findings are false positives and should be moved into the exclusions list.
|
||||||
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`.
|
## Workflow
|
||||||
|
|
||||||
|
1. Collect all findings into one list.
|
||||||
|
2. Merge duplicates into a single finding when they describe the same issue.
|
||||||
|
3. Sort the final list by severity:
|
||||||
|
- critical
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
4. Renumber the sorted list from 1 upward.
|
||||||
|
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
|
||||||
|
6. If a finding is a false positive, do not keep it in the final list.
|
||||||
|
7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
|
||||||
|
|
||||||
|
## Resolution Flow
|
||||||
|
|
||||||
|
After the list is merged and ordered, resolve the remaining findings one by one.
|
||||||
|
|
||||||
|
1. Start from the highest severity item.
|
||||||
|
2. Identify the root cause in the relevant file or context.
|
||||||
|
3. Apply the smallest safe change that fixes the issue.
|
||||||
|
4. Add or update tests when behavior changes.
|
||||||
|
5. Re-check the issue after the change.
|
||||||
|
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
|
||||||
|
7. Continue until the list is either fixed or explicitly excluded.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
|
||||||
|
- Keep numbering contiguous after filtering and merging.
|
||||||
|
- Preserve useful details like file path, location, and suggested fix.
|
||||||
|
- Keep exclusions entries minimal and consistent with the project schema.
|
||||||
|
- When writing exclusions, always output a top-level JSON array.
|
||||||
|
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
|
||||||
|
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ When the task is to triage review findings, follow this workflow:
|
|||||||
8. Re-check the issue after each fix.
|
8. Re-check the issue after each fix.
|
||||||
|
|
||||||
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
||||||
|
|
||||||
|
Trigger it with `/triage-findings`.
|
||||||
@@ -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 `.antigravity/skills/triage-findings/SKILL.md`.
|
||||||
@@ -11,6 +11,6 @@ When the task is to triage review findings, follow this workflow:
|
|||||||
7. Add or update tests when behavior changes.
|
7. Add or update tests when behavior changes.
|
||||||
8. Re-check the issue after each fix.
|
8. Re-check the issue after each fix.
|
||||||
|
|
||||||
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
Use the repo-local `triage-findings` skill for the same workflow when running in Claude.
|
||||||
|
|
||||||
Trigger it with `/triage-findings`.
|
Trigger it with `/triage-findings`.
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ RUN cd /action/app && npm install
|
|||||||
|
|
||||||
COPY .amazonq/ /action/.amazonq/
|
COPY .amazonq/ /action/.amazonq/
|
||||||
COPY .codex/ /action/.codex/
|
COPY .codex/ /action/.codex/
|
||||||
|
COPY .agents/ /action/.agents/
|
||||||
COPY .claude/ /action/.claude/
|
COPY .claude/ /action/.claude/
|
||||||
COPY .gemini/ /action/.gemini/
|
COPY .gemini/ /action/.gemini/
|
||||||
COPY .github/ /action/.github/
|
COPY .github/ /action/.github/
|
||||||
|
COPY AGENTS.md /action/
|
||||||
COPY CLAUDE.md /action/
|
COPY CLAUDE.md /action/
|
||||||
COPY GEMINI.md /action/
|
COPY GEMINI.md /action/
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Pull Request 中變更的內容後,將問題分級 Comment 到 Pull Request 中。
|
這是一個 AI Code Review Action。Gitea Workflow 可以使用此 Action 讓 AI 助理根據不同面向分析 Pull Request 中變更的內容後,將問題分級 Comment 到 Pull Request 中。
|
||||||
|
|
||||||
# 流程(新 Push Request、新 Commit 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
||||||
|
|
||||||
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
|
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
|
||||||
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
|
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
||||||
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾PR問題表格中不需要處理的問題
|
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
||||||
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
||||||
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
||||||
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Pull Request
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
||||||
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
||||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||||
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.agents/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
||||||
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
||||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||||
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
||||||
@@ -57,6 +57,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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 }}
|
||||||
@@ -86,6 +87,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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 }}
|
||||||
@@ -115,6 +117,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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:
|
||||||
@@ -143,6 +146,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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 }}
|
||||||
@@ -172,6 +176,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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:
|
||||||
@@ -201,6 +206,7 @@ jobs:
|
|||||||
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 }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
|
||||||
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -229,6 +235,7 @@ Copilot:`/triage-findings 問題原始檔(文字或截圖)`
|
|||||||
Claude:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
Claude:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
Gemini:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
Gemini:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
Antigravity:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||||
|
|
||||||
### 適用情境
|
### 適用情境
|
||||||
|
|
||||||
@@ -236,4 +243,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|||||||
|
|
||||||
### 版本包含
|
### 版本包含
|
||||||
|
|
||||||
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
提交時一併包含 `triage-findings` skill 與各平台入口檔;其中 `AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 會在目標專案已存在時先做規則化合併,並在可用 LLM 時再用 AI 輔助檢查是否有遺失任何 skill、command 或規則;其餘同步檔則以來源覆蓋;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,以及 `.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼。
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,以及 `.amazonq/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼。
|
||||||
- 驗收:PR 中有上述路徑或檔案的變更時,diff 內容不包含該區塊,AI 分析結果不含這些路徑相關問題。
|
- 驗收:PR 中有上述路徑或檔案的變更時,diff 內容不包含該區塊,AI 分析結果不含這些路徑相關問題。
|
||||||
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
||||||
|
|
||||||
## 階段三:Findings 產生與合併
|
## 階段三:Findings 產生與合併
|
||||||
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
|
||||||
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
|
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3 merged findings total=...」等訊息。
|
||||||
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`。
|
- 已驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=...` 與去重統計訊息。
|
||||||
|
|
||||||
## 階段四:AI 語意去重
|
## 階段四:AI 語意去重
|
||||||
- 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。
|
- 目標:嘗試呼叫 LLM 進行 findings 語意去重,API 額度不足時要有降級處理 log。
|
||||||
@@ -21,16 +21,16 @@
|
|||||||
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
|
||||||
|
|
||||||
## 階段五:AI 排除問題過濾
|
## 階段五:AI 排除問題過濾
|
||||||
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)時先去除重複條目、整理成語意群組摘要;若檔案不是頂層陣列格式,需主動修正成正確格式,再進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
|
||||||
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、重複排除條目的整理摘要、格式修正訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
|
||||||
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆` 與 `排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
|
- 已驗收:`app/findings.js` 會先整理與去重 exclusions,再進行規則過濾與 AI 誤報過濾;若格式不是頂層陣列,會先修正為陣列後再繼續流程。
|
||||||
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
- 補充紀錄:當 `排除過濾` 後仍保留 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。
|
||||||
|
|
||||||
## 階段六:findings 寫入與 comment 發布
|
## 階段六:findings 寫入與 comment 發布
|
||||||
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
|
||||||
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
|
||||||
- 部分驗收:`findings.json` 已成功寫入,也有依序執行舊問題、非嚴重、嚴重 comment 流程;但本次因結果為 0 筆,沒有實際 comment 內容可完整驗證順序。
|
- 已驗收:`findings.json` 會被正確寫入,且 comment 流程會依序嘗試舊問題、非嚴重新問題與嚴重新問題三段。
|
||||||
- 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布`、`新問題(非嚴重)comment 發布`、`嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。
|
- 補充紀錄:當最終 findings 沒有對應類型時,會以 `無舊問題,跳過`、`無新的非嚴重問題,跳過`、`無新的嚴重問題,跳過` 的方式略過;若有問題,則會分別發布對應 comment。
|
||||||
|
|
||||||
## 階段七:階段六後驗證 JSON 格式
|
## 階段七:階段六後驗證 JSON 格式
|
||||||
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`。
|
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`。
|
||||||
@@ -38,14 +38,14 @@
|
|||||||
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||||
|
|
||||||
## 階段八:記憶區 commit/push 與錯誤處理
|
## 階段八:記憶區 commit/push 與錯誤處理
|
||||||
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;`AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋;workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 已驗收:log 已出現 `persisted findings commit=b867eaa push=feat/解決問題`,代表 commit/push 成功;本次已補上「來源覆蓋、缺檔不刪除」的同步規則,相關單元測試也已覆蓋。
|
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
|
||||||
|
|
||||||
## 階段九:阻擋嚴重問題 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 已明確出現 `❌ 發現 2 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,證明阻擋分支確實生效。
|
- 已驗收:`app/main.js` 會在 Step8 檢查 `critical` 數量,若大於 0 就直接 `process.exit(1)`;因此只要最終 findings 含有 critical,workflow 就會失敗。
|
||||||
- 補充紀錄:`Step8` 的退出訊息屬於預期行為,不代表 Step7 commit/push 失敗。
|
- 補充紀錄:`Step8` 的退出訊息屬於預期行為,不代表 Step7 commit/push 失敗。
|
||||||
|
|
||||||
## 階段十:API Key 輪替
|
## 階段十:API Key 輪替
|
||||||
|
|||||||
+174
-10
@@ -37,15 +37,161 @@ function readJSONArray(fullPath, label) {
|
|||||||
|
|
||||||
function normalizeExclusions(data) {
|
function normalizeExclusions(data) {
|
||||||
if (Array.isArray(data)) return data;
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.exclusions)) return data.exclusions;
|
||||||
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
|
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectExclusionSource(data) {
|
||||||
|
if (Array.isArray(data)) return 'array';
|
||||||
|
if (data && Array.isArray(data.exclusions)) return 'exclusions';
|
||||||
|
if (data && Array.isArray(data.excluded_findings)) return 'excluded_findings';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCanonicalExclusions(fullPath, exclusions) {
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
function formatFileTime(mtimeMs) {
|
function formatFileTime(mtimeMs) {
|
||||||
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
||||||
return new Date(mtimeMs).toISOString();
|
return new Date(mtimeMs).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return cleanText(value)
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKeyText(value) {
|
||||||
|
return cleanText(value)
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\p{P}\p{S}\s]+/gu, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExclusionText(exclusion) {
|
||||||
|
return cleanText(exclusion?.original_finding)
|
||||||
|
|| cleanText(exclusion?.title)
|
||||||
|
|| cleanText(exclusion?.suggestion)
|
||||||
|
|| cleanText(exclusion?.reason)
|
||||||
|
|| cleanText(exclusion?.note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExclusionEntry(exclusion, index) {
|
||||||
|
const location = cleanText(exclusion?.location);
|
||||||
|
const filePath = location ? location.split(':')[0] : '';
|
||||||
|
const role = cleanText(exclusion?.role);
|
||||||
|
const text = getExclusionText(exclusion);
|
||||||
|
const textKey = toKeyText(text);
|
||||||
|
const fingerprint = [filePath || '*', role || '*', textKey || `entry-${index + 1}`].join('|');
|
||||||
|
return {
|
||||||
|
...exclusion,
|
||||||
|
location: location || null,
|
||||||
|
filePath,
|
||||||
|
role: role || null,
|
||||||
|
text,
|
||||||
|
textKey,
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeExclusions(exclusions) {
|
||||||
|
const seen = new Set();
|
||||||
|
return exclusions.filter(exclusion => {
|
||||||
|
if (seen.has(exclusion.fingerprint)) return false;
|
||||||
|
seen.add(exclusion.fingerprint);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupExclusionsForAI(exclusions) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const exclusion of exclusions) {
|
||||||
|
const groupKey = exclusion.textKey || exclusion.fingerprint;
|
||||||
|
if (!groups.has(groupKey)) {
|
||||||
|
groups.set(groupKey, {
|
||||||
|
key: groupKey,
|
||||||
|
text: exclusion.text || exclusion.location || exclusion.fingerprint,
|
||||||
|
count: 0,
|
||||||
|
paths: new Set(),
|
||||||
|
roles: new Set(),
|
||||||
|
samples: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const group = groups.get(groupKey);
|
||||||
|
group.count += 1;
|
||||||
|
if (exclusion.filePath) group.paths.add(exclusion.filePath);
|
||||||
|
if (exclusion.role) group.roles.add(exclusion.role);
|
||||||
|
if (group.samples.length < 2 && exclusion.text) group.samples.push(exclusion.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.sort((a, b) => b.count - a.count || b.paths.size - a.paths.size || a.text.localeCompare(b.text))
|
||||||
|
.map(group => ({
|
||||||
|
text: group.text,
|
||||||
|
count: group.count,
|
||||||
|
paths: [...group.paths].sort(),
|
||||||
|
roles: [...group.roles].sort(),
|
||||||
|
samples: group.samples,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExclusionContext(exclusions) {
|
||||||
|
if (exclusions.length === 0) {
|
||||||
|
return {
|
||||||
|
rawCount: 0,
|
||||||
|
uniqueCount: 0,
|
||||||
|
groups: [],
|
||||||
|
prompt: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = exclusions.map((exclusion, index) => normalizeExclusionEntry(exclusion, index));
|
||||||
|
const unique = dedupeExclusions(normalized);
|
||||||
|
const groups = groupExclusionsForAI(unique);
|
||||||
|
const topGroups = groups.slice(0, 12).map(group => ({
|
||||||
|
text: group.text,
|
||||||
|
count: group.count,
|
||||||
|
paths: group.paths.slice(0, 4),
|
||||||
|
roles: group.roles.slice(0, 3),
|
||||||
|
samples: group.samples.slice(0, 2),
|
||||||
|
}));
|
||||||
|
const omitted = groups.length - topGroups.length;
|
||||||
|
const promptLines = [
|
||||||
|
`已知誤報清單(原始 ${exclusions.length} 筆,整理後 ${unique.length} 筆,分成 ${groups.length} 類):`,
|
||||||
|
...topGroups.map((group, index) => {
|
||||||
|
const parts = [
|
||||||
|
`${index + 1}. ${group.text}`,
|
||||||
|
`count=${group.count}`,
|
||||||
|
];
|
||||||
|
if (group.paths.length > 0) parts.push(`paths=${group.paths.join(', ')}`);
|
||||||
|
if (group.roles.length > 0) parts.push(`roles=${group.roles.join(', ')}`);
|
||||||
|
if (group.samples.length > 0) parts.push(`samples=${group.samples.join(' | ')}`);
|
||||||
|
return `- ${parts.join(' ; ')}`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (omitted > 0) {
|
||||||
|
promptLines.push(`- 另有 ${omitted} 類相似排除條目未展開,請依上述群組規則推論。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawCount: exclusions.length,
|
||||||
|
uniqueCount: unique.length,
|
||||||
|
groupCount: groups.length,
|
||||||
|
groups: topGroups,
|
||||||
|
prompt: promptLines.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
||||||
*/
|
*/
|
||||||
@@ -126,7 +272,7 @@ export async function deduplicateWithAI(findings) {
|
|||||||
/**
|
/**
|
||||||
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
||||||
*/
|
*/
|
||||||
export function loadExclusions(workspace, repoState = null) {
|
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
|
||||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
|
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
|
||||||
@@ -144,19 +290,31 @@ export function loadExclusions(workspace, repoState = null) {
|
|||||||
try {
|
try {
|
||||||
const stat = fs.statSync(fullPath);
|
const stat = fs.statSync(fullPath);
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0;
|
const sourceFormat = detectExclusionSource(data);
|
||||||
exclusions = normalizeExclusions(data);
|
const normalizedSource = normalizeExclusions(data);
|
||||||
|
rawCount = normalizedSource.length;
|
||||||
|
exclusions = dedupeExclusions(normalizedSource.map((exclusion, index) => normalizeExclusionEntry(exclusion, index)));
|
||||||
const branch = repoState?.branch || 'detached';
|
const branch = repoState?.branch || 'detached';
|
||||||
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
|
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
|
||||||
const commitTime = repoState?.commitTime || 'unknown';
|
const commitTime = repoState?.commitTime || 'unknown';
|
||||||
line(`讀取排除問題檔案: ${fullPath}`);
|
line(`讀取排除問題檔案: ${fullPath}`);
|
||||||
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
|
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
|
||||||
line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
|
line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
if (sourceFormat !== 'array') {
|
||||||
|
writeCanonicalExclusions(fullPath, normalizedSource);
|
||||||
|
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) {
|
||||||
|
const mirrorPath = path.join(mirrorWorkspace, EXCLUSIONS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
|
||||||
|
writeCanonicalExclusions(mirrorPath, normalizedSource);
|
||||||
|
}
|
||||||
|
line(`排除問題格式已修正為頂層陣列: source=${sourceFormat} -> array`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
||||||
exclusions = [];
|
exclusions = [];
|
||||||
}
|
}
|
||||||
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`);
|
const summary = buildExclusionContext(exclusions);
|
||||||
|
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount} 筆`);
|
||||||
return exclusions;
|
return exclusions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +327,13 @@ export function applyExclusions(findings, exclusions) {
|
|||||||
const before = findings.length;
|
const before = findings.length;
|
||||||
const filtered = findings.filter(f => !exclusions.some(ex => {
|
const filtered = findings.filter(f => !exclusions.some(ex => {
|
||||||
const fPath = String(f.location).split(':')[0];
|
const fPath = String(f.location).split(':')[0];
|
||||||
const exPath = ex.location ? String(ex.location).split(':')[0] : null;
|
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
|
||||||
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
|
const findingText = normalizeText(f.suggestion || f.title || '');
|
||||||
|
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || '');
|
||||||
|
const locationMatches = (!exPath || fPath === exPath);
|
||||||
|
const roleMatches = (!ex.role || ex.role === f.role);
|
||||||
|
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
|
||||||
|
return locationMatches && roleMatches && (exPath || ex.role ? true : textMatches);
|
||||||
}));
|
}));
|
||||||
ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
|
ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
|
||||||
return filtered;
|
return filtered;
|
||||||
@@ -179,17 +342,18 @@ export function applyExclusions(findings, exclusions) {
|
|||||||
/**
|
/**
|
||||||
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
||||||
*/
|
*/
|
||||||
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const exclusionHint = exclusions.length > 0
|
const exclusionContext = buildExclusionContext(exclusions);
|
||||||
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
|
const exclusionHint = exclusionContext.prompt
|
||||||
|
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
ok(`AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
ok(`AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
||||||
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||||
|
|||||||
+86
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js';
|
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
||||||
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
|
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
describe('findings exclusions', () => {
|
describe('findings exclusions', () => {
|
||||||
@@ -41,6 +41,47 @@ describe('findings exclusions', () => {
|
|||||||
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
|
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs exclusions wrapper format to a top-level array', () => {
|
||||||
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify({
|
||||||
|
exclusions: [
|
||||||
|
{ location: 'README.md:12', suggestion: 'keep' },
|
||||||
|
],
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
const exclusions = loadExclusions(workspace);
|
||||||
|
const repaired = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(exclusions.length, 1);
|
||||||
|
assert.ok(Array.isArray(repaired));
|
||||||
|
assert.equal(repaired[0].location, 'README.md:12');
|
||||||
|
assert.equal(repaired[0].suggestion, 'keep');
|
||||||
|
assert.ok(logs.some(line => line.includes('排除問題格式已修正為頂層陣列: source=exclusions -> array')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors repaired exclusions into the workspace root when requested', () => {
|
||||||
|
const repoRoot = path.join(workspace, 'repo');
|
||||||
|
const mirrorRoot = path.join(workspace, 'workspace');
|
||||||
|
const repoFullPath = path.join(repoRoot, EXCLUSIONS_PATH);
|
||||||
|
const mirrorFullPath = path.join(mirrorRoot, EXCLUSIONS_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(repoFullPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(mirrorFullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(repoFullPath, JSON.stringify({
|
||||||
|
exclusions: [
|
||||||
|
{ location: 'README.md:12', suggestion: 'keep' },
|
||||||
|
],
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
const exclusions = loadExclusions(repoRoot, null, mirrorRoot);
|
||||||
|
const mirror = JSON.parse(fs.readFileSync(mirrorFullPath, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(exclusions.length, 1);
|
||||||
|
assert.ok(Array.isArray(mirror));
|
||||||
|
assert.equal(mirror[0].location, 'README.md:12');
|
||||||
|
assert.equal(mirror[0].suggestion, 'keep');
|
||||||
|
});
|
||||||
|
|
||||||
it('applies exclusions loaded from wrapper format', () => {
|
it('applies exclusions loaded from wrapper format', () => {
|
||||||
const findings = [
|
const findings = [
|
||||||
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
|
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
|
||||||
@@ -56,6 +97,50 @@ describe('findings exclusions', () => {
|
|||||||
assert.equal(filtered[0].location, 'README.md:12');
|
assert.equal(filtered[0].location, 'README.md:12');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dedupes repeated exclusions 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', title: 'fetch_package_versions jq overhead' },
|
||||||
|
{ location: 'entrypoint.sh:999', title: 'fetch_package_versions jq overhead' },
|
||||||
|
{ 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].filePath, 'entrypoint.sh');
|
||||||
|
assert.equal(exclusions[0].text, 'fetch_package_versions jq overhead');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a compact exclusion hint for AI', async () => {
|
||||||
|
const findings = [
|
||||||
|
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', suggestion: 'update tests' },
|
||||||
|
];
|
||||||
|
const exclusions = [
|
||||||
|
{ location: 'src/app.cs:1', original_finding: '更新套件後請補上測試驗證' },
|
||||||
|
{ location: 'src/app.cs:99', original_finding: '更新套件後請補上測試驗證 ' },
|
||||||
|
{ location: 'src/service.cs:3', original_finding: '更新套件後請補上測試驗證' },
|
||||||
|
{ location: 'src/service.cs:8', title: '請確認安全性變更' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let capturedSystemPrompt = '';
|
||||||
|
let capturedUserContent = '';
|
||||||
|
const result = await filterFalsePositivesWithAI(findings, exclusions, async (systemPrompt, userContent) => {
|
||||||
|
capturedSystemPrompt = systemPrompt;
|
||||||
|
capturedUserContent = userContent;
|
||||||
|
return findings;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
assert.ok(capturedSystemPrompt.includes('已知誤報清單(原始 4 筆,整理後 3 筆,分成 2 類)'));
|
||||||
|
assert.ok(capturedSystemPrompt.includes('更新套件後請補上測試驗證'));
|
||||||
|
assert.ok(capturedSystemPrompt.includes('paths=src/app.cs, src/service.cs'));
|
||||||
|
assert.ok(capturedSystemPrompt.includes('請確認安全性變更'));
|
||||||
|
assert.ok(capturedUserContent.includes('"location":"src/app.cs:12"'));
|
||||||
|
assert.ok(capturedUserContent.includes('"suggestion":"update tests"'));
|
||||||
|
});
|
||||||
|
|
||||||
it('logs exclusions file metadata and repo state when loading exclusions', () => {
|
it('logs exclusions file metadata and repo state when loading exclusions', () => {
|
||||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
|||||||
+216
-13
@@ -2,8 +2,8 @@ import { spawnSync } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH, getLLMConfig } from './config.js';
|
||||||
import { line, ok, warn } from './log.js';
|
import { line, ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||||||
@@ -11,15 +11,41 @@ const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.gi
|
|||||||
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||||||
export const SYNC_PATHS = [
|
export const SYNC_PATHS = [
|
||||||
'.amazonq/rules/triage-findings.md',
|
'.amazonq/rules/triage-findings.md',
|
||||||
|
'.agents/skills/triage-findings/SKILL.md',
|
||||||
|
'.antigravity/skills/triage-findings/SKILL.md',
|
||||||
'.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',
|
||||||
'.claude/skills/triage-findings/SKILL.md',
|
'.claude/skills/triage-findings/SKILL.md',
|
||||||
'.gemini/skills/triage-findings/SKILL.md',
|
'.gemini/skills/triage-findings/SKILL.md',
|
||||||
'.github/copilot-instructions.md',
|
'.github/copilot-instructions.md',
|
||||||
'.github/skills/triage-findings/SKILL.md',
|
'.github/skills/triage-findings/SKILL.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'ANTIGRAVITY.md',
|
||||||
'CLAUDE.md',
|
'CLAUDE.md',
|
||||||
'GEMINI.md',
|
'GEMINI.md',
|
||||||
];
|
];
|
||||||
|
const FORCE_SYNC_FILE_PATHS = [
|
||||||
|
'.github/copilot-instructions.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'ANTIGRAVITY.md',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'GEMINI.md',
|
||||||
|
];
|
||||||
|
const MERGE_SYNC_FILE_PATHS = new Set([
|
||||||
|
'AGENTS.md',
|
||||||
|
'ANTIGRAVITY.md',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'GEMINI.md',
|
||||||
|
]);
|
||||||
|
let instructionMergeAssistantPromise = null;
|
||||||
|
const SYNC_TREE_PATHS = [
|
||||||
|
'.agents/skills/triage-findings',
|
||||||
|
'.antigravity/skills/triage-findings',
|
||||||
|
'.codex/skills/triage-findings',
|
||||||
|
'.claude/skills/triage-findings',
|
||||||
|
'.gemini/skills/triage-findings',
|
||||||
|
'.github/skills/triage-findings',
|
||||||
|
];
|
||||||
|
|
||||||
function makeRunner(spawn) {
|
function makeRunner(spawn) {
|
||||||
return function run(args, cwd, env) {
|
return function run(args, cwd, env) {
|
||||||
@@ -51,6 +77,169 @@ function readGitOutput(run, args, cwd, env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(text) {
|
||||||
|
return text.replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTextBlocks(text) {
|
||||||
|
const normalized = normalizeText(text).replace(/\n+$/, '');
|
||||||
|
if (!normalized) return [];
|
||||||
|
return normalized.split(/\n{2,}/).map(block => block.trimEnd()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeText(existingText, sourceText) {
|
||||||
|
const existing = normalizeText(existingText);
|
||||||
|
const source = normalizeText(sourceText);
|
||||||
|
if (existing === source) return existing;
|
||||||
|
|
||||||
|
const mergedBlocks = splitTextBlocks(existing);
|
||||||
|
const seenBlocks = new Set(mergedBlocks.map(block => block.trim()));
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const block of splitTextBlocks(source)) {
|
||||||
|
const key = block.trim();
|
||||||
|
if (seenBlocks.has(key)) continue;
|
||||||
|
seenBlocks.add(key);
|
||||||
|
mergedBlocks.push(block);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return existing;
|
||||||
|
return `${mergedBlocks.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBlocksFromTexts(...texts) {
|
||||||
|
const seen = new Set();
|
||||||
|
const blocks = [];
|
||||||
|
for (const text of texts) {
|
||||||
|
for (const block of splitTextBlocks(text)) {
|
||||||
|
const key = block.trim();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
blocks.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMergedInstructionText(mergedText, requiredBlocks) {
|
||||||
|
const candidate = normalizeText(mergedText);
|
||||||
|
return requiredBlocks.every(block => candidate.includes(normalizeText(block).trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class InstructionMergeError extends Error {
|
||||||
|
constructor(message, options) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'InstructionMergeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortInstructionMerge(message) {
|
||||||
|
error(message);
|
||||||
|
process.exit(1);
|
||||||
|
throw new InstructionMergeError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFileOverwrite(sourceRoot, repoDir, relPath) {
|
||||||
|
const src = path.join(sourceRoot, relPath);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstructionMergeAssistant() {
|
||||||
|
const { provider } = getLLMConfig();
|
||||||
|
if (!provider) return null;
|
||||||
|
if (instructionMergeAssistantPromise) return instructionMergeAssistantPromise;
|
||||||
|
|
||||||
|
instructionMergeAssistantPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const { chatJSON } = await import('./llm.js');
|
||||||
|
return async ({ relPath, existingText, sourceText, deterministicText }) => {
|
||||||
|
const systemPrompt = [
|
||||||
|
'You merge repository instruction files without losing any skill, command, or rule.',
|
||||||
|
'Never delete unique content from either input.',
|
||||||
|
'You may only remove exact duplicates or improve ordering/formatting.',
|
||||||
|
'Return JSON with a single field: merged_text.',
|
||||||
|
].join(' ');
|
||||||
|
const userContent = JSON.stringify({
|
||||||
|
path: relPath,
|
||||||
|
existing_text: existingText,
|
||||||
|
source_text: sourceText,
|
||||||
|
deterministic_candidate: deterministicText,
|
||||||
|
});
|
||||||
|
const result = await chatJSON(systemPrompt, userContent);
|
||||||
|
if (typeof result === 'string') return result;
|
||||||
|
if (result && typeof result.merged_text === 'string') return result.merged_text;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
warn(`[merge] AI instruction merge unavailable: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return instructionMergeAssistantPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant = null) {
|
||||||
|
const deterministic = mergeText(existingText, sourceText);
|
||||||
|
const requiredBlocks = uniqueBlocksFromTexts(existingText, sourceText);
|
||||||
|
if (!aiMergeAssistant || requiredBlocks.length === 0) return deterministic;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
|
||||||
|
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
|
||||||
|
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
|
||||||
|
}
|
||||||
|
abortInstructionMerge(`[merge] ${relPath} AI result rejected; refusing fallback`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof InstructionMergeError) throw e;
|
||||||
|
abortInstructionMerge(`[merge] ${relPath} AI merge failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant = null) {
|
||||||
|
const src = path.join(sourceRoot, relPath);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingText = fs.readFileSync(dest, 'utf8');
|
||||||
|
const sourceText = fs.readFileSync(src, 'utf8');
|
||||||
|
const merged = await mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant);
|
||||||
|
if (merged !== existingText) {
|
||||||
|
fs.writeFileSync(dest, merged, 'utf8');
|
||||||
|
}
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTree(sourceRoot, repoDir, relDir) {
|
||||||
|
const srcDir = path.join(sourceRoot, relDir);
|
||||||
|
if (!fs.existsSync(srcDir)) return [];
|
||||||
|
|
||||||
|
const copied = [];
|
||||||
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||||
|
const relPath = path.join(relDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copied.push(...syncTree(sourceRoot, repoDir, relPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const synced = syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
|
if (synced) copied.push(synced);
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||||||
@@ -101,21 +290,35 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSyncPaths = [];
|
const existingSyncPaths = new Set();
|
||||||
|
const aiMergeAssistant = await getInstructionMergeAssistant();
|
||||||
|
|
||||||
// Copy action skill files into the target repo. Existing files are overwritten;
|
// Copy action skill trees into the target repo. Existing files are merged with
|
||||||
// missing source files are ignored so we do not delete target repo content.
|
// the action source; missing source files are ignored so we do not delete
|
||||||
for (const relPath of SYNC_PATHS) {
|
// target repo content.
|
||||||
const src = path.join(sourceRoot, relPath);
|
for (const relDir of SYNC_TREE_PATHS) {
|
||||||
const dest = path.join(repoDir, relPath);
|
for (const relPath of syncTree(sourceRoot, repoDir, relDir)) {
|
||||||
if (fs.existsSync(src)) {
|
existingSyncPaths.add(relPath);
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
existingSyncPaths.push(relPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingSyncPaths.length > 0) {
|
// Merge only the direct instruction files that must preserve repository-specific
|
||||||
|
// skills, commands, and rules. Everything else keeps the source copy.
|
||||||
|
for (const relPath of FORCE_SYNC_FILE_PATHS) {
|
||||||
|
const copied = MERGE_SYNC_FILE_PATHS.has(relPath)
|
||||||
|
? await syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant)
|
||||||
|
: syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
|
if (copied) existingSyncPaths.add(copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge standalone action files into the target repo.
|
||||||
|
for (const relPath of SYNC_PATHS) {
|
||||||
|
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
|
||||||
|
const copied = syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
|
if (copied) existingSyncPaths.add(copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSyncPaths.size > 0) {
|
||||||
run(['add', ...existingSyncPaths], repoDir);
|
run(['add', ...existingSyncPaths], repoDir);
|
||||||
}
|
}
|
||||||
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||||||
|
|||||||
+79
-7
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -130,10 +130,14 @@ describe('commitAndPush', () => {
|
|||||||
assert.ok(generatedAddCall, 'expected git add for generated review files');
|
assert.ok(generatedAddCall, 'expected git add for generated review files');
|
||||||
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
||||||
|
assert.ok(skillAddCall.args.includes('.agents/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
|
assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
|
||||||
|
assert.ok(skillAddCall.args.includes('.antigravity/skills/triage-findings/SKILL.md'));
|
||||||
assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
|
assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
|
||||||
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
|
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
|
||||||
|
assert.ok(skillAddCall.args.includes('AGENTS.md'));
|
||||||
|
assert.ok(skillAddCall.args.includes('ANTIGRAVITY.md'));
|
||||||
assert.ok(skillAddCall.args.includes('CLAUDE.md'));
|
assert.ok(skillAddCall.args.includes('CLAUDE.md'));
|
||||||
assert.ok(skillAddCall.args.includes('GEMINI.md'));
|
assert.ok(skillAddCall.args.includes('GEMINI.md'));
|
||||||
assert.ok(!skillAddCall.args.includes('README.md'));
|
assert.ok(!skillAddCall.args.includes('README.md'));
|
||||||
@@ -155,15 +159,79 @@ describe('commitAndPush', () => {
|
|||||||
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites existing repo copies with workspace files', async () => {
|
it('merges existing repo copies with workspace files', async () => {
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc');
|
||||||
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'repo antigravity doc');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'repo claude doc');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'repo gemini doc');
|
||||||
|
|
||||||
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
||||||
|
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md');
|
const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
|
const antigravityDoc = fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8');
|
||||||
|
const claudeDoc = fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8');
|
||||||
|
const geminiDoc = fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(agentsDoc.includes('repo agents doc'));
|
||||||
|
assert.ok(agentsDoc.includes('AGENTS.md'));
|
||||||
|
assert.ok(antigravityDoc.includes('repo antigravity doc'));
|
||||||
|
assert.ok(antigravityDoc.includes('ANTIGRAVITY.md'));
|
||||||
|
assert.ok(claudeDoc.includes('repo claude doc'));
|
||||||
|
assert.ok(claudeDoc.includes('CLAUDE.md'));
|
||||||
|
assert.ok(geminiDoc.includes('repo gemini doc'));
|
||||||
|
assert.ok(geminiDoc.includes('GEMINI.md'));
|
||||||
|
assert.ok(agentsDoc.includes('repo agents doc'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts AI merged instruction text when all unique blocks are preserved', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const aiMergeAssistant = async payload => {
|
||||||
|
calls.push(payload);
|
||||||
|
return ['repo block', 'source block', 'extra block'].join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(result.includes('repo block'));
|
||||||
|
assert.ok(result.includes('source block'));
|
||||||
|
assert.ok(result.includes('extra block'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits when AI output drops a block', async () => {
|
||||||
|
const originalExit = process.exit;
|
||||||
|
let exitCode = null;
|
||||||
|
process.exit = code => { exitCode = code; };
|
||||||
|
try {
|
||||||
|
const aiMergeAssistant = async () => 'source block only';
|
||||||
|
await assert.rejects(() => mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant));
|
||||||
|
assert.equal(exitCode, 1);
|
||||||
|
} finally {
|
||||||
|
process.exit = originalExit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites non-merge sync files with workspace files', async () => {
|
||||||
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
const sourceSkillPath = path.join(sourceRoot, '.github/skills/triage-findings/SKILL.md');
|
||||||
|
const repoSkillPath = path.join(repoDir, '.github/skills/triage-findings/SKILL.md');
|
||||||
|
const sourceNestedPath = path.join(sourceRoot, '.codex/skills/triage-findings/assets/example.txt');
|
||||||
|
const repoNestedPath = path.join(repoDir, '.codex/skills/triage-findings/assets/example.txt');
|
||||||
|
|
||||||
|
fs.writeFileSync(sourceSkillPath, 'fresh github skill');
|
||||||
|
fs.writeFileSync(repoSkillPath, 'stale github skill');
|
||||||
|
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
|
||||||
|
fs.writeFileSync(sourceNestedPath, 'fresh nested');
|
||||||
|
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
|
||||||
|
fs.writeFileSync(repoNestedPath, 'stale nested');
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale copilot');
|
||||||
|
|
||||||
|
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(repoSkillPath, 'utf8'), 'fresh github skill');
|
||||||
|
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh nested');
|
||||||
|
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when git command fails', async () => {
|
it('does not throw when git command fails', async () => {
|
||||||
@@ -185,12 +253,16 @@ describe('commitAndPush', () => {
|
|||||||
});
|
});
|
||||||
const logs = [];
|
const logs = [];
|
||||||
const originalLog = console.log;
|
const originalLog = console.log;
|
||||||
console.log = (...args) => { logs.push(args.join(' ')); };
|
const originalWarn = console.warn;
|
||||||
|
const capture = (...args) => { logs.push(args.join(' ')); };
|
||||||
|
console.log = capture;
|
||||||
|
console.warn = capture;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
} finally {
|
} finally {
|
||||||
console.log = originalLog;
|
console.log = originalLog;
|
||||||
|
console.warn = originalWarn;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ export async function getPRDiff() {
|
|||||||
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
||||||
return filterDiff(resp.data, [
|
return filterDiff(resp.data, [
|
||||||
'.amazonq/',
|
'.amazonq/',
|
||||||
|
'.agents/',
|
||||||
|
'.antigravity/',
|
||||||
'.claude/',
|
'.claude/',
|
||||||
'.codex/',
|
'.codex/',
|
||||||
'.gemini/',
|
'.gemini/',
|
||||||
'.gitea/',
|
'.gitea/',
|
||||||
'.github/',
|
'.github/',
|
||||||
|
'AGENTS.md',
|
||||||
|
'ANTIGRAVITY.md',
|
||||||
'CLAUDE.md',
|
'CLAUDE.md',
|
||||||
'GEMINI.md',
|
'GEMINI.md',
|
||||||
'README.md',
|
'README.md',
|
||||||
|
|||||||
+2
-2
@@ -119,8 +119,8 @@ describe('filterDiff', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty string when all blocks are excluded', () => {
|
it('returns empty string when all blocks are excluded', () => {
|
||||||
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('CLAUDE.md');
|
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('.agents/skills/triage-findings/SKILL.md');
|
||||||
const result = filterDiff(diff, ['.gitea/', 'CLAUDE.md']);
|
const result = filterDiff(diff, ['.gitea/', '.agents/']);
|
||||||
assert.equal(result, '');
|
assert.equal(result, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export function ok(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function warn(message) {
|
export function warn(message) {
|
||||||
console.log(` ! ${message}`);
|
console.warn(` ! ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function error(message) {
|
export function error(message) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { section, step, line, ok, warn, error } from './log.js';
|
||||||
|
|
||||||
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
|
describe('log helpers', () => {
|
||||||
|
it('formats section and step messages', () => {
|
||||||
|
const calls = [];
|
||||||
|
mock.method(console, 'log', (...args) => {
|
||||||
|
calls.push(args.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
section('Pipeline');
|
||||||
|
step('Step1', 'Start');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'\n=== Pipeline ===',
|
||||||
|
'\n[Step1] Start',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats line and ok messages with console.log', () => {
|
||||||
|
const calls = [];
|
||||||
|
mock.method(console, 'log', (...args) => {
|
||||||
|
calls.push(args.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
line('hello');
|
||||||
|
ok('done');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
' - hello',
|
||||||
|
' ✓ done',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats warn messages with console.warn', () => {
|
||||||
|
const calls = [];
|
||||||
|
mock.method(console, 'warn', (...args) => {
|
||||||
|
calls.push(args.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
warn('careful');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [' ! careful']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats error messages with console.error', () => {
|
||||||
|
const calls = [];
|
||||||
|
mock.method(console, 'error', (...args) => {
|
||||||
|
calls.push(args.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
error('boom');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [' x boom']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-5
@@ -76,7 +76,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||||
|
|
||||||
step('Step3', 'Findings 合併');
|
step('Step3', 'Findings 合併與語意去重');
|
||||||
let repoDir;
|
let repoDir;
|
||||||
try {
|
try {
|
||||||
repoDir = cloneRepo(WORKSPACE);
|
repoDir = cloneRepo(WORKSPACE);
|
||||||
@@ -90,14 +90,12 @@ async function main() {
|
|||||||
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||||
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||||
ok(`Step3 merged findings total=${mergedFindings.length}`);
|
ok(`Step3 merged findings total=${mergedFindings.length}`);
|
||||||
|
|
||||||
step('Step3b', 'AI 語意去重');
|
|
||||||
const deduped = await deduplicateWithAI(mergedFindings);
|
const deduped = await deduplicateWithAI(mergedFindings);
|
||||||
const sorted = sortByLevel(deduped);
|
const sorted = sortByLevel(deduped);
|
||||||
ok(`Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
||||||
|
|
||||||
step('Step4', 'AI 排除問題過濾');
|
step('Step4', 'AI 排除問題過濾');
|
||||||
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
|
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
|
||||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||||
ok(`Step4 完成: findings total=${filtered.length}`);
|
ok(`Step4 完成: findings total=${filtered.length}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user