Compare commits
10 Commits
v0.2.3
...
v0.2.6-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 198bfce64e | |||
| 7138250b22 | |||
| 0e08f5c4d8 | |||
| 6f2bbab041 | |||
| c66a9aa025 | |||
| 3bfdefa4ba | |||
| 1492f1b9b4 | |||
| a9ac7857be | |||
| 3f78543159 | |||
| 98eaeb6050 |
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
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,41 +0,0 @@
|
|||||||
# 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,46 +0,0 @@
|
|||||||
---
|
|
||||||
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,46 +0,0 @@
|
|||||||
---
|
|
||||||
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,46 +0,0 @@
|
|||||||
---
|
|
||||||
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,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Triage Findings"
|
|
||||||
short_description: "Triage, sort, fix, and exclude review findings"
|
|
||||||
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to `.gitea/ai-review/exclusions.json` as a top-level JSON array."
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -169,18 +169,6 @@
|
|||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"location": "Dockerfile, app/git.js, app/git.test.js",
|
|
||||||
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"location": "Dockerfile",
|
|
||||||
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"location": "Dockerfile",
|
|
||||||
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"role": "Bard",
|
"role": "Bard",
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
@@ -345,13 +333,6 @@
|
|||||||
"location": "app/log.js",
|
"location": "app/log.js",
|
||||||
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "Dockerfile, app/git.js, app/gitea.js",
|
|
||||||
"suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"role": "Assassin",
|
"role": "Assassin",
|
||||||
"location": "app/preflight.js:12",
|
"location": "app/preflight.js:12",
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Bard",
|
|
||||||
"location": "app/comments.test.js:172",
|
|
||||||
"suggestion": "此處斷言使用了魔術字串 `/嚴重問題/`,就像樂譜中突然出現的無標記音符,雖能理解,卻少了點優雅與明確。建議將此字串提取為一個具名常數,或至少賦予一個描述性變數,以提升可讀性與未來維護的便利性,讓意圖更加清晰。",
|
|
||||||
"is_new": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Triage Findings
|
|
||||||
|
|
||||||
Use the triage-finding workflow for review issue lists:
|
|
||||||
|
|
||||||
1. Merge findings into one list.
|
|
||||||
2. Remove duplicates.
|
|
||||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
|
||||||
4. Renumber from 1.
|
|
||||||
5. Fix real issues with the smallest safe change.
|
|
||||||
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
|
||||||
7. Add or update tests when behavior changes.
|
|
||||||
8. Re-check after each fix.
|
|
||||||
|
|
||||||
The full reusable skill lives in `.github/skills/triage-findings/SKILL.md`.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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,16 +0,0 @@
|
|||||||
# Triage Findings
|
|
||||||
|
|
||||||
When the task is to triage review findings, follow this workflow:
|
|
||||||
|
|
||||||
1. Merge all findings into one list.
|
|
||||||
2. Remove duplicates.
|
|
||||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
|
||||||
4. Renumber from 1 after sorting.
|
|
||||||
5. Fix real issues with the smallest safe change.
|
|
||||||
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
|
||||||
7. Add or update tests when behavior changes.
|
|
||||||
8. Re-check the issue after each fix.
|
|
||||||
|
|
||||||
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
|
||||||
|
|
||||||
Trigger it with `/triage-findings`.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Triage Findings
|
|
||||||
|
|
||||||
When the task is to triage review findings, follow this workflow:
|
|
||||||
|
|
||||||
1. Merge all findings into one list.
|
|
||||||
2. Remove duplicates.
|
|
||||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
|
||||||
4. Renumber from 1 after sorting.
|
|
||||||
5. Fix real issues with the smallest safe change.
|
|
||||||
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
|
||||||
7. Add or update tests when behavior changes.
|
|
||||||
8. Re-check the issue after each fix.
|
|
||||||
|
|
||||||
Use the repo-local `triage-findings` skill for the same workflow when running in Claude.
|
|
||||||
|
|
||||||
Trigger it with `/triage-findings`.
|
|
||||||
-10
@@ -10,16 +10,6 @@ WORKDIR /action
|
|||||||
COPY app/package.json /action/app/
|
COPY app/package.json /action/app/
|
||||||
RUN cd /action/app && npm install
|
RUN cd /action/app && npm install
|
||||||
|
|
||||||
COPY .amazonq/ /action/.amazonq/
|
|
||||||
COPY .codex/ /action/.codex/
|
|
||||||
COPY .agents/ /action/.agents/
|
|
||||||
COPY .claude/ /action/.claude/
|
|
||||||
COPY .gemini/ /action/.gemini/
|
|
||||||
COPY .github/ /action/.github/
|
|
||||||
COPY AGENTS.md /action/
|
|
||||||
COPY CLAUDE.md /action/
|
|
||||||
COPY GEMINI.md /action/
|
|
||||||
|
|
||||||
COPY app/ /action/app/
|
COPY app/ /action/app/
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Triage Findings
|
|
||||||
|
|
||||||
Use the triage-finding workflow for review issue lists:
|
|
||||||
|
|
||||||
1. Merge findings into one list.
|
|
||||||
2. Remove duplicates.
|
|
||||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
|
||||||
4. Renumber from 1.
|
|
||||||
5. Fix real issues with the smallest safe change.
|
|
||||||
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
|
|
||||||
7. Add or update tests when behavior changes.
|
|
||||||
8. Re-check after each fix.
|
|
||||||
|
|
||||||
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
||||||
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
||||||
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment
|
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
8. Commit 問題檔案,只將 workspace 中實際存在的 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 覆蓋到記憶區;workspace 沒有的問題檔就略過。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
# 設計
|
# 設計
|
||||||
@@ -28,7 +28,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/`、`.agents/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
|
7. 讀取 Git Diff 時排除 `.gitea/`、`.github/` 資料夾,以及 `TODO.md`、`README.md`,避免 AI 分析 workflow 設定與文件等非業務程式碼
|
||||||
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 筆數,方便追查讀檔與分支內容不一致的問題
|
||||||
@@ -74,6 +74,14 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
|
OpenAI GPT-5.5 會透過 Responses API 呼叫;設定方式仍使用 `OPENAI_*`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL: gpt-5.5
|
||||||
|
```
|
||||||
|
|
||||||
### 2. OpenRouter
|
### 2. OpenRouter
|
||||||
```yaml
|
```yaml
|
||||||
name: AI
|
name: AI
|
||||||
@@ -192,7 +200,54 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Ollama
|
### 6. OpenCode Server
|
||||||
|
```yaml
|
||||||
|
name: AI
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- master
|
||||||
|
types: [opened, synchronize]
|
||||||
|
jobs:
|
||||||
|
code-review:
|
||||||
|
name: Code Review
|
||||||
|
runs-on: ubuntu
|
||||||
|
steps:
|
||||||
|
- name: AI Code Review
|
||||||
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
OPENCODE_BASE_URL: http://192.168.3.124:4096
|
||||||
|
OPENCODE_PROVIDER: google
|
||||||
|
OPENCODE_MODEL: gemini-2.5-flash
|
||||||
|
# 若 OpenCode server 有設定 OPENCODE_SERVER_PASSWORD,才需要提供:
|
||||||
|
# OPENCODE_SERVER_USERNAME: opencode
|
||||||
|
# OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenCode Server 串接方式會呼叫 server root 的 `/session` 與 `/session/{sessionID}/message`,並把模型指定為 `providerID=google`、`modelID=gemini-2.5-flash`。可用的內部 OpenCode server:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENCODE_BASE_URL: https://opencode.jsc.idv.me
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENCODE_BASE_URL: http://192.168.3.124:4096
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenCode server 本身必須已設定好 `google` provider 與 `gemini-2.5-flash` model;此 action 不會把 Google API key 傳給 OpenCode server。
|
||||||
|
|
||||||
|
### 7. Ollama
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: AI
|
name: AI
|
||||||
@@ -221,33 +276,3 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
## Skill:Triage Findings
|
|
||||||
|
|
||||||
這份 skill 用來處理 review 問題清單。
|
|
||||||
|
|
||||||
### 規則
|
|
||||||
|
|
||||||
1. 合併問題。
|
|
||||||
2. 依嚴重度排序:`critical` -> `warning` -> `info`。
|
|
||||||
3. 重新編號。
|
|
||||||
4. 真問題就修。
|
|
||||||
5. 誤判就加到 `.gitea/ai-review/exclusions.json`。
|
|
||||||
6. 有變更就補測試。
|
|
||||||
|
|
||||||
### 使用方式
|
|
||||||
|
|
||||||
Codex:`$triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
Copilot:`/triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
Claude:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
Gemini:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
Antigravity:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|
||||||
|
|
||||||
### 適用情境
|
|
||||||
|
|
||||||
`triage-findings 問題原始檔(文字或截圖)` 用在 review 問題整併、排序、修正、排除誤判。
|
|
||||||
|
|
||||||
### 版本包含
|
|
||||||
|
|
||||||
提交時一併包含 `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,7 +6,7 @@
|
|||||||
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
- 已驗收:`code-review` job 的 log 已完整出現 `Step1` 到 `Step8`,並以 `Pipeline 完成` 結束。
|
||||||
|
|
||||||
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
## 階段二:Git Diff 排除 .gitea/ 資料夾
|
||||||
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,以及 `.amazonq/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼。
|
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,以及 `.github/`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定與文件等非業務程式碼。
|
||||||
- 驗收:PR 中有上述路徑或檔案的變更時,diff 內容不包含該區塊,AI 分析結果不含這些路徑相關問題。
|
- 驗收:PR 中有上述路徑或檔案的變更時,diff 內容不包含該區塊,AI 分析結果不含這些路徑相關問題。
|
||||||
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
- 已驗收:`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
- 已驗收: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 與各平台入口檔;`AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋;workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,且只提交 workspace 中實際存在的 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json`;workspace 沒有的問題檔就略過;錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息;git add 只包含新的問題檔;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
|
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且只提交問題檔的行為已有單元測試覆蓋。
|
||||||
|
|
||||||
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||||
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
||||||
|
|||||||
+22
@@ -79,6 +79,23 @@ inputs:
|
|||||||
description: 'Amazon Q Base URL'
|
description: 'Amazon Q Base URL'
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
# OpenCode Server
|
||||||
|
OPENCODE_BASE_URL:
|
||||||
|
description: 'OpenCode server Base URL'
|
||||||
|
required: false
|
||||||
|
OPENCODE_MODEL:
|
||||||
|
description: 'OpenCode model id'
|
||||||
|
required: false
|
||||||
|
OPENCODE_PROVIDER:
|
||||||
|
description: 'OpenCode server provider id'
|
||||||
|
required: false
|
||||||
|
OPENCODE_SERVER_USERNAME:
|
||||||
|
description: 'OpenCode server Basic Auth username'
|
||||||
|
required: false
|
||||||
|
OPENCODE_SERVER_PASSWORD:
|
||||||
|
description: 'OpenCode server Basic Auth password'
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
@@ -107,3 +124,8 @@ runs:
|
|||||||
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
|
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
|
||||||
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
|
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
|
||||||
AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }}
|
AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }}
|
||||||
|
OPENCODE_BASE_URL: ${{ inputs.OPENCODE_BASE_URL }}
|
||||||
|
OPENCODE_MODEL: ${{ inputs.OPENCODE_MODEL }}
|
||||||
|
OPENCODE_PROVIDER: ${{ inputs.OPENCODE_PROVIDER }}
|
||||||
|
OPENCODE_SERVER_USERNAME: ${{ inputs.OPENCODE_SERVER_USERNAME }}
|
||||||
|
OPENCODE_SERVER_PASSWORD: ${{ inputs.OPENCODE_SERVER_PASSWORD }}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ describe('postNewCriticalComments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
|
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
|
||||||
|
const criticalCommentPattern = /嚴重問題/;
|
||||||
const inlineCalls = [];
|
const inlineCalls = [];
|
||||||
const issueCalls = [];
|
const issueCalls = [];
|
||||||
const findings = [
|
const findings = [
|
||||||
@@ -181,6 +182,6 @@ describe('postNewCriticalComments', () => {
|
|||||||
assert.equal(inlineCalls[0].path, 'app/a.js');
|
assert.equal(inlineCalls[0].path, 'app/a.js');
|
||||||
assert.equal(inlineCalls[0].line, 10);
|
assert.equal(inlineCalls[0].line, 10);
|
||||||
assert.equal(issueCalls.length, 2);
|
assert.equal(issueCalls.length, 2);
|
||||||
assert.ok(issueCalls.every(b => /嚴重問題/.test(b)));
|
assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function getLLMConfig() {
|
|||||||
['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
|
['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
|
||||||
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
|
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
|
||||||
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
|
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
|
||||||
|
['opencode', ['opencode'], process.env.OPENCODE_BASE_URL, process.env.OPENCODE_MODEL || 'gemini-2.5-flash'],
|
||||||
];
|
];
|
||||||
for (const [provider, apiKeys, baseURL, model] of checks) {
|
for (const [provider, apiKeys, baseURL, model] of checks) {
|
||||||
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
|
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const ENV_KEYS = [
|
|||||||
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -84,6 +86,24 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'my-amazon-model');
|
assert.equal(cfg.model, 'my-amazon-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects opencode server with gemini defaults', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
assert.equal(cfg.provider, 'opencode');
|
||||||
|
assert.deepEqual(cfg.apiKeys, ['opencode']);
|
||||||
|
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
||||||
|
assert.equal(cfg.model, 'gemini-2.5-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects opencode server with custom model', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
assert.equal(cfg.provider, 'opencode');
|
||||||
|
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
||||||
|
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
it('openai takes priority over gemini when both set', () => {
|
it('openai takes priority over gemini when both set', () => {
|
||||||
process.env.OPENAI_API_KEY = 'sk-test';
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
process.env.GEMINI_API_KEY = 'gemini-key';
|
process.env.GEMINI_API_KEY = 'gemini-key';
|
||||||
|
|||||||
+9
-242
@@ -1,51 +1,12 @@
|
|||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH, 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 REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||||||
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
|
||||||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||||||
export const SYNC_PATHS = [
|
|
||||||
'.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/agents/openai.yaml',
|
|
||||||
'.claude/skills/triage-findings/SKILL.md',
|
|
||||||
'.gemini/skills/triage-findings/SKILL.md',
|
|
||||||
'.github/copilot-instructions.md',
|
|
||||||
'.github/skills/triage-findings/SKILL.md',
|
|
||||||
'AGENTS.md',
|
|
||||||
'ANTIGRAVITY.md',
|
|
||||||
'CLAUDE.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) {
|
||||||
@@ -88,169 +49,6 @@ 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);
|
||||||
@@ -307,7 +105,7 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT, reviewOutcome = 'success') {
|
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -319,51 +117,20 @@ 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 = new Set();
|
const reviewFilePaths = REVIEW_FILE_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||||||
const aiMergeAssistant = await getInstructionMergeAssistant();
|
if (reviewFilePaths.length > 0) {
|
||||||
|
for (const relPath of reviewFilePaths) {
|
||||||
// Copy action skill trees into the target repo. Existing files are merged with
|
|
||||||
// the action source; missing source files are ignored so we do not delete
|
|
||||||
// target repo content.
|
|
||||||
for (const relDir of SYNC_TREE_PATHS) {
|
|
||||||
for (const relPath of syncTree(sourceRoot, repoDir, relDir)) {
|
|
||||||
existingSyncPaths.add(relPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
|
||||||
if (generatedSyncPaths.length > 0) {
|
|
||||||
for (const relPath of generatedSyncPaths) {
|
|
||||||
const src = path.join(workspace, relPath);
|
const src = path.join(workspace, relPath);
|
||||||
const dest = path.join(repoDir, relPath);
|
const dest = path.join(repoDir, relPath);
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
fs.copyFileSync(src, dest);
|
fs.copyFileSync(src, dest);
|
||||||
}
|
}
|
||||||
run(['add', ...generatedSyncPaths], repoDir);
|
run(['add', ...reviewFilePaths], repoDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = run(['status', '--porcelain'], repoDir);
|
const status = run(['status', '--porcelain'], repoDir);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
line('sync files 無變更,跳過 commit');
|
line('review files 無變更,跳過 commit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-109
@@ -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, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
import { commitAndPush, cloneRepo, verifyRemoteAccess, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -13,13 +13,7 @@ function makeTmpWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeActionSource() {
|
function makeActionSource() {
|
||||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
|
||||||
for (const relPath of SYNC_PATHS) {
|
|
||||||
const fullPath = path.join(sourceRoot, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
||||||
fs.writeFileSync(fullPath, relPath);
|
|
||||||
}
|
|
||||||
return sourceRoot;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default stub: all commands succeed, status returns changes
|
// Default stub: all commands succeed, status returns changes
|
||||||
@@ -125,7 +119,7 @@ describe('commitAndPush', () => {
|
|||||||
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds skill and entry files together with findings', async () => {
|
it('adds only generated review files', async () => {
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
|
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
@@ -136,114 +130,36 @@ describe('commitAndPush', () => {
|
|||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
|
const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
|
||||||
const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md'));
|
|
||||||
const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
|
const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
|
||||||
assert.ok(skillAddCall, 'expected git add for synced skill files');
|
|
||||||
assert.ok(generatedAddCall, 'expected git add for generated review files');
|
assert.ok(generatedAddCall, 'expected git add for generated review files');
|
||||||
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
|
||||||
assert.ok(skillAddCall.args.includes('.agents/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('.antigravity/skills/triage-findings/SKILL.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('AGENTS.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('ANTIGRAVITY.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('CLAUDE.md'));
|
|
||||||
assert.ok(skillAddCall.args.includes('GEMINI.md'));
|
|
||||||
assert.ok(!skillAddCall.args.includes('README.md'));
|
|
||||||
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json'));
|
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json'));
|
||||||
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json'));
|
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json'));
|
||||||
|
assert.equal(addCalls.length, 1, 'expected only generated review files to be staged');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps repo copies when the source sync file is missing', async () => {
|
it('does not overwrite or add action source files', async () => {
|
||||||
const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
fs.rmSync(missingPath, { force: true });
|
const sourceDocPath = path.join(sourceRoot, 'docs/source-only.md');
|
||||||
const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md');
|
const repoDocPath = path.join(repoDir, 'docs/source-only.md');
|
||||||
fs.writeFileSync(repoPath, 'stale');
|
const repoConfigPath = path.join(repoDir, 'project-notes.md');
|
||||||
|
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
|
||||||
|
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(sourceDocPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(repoDocPath), { recursive: true });
|
||||||
|
fs.writeFileSync(sourceDocPath, 'fresh action source doc');
|
||||||
|
fs.writeFileSync(repoDocPath, 'existing repo doc');
|
||||||
|
fs.writeFileSync(repoConfigPath, 'existing repo notes');
|
||||||
|
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
||||||
|
const addedArgs = spawn.calls.filter(c => c.args[0] === 'add').flatMap(c => c.args);
|
||||||
|
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
assert.equal(fs.readFileSync(repoDocPath, 'utf8'), 'existing repo doc');
|
||||||
|
assert.equal(fs.readFileSync(repoConfigPath, 'utf8'), 'existing repo notes');
|
||||||
const rmCall = spawn.calls.find(c => c.args[0] === 'rm');
|
assert.ok(!addedArgs.includes('docs/source-only.md'));
|
||||||
assert.equal(rmCall, undefined, 'git rm should not run for missing source files');
|
assert.ok(!addedArgs.includes('project-notes.md'));
|
||||||
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges existing repo copies with workspace files', async () => {
|
|
||||||
const repoDir = path.join(workspace, 'repo');
|
|
||||||
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc');
|
|
||||||
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);
|
|
||||||
|
|
||||||
const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -25,18 +25,8 @@ export function getBotReviewOutcome(message) {
|
|||||||
export async function getPRDiff() {
|
export async function getPRDiff() {
|
||||||
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
||||||
return filterDiff(resp.data, [
|
return filterDiff(resp.data, [
|
||||||
'.amazonq/',
|
|
||||||
'.agents/',
|
|
||||||
'.antigravity/',
|
|
||||||
'.claude/',
|
|
||||||
'.codex/',
|
|
||||||
'.gemini/',
|
|
||||||
'.gitea/',
|
'.gitea/',
|
||||||
'.github/',
|
'.github/',
|
||||||
'AGENTS.md',
|
|
||||||
'ANTIGRAVITY.md',
|
|
||||||
'CLAUDE.md',
|
|
||||||
'GEMINI.md',
|
|
||||||
'README.md',
|
'README.md',
|
||||||
'TODO.md',
|
'TODO.md',
|
||||||
]);
|
]);
|
||||||
|
|||||||
+5
-5
@@ -129,10 +129,10 @@ describe('filterDiff', () => {
|
|||||||
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
|
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
|
||||||
|
|
||||||
it('filters out configured folder blocks', () => {
|
it('filters out configured folder blocks', () => {
|
||||||
const diff = block('.gitea/workflows/review.yaml') + block('.amazonq/rules/triage-findings.md') + block('src/index.js');
|
const diff = block('.gitea/workflows/review.yaml') + block('.github/workflows/review.yaml') + block('src/index.js');
|
||||||
const result = filterDiff(diff, ['.gitea/', '.amazonq/']);
|
const result = filterDiff(diff, ['.gitea/', '.github/']);
|
||||||
assert.ok(!result.includes('.gitea/'));
|
assert.ok(!result.includes('.gitea/'));
|
||||||
assert.ok(!result.includes('.amazonq/'));
|
assert.ok(!result.includes('.github/'));
|
||||||
assert.ok(result.includes('src/index.js'));
|
assert.ok(result.includes('src/index.js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +144,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('.agents/skills/triage-findings/SKILL.md');
|
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json');
|
||||||
const result = filterDiff(diff, ['.gitea/', '.agents/']);
|
const result = filterDiff(diff, ['.gitea/']);
|
||||||
assert.equal(result, '');
|
assert.equal(result, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+144
-5
@@ -2,6 +2,81 @@ import axios from 'axios';
|
|||||||
import { getLLMConfig } from './config.js';
|
import { getLLMConfig } from './config.js';
|
||||||
import { line, error } from './log.js';
|
import { line, error } from './log.js';
|
||||||
|
|
||||||
|
function isOpenAIGpt55(provider, model) {
|
||||||
|
return provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatEndpoint(baseURL, provider, model) {
|
||||||
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
return isOpenAIGpt55(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatPayload(provider, model, systemPrompt, userContent) {
|
||||||
|
if (isOpenAIGpt55(provider, model)) {
|
||||||
|
return { model, instructions: systemPrompt, input: userContent, temperature: 0.2 };
|
||||||
|
}
|
||||||
|
return { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContent(provider, model, data) {
|
||||||
|
if (!isOpenAIGpt55(provider, model)) return data.choices[0].message.content;
|
||||||
|
if (typeof data.output_text === 'string') return data.output_text;
|
||||||
|
const parts = data.output?.flatMap(item => item.content || []) || [];
|
||||||
|
const text = parts
|
||||||
|
.map(part => {
|
||||||
|
if (typeof part.text === 'string') return part.text;
|
||||||
|
if (typeof part.content === 'string') return part.content;
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
if (text) return text;
|
||||||
|
return data.choices?.[0]?.message?.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function opencodeModelConfig(model) {
|
||||||
|
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
||||||
|
return { providerID, modelID };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOpenCodeAuth(headers) {
|
||||||
|
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
||||||
|
if (!password) return;
|
||||||
|
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
|
||||||
|
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenCodeContent(data) {
|
||||||
|
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
||||||
|
return parts
|
||||||
|
.map(part => part.text || part.content || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
||||||
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
const { providerID, modelID } = opencodeModelConfig(model);
|
||||||
|
const session = await axios.post(
|
||||||
|
`${base}/session`,
|
||||||
|
{ title: 'AI Code Review', model: { providerID, id: modelID } },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
const sessionID = session.data.id || session.data.data?.id;
|
||||||
|
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
|
||||||
|
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${base}/session/${sessionID}/message`,
|
||||||
|
{
|
||||||
|
model: { providerID, modelID },
|
||||||
|
system: systemPrompt,
|
||||||
|
parts: [{ type: 'text', text: userContent }],
|
||||||
|
},
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
return extractOpenCodeContent(resp.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) throw new Error('未設定任何 LLM API Key');
|
if (!provider) throw new Error('未設定任何 LLM API Key');
|
||||||
@@ -13,14 +88,18 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
|
|
||||||
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
|
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
|
||||||
for (let i = 0; i < shuffled.length; i++) {
|
for (let i = 0; i < shuffled.length; i++) {
|
||||||
if (provider !== 'ollama') headers['Authorization'] = `Bearer ${shuffled[i]}`;
|
if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`;
|
||||||
try {
|
try {
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
applyOpenCodeAuth(headers);
|
||||||
|
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
||||||
|
}
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
`${baseURL.replace(/\/$/, '')}/chat/completions`,
|
chatEndpoint(baseURL, provider, model),
|
||||||
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 },
|
chatPayload(provider, model, systemPrompt, userContent),
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
return resp.data.choices[0].message.content;
|
return extractContent(provider, model, resp.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -32,9 +111,69 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
export async function chatJSON(systemPrompt, userContent) {
|
export async function chatJSON(systemPrompt, userContent) {
|
||||||
const text = await chat(systemPrompt, userContent);
|
const text = await chat(systemPrompt, userContent);
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
|
return JSON.parse(extractJSONText(text));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] JSON 解析失敗: ${e.message}`);
|
line(`[LLM] JSON 解析失敗: ${e.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripOuterFence(text) {
|
||||||
|
return String(text)
|
||||||
|
.trim()
|
||||||
|
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
|
||||||
|
.replace(/```$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBalancedJSON(text, startIndex) {
|
||||||
|
const source = String(text);
|
||||||
|
const open = source[startIndex];
|
||||||
|
const close = open === '{' ? '}' : ']';
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let i = startIndex; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (ch === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === open) depth += 1;
|
||||||
|
else if (ch === close) {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return source.slice(startIndex, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJSONText(text) {
|
||||||
|
const stripped = stripOuterFence(text);
|
||||||
|
try {
|
||||||
|
JSON.parse(stripped);
|
||||||
|
return stripped;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
for (let i = 0; i < stripped.length; i++) {
|
||||||
|
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
|
||||||
|
const candidate = extractBalancedJSON(stripped, i);
|
||||||
|
if (!candidate) continue;
|
||||||
|
try {
|
||||||
|
JSON.parse(candidate);
|
||||||
|
return candidate;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const ENV_KEYS = [
|
|||||||
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
|
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -125,6 +127,68 @@ describe('chat - key rotation', async () => {
|
|||||||
await chat('sys', 'user');
|
await chat('sys', 'user');
|
||||||
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses OpenCode server session API for opencode', async () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_PROVIDER = 'google';
|
||||||
|
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
||||||
|
const calls = [];
|
||||||
|
mock.method(axios, 'post', async (url, payload, opts) => {
|
||||||
|
calls.push({ url, payload, headers: opts.headers });
|
||||||
|
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
||||||
|
return { data: { parts: [{ type: 'text', text: 'opencode response' }] } };
|
||||||
|
});
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
assert.equal(result, 'opencode response');
|
||||||
|
assert.equal(calls[0].url, 'http://opencode.local:4096/session');
|
||||||
|
assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' });
|
||||||
|
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message');
|
||||||
|
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' });
|
||||||
|
assert.equal(calls[1].payload.system, 'sys');
|
||||||
|
assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]);
|
||||||
|
assert.equal(calls[1].headers['Authorization'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Basic Auth for protected OpenCode server', async () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_SERVER_USERNAME = 'opencode';
|
||||||
|
process.env.OPENCODE_SERVER_PASSWORD = 'secret';
|
||||||
|
const headers = [];
|
||||||
|
mock.method(axios, 'post', async (url, _payload, opts) => {
|
||||||
|
headers.push(opts.headers);
|
||||||
|
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
||||||
|
return { data: { parts: [{ type: 'text', text: 'ok' }] } };
|
||||||
|
});
|
||||||
|
await chat('sys', 'user');
|
||||||
|
assert.equal(headers[0]['Authorization'], `Basic ${Buffer.from('opencode:secret').toString('base64')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Responses API for openai GPT-5.5', async () => {
|
||||||
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
|
process.env.OPENAI_MODEL = 'GPT-5.5';
|
||||||
|
let capturedUrl, capturedPayload;
|
||||||
|
mock.method(axios, 'post', async (url, payload) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedPayload = payload;
|
||||||
|
return { data: { output_text: 'gpt response' } };
|
||||||
|
});
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
assert.equal(result, 'gpt response');
|
||||||
|
assert.equal(capturedUrl, 'https://api.openai.com/v1/responses');
|
||||||
|
assert.deepEqual(capturedPayload, { model: 'GPT-5.5', instructions: 'sys', input: 'user', temperature: 0.2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts opencode text from message parts', async () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
let calls = 0;
|
||||||
|
mock.method(axios, 'post', async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return { data: { id: 'ses_test' } };
|
||||||
|
return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } };
|
||||||
|
});
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
assert.equal(result, 'hello world');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
describe('chatJSON', async () => {
|
||||||
@@ -144,6 +208,20 @@ describe('chatJSON', async () => {
|
|||||||
assert.deepEqual(result, [{ level: 'info' }]);
|
assert.deepEqual(result, [{ level: 'info' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts JSON array from surrounding prose', async () => {
|
||||||
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
|
mockAxiosPost([makeOkResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.')]);
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts JSON object from surrounding prose', async () => {
|
||||||
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
|
mockAxiosPost([makeOkResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}')]);
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns [] when JSON is invalid', async () => {
|
it('returns [] when JSON is invalid', async () => {
|
||||||
process.env.OPENAI_API_KEY = 'sk-test';
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
mockAxiosPost([makeOkResponse('not json')]);
|
mockAxiosPost([makeOkResponse('not json')]);
|
||||||
|
|||||||
+32
-3
@@ -15,6 +15,17 @@ import { step, line, ok, error } from './log.js';
|
|||||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||||||
|
const usesResponsesApi = (provider, model) => provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
|
||||||
|
const opencodeModelConfig = (model) => {
|
||||||
|
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
||||||
|
return { providerID, modelID };
|
||||||
|
};
|
||||||
|
const applyOpenCodeAuth = (headers) => {
|
||||||
|
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
||||||
|
if (!password) return;
|
||||||
|
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
|
||||||
|
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
};
|
||||||
|
|
||||||
function giteaErr(e) {
|
function giteaErr(e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
@@ -63,6 +74,7 @@ export async function verifyLLM() {
|
|||||||
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
||||||
|
|
||||||
const base = baseURL.replace(/\/$/, '');
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
if (provider === 'ollama') {
|
if (provider === 'ollama') {
|
||||||
try {
|
try {
|
||||||
@@ -73,14 +85,31 @@ export async function verifyLLM() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
if (provider === 'opencode') {
|
||||||
|
const { providerID, modelID } = opencodeModelConfig(model);
|
||||||
|
applyOpenCodeAuth(headers);
|
||||||
|
try {
|
||||||
|
await axios.get(`${base}/global/health`, { headers, timeout: 30000 });
|
||||||
|
const providers = await axios.get(`${base}/config/providers`, { headers, timeout: 30000 });
|
||||||
|
const configuredProvider = providers.data.providers?.find(p => p.id === providerID);
|
||||||
|
if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` };
|
||||||
|
if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` };
|
||||||
|
return { ok: true, provider };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
||||||
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
|
const endpoint = usesResponsesApi(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
|
||||||
|
const payload = usesResponsesApi(provider, model)
|
||||||
|
? { model, input: 'ping', max_output_tokens: 1, temperature: 0 }
|
||||||
|
: { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
|
||||||
|
|
||||||
for (let i = 0; i < apiKeys.length; i++) {
|
for (let i = 0; i < apiKeys.length; i++) {
|
||||||
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
||||||
try {
|
try {
|
||||||
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 });
|
await axios.post(endpoint, payload, { headers, timeout: 30000 });
|
||||||
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
|
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
|
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const LLM_ENV_KEYS = [
|
|||||||
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
function clearLLMEnv() {
|
function clearLLMEnv() {
|
||||||
@@ -163,6 +165,41 @@ describe('verifyLLM', () => {
|
|||||||
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks opencode server provider and model', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_PROVIDER = 'google';
|
||||||
|
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
||||||
|
const urls = [];
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
urls.push(url);
|
||||||
|
if (url.endsWith('/global/health')) return { data: { healthy: true, version: '1.17.7' } };
|
||||||
|
return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'opencode');
|
||||||
|
assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks openai GPT-5.5 with Responses API', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
|
process.env.OPENAI_MODEL = 'GPT-5.5';
|
||||||
|
let capturedUrl, capturedPayload;
|
||||||
|
mock.method(axios, 'post', async (url, payload) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedPayload = payload;
|
||||||
|
return { data: { output_text: 'o' } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'openai');
|
||||||
|
assert.equal(capturedUrl, 'https://api.openai.com/v1/responses');
|
||||||
|
assert.equal(capturedPayload.model, 'GPT-5.5');
|
||||||
|
assert.equal(capturedPayload.max_output_tokens, 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('checks base URL connectivity for ollama (no key)', async () => {
|
it('checks base URL connectivity for ollama (no key)', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
||||||
|
|||||||
Reference in New Issue
Block a user