Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f3baf95f | |||
| 33d5cdde7c | |||
| ae96ead6cf | |||
| d502393745 | |||
| e5539c377c | |||
| 109048e604 | |||
| f241f70898 | |||
| 7186098edf | |||
| 46da713fa7 | |||
| 515ccb0509 | |||
| 69e3b33558 | |||
| c70a818986 | |||
| 684c35bc00 |
@@ -164,6 +164,18 @@
|
||||
"location": "app/llm.js",
|
||||
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile, app/git.js, app/git.test.js",
|
||||
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile",
|
||||
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile",
|
||||
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
||||
},
|
||||
{
|
||||
"role": "Aria",
|
||||
"location": "Dockerfile",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# Triage Findings
|
||||
|
||||
When the task is to triage review findings, follow this workflow:
|
||||
Use the triage-finding workflow for review issue lists:
|
||||
|
||||
1. Merge all findings into one list.
|
||||
1. Merge findings into one list.
|
||||
2. Remove duplicates.
|
||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||
4. Renumber from 1 after sorting.
|
||||
4. Renumber from 1.
|
||||
5. Fix real issues with the smallest safe change.
|
||||
6. Add false positives to `.gitea/ai-review/exclusions.json`.
|
||||
6. Put false positives into `.gitea/ai-review/exclusions.json`.
|
||||
7. Add or update tests when behavior changes.
|
||||
8. Re-check the issue after each fix.
|
||||
8. Re-check after each fix.
|
||||
|
||||
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
||||
|
||||
Trigger it with `/triage-findings`.
|
||||
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
---
|
||||
name: triage-findings
|
||||
description: Triage findings, fix real issues, and exclude false positives.
|
||||
---
|
||||
|
||||
# Triage Findings
|
||||
|
||||
## Use
|
||||
Use the triage-finding workflow for review issue lists:
|
||||
|
||||
`triage-findings 問題原始檔(文字或截圖)`
|
||||
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`.
|
||||
7. Add or update tests when behavior changes.
|
||||
8. Re-check after each fix.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Merge all findings.
|
||||
2. Sort by severity:
|
||||
- critical
|
||||
- warning
|
||||
- info
|
||||
3. Renumber from 1.
|
||||
4. Fix real issues.
|
||||
5. Put false positives into `.gitea/ai-review/exclusions.json`.
|
||||
6. Add tests when behavior changes.
|
||||
|
||||
## Output Rules
|
||||
|
||||
- Keep the final list short.
|
||||
- Keep numbering contiguous.
|
||||
- Preserve file path, location, and fix.
|
||||
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Triage Findings
|
||||
|
||||
Use the triage-finding workflow for review issue lists:
|
||||
When the task is to triage review findings, follow this workflow:
|
||||
|
||||
1. Merge findings into one list.
|
||||
1. Merge all findings into one list.
|
||||
2. Remove duplicates.
|
||||
3. Sort by severity: `critical` -> `warning` -> `info`.
|
||||
4. Renumber from 1.
|
||||
4. Renumber from 1 after sorting.
|
||||
5. Fix real issues with the smallest safe change.
|
||||
6. Put false positives into `.gitea/ai-review/exclusions.json`.
|
||||
6. Add false positives to `.gitea/ai-review/exclusions.json`.
|
||||
7. Add or update tests when behavior changes.
|
||||
8. Re-check after each fix.
|
||||
8. Re-check the issue after each fix.
|
||||
|
||||
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
|
||||
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
|
||||
|
||||
Trigger it with `/triage-findings`.
|
||||
|
||||
@@ -10,6 +10,14 @@ WORKDIR /action
|
||||
COPY app/package.json /action/app/
|
||||
RUN cd /action/app && npm install
|
||||
|
||||
COPY .amazonq/ /action/.amazonq/
|
||||
COPY .codex/ /action/.codex/
|
||||
COPY .claude/ /action/.claude/
|
||||
COPY .gemini/ /action/.gemini/
|
||||
COPY .github/ /action/.github/
|
||||
COPY CLAUDE.md /action/
|
||||
COPY GEMINI.md /action/
|
||||
|
||||
COPY app/ /action/app/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
|
||||
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
|
||||
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
|
||||
8. Commit 問題檔案
|
||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容
|
||||
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||
|
||||
# 設計
|
||||
@@ -227,4 +227,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
||||
|
||||
### 版本包含
|
||||
|
||||
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容。
|
||||
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
||||
|
||||
@@ -38,15 +38,15 @@
|
||||
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||
|
||||
## 階段八:記憶區 commit/push 與錯誤處理
|
||||
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,達到同步效果;錯誤時有明確 log,流程結束有總結訊息。
|
||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被覆蓋同步;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||
- 已驗收:log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功。
|
||||
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||
- 已驗收:log 已出現 `persisted findings commit=b867eaa push=feat/解決問題`,代表 commit/push 成功;本次已補上「來源覆蓋、缺檔不刪除」的同步規則,相關單元測試也已覆蓋。
|
||||
|
||||
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
|
||||
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
||||
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證。
|
||||
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log。
|
||||
- 已驗收:這次 log 已明確出現 `❌ 發現 2 個嚴重問題,workflow 結束(exit 1)`,且 job 以失敗結束,證明阻擋分支確實生效。
|
||||
- 補充紀錄:`Step8` 的退出訊息屬於預期行為,不代表 Step7 commit/push 失敗。
|
||||
|
||||
## 階段十:API Key 輪替
|
||||
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
||||
|
||||
+18
-7
@@ -1,12 +1,16 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||||
|
||||
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||
export const SYNC_PATHS = [
|
||||
FINDINGS_PATH,
|
||||
'.amazonq/rules/triage-findings.md',
|
||||
'.codex/skills/triage-findings/SKILL.md',
|
||||
'.codex/skills/triage-findings/agents/openai.yaml',
|
||||
'.claude/skills/triage-findings/SKILL.md',
|
||||
'.gemini/skills/triage-findings/SKILL.md',
|
||||
'.github/copilot-instructions.md',
|
||||
@@ -57,7 +61,7 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
|
||||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT) {
|
||||
const run = makeRunner(_spawnSync);
|
||||
|
||||
try {
|
||||
@@ -65,16 +69,23 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync)
|
||||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||
|
||||
// Always copy source files over the repo copy so skill files stay in sync.
|
||||
const existingSyncPaths = [];
|
||||
|
||||
// Copy action skill files into the target repo. Existing files are overwritten;
|
||||
// missing source files are ignored so we do not delete target repo content.
|
||||
for (const relPath of SYNC_PATHS) {
|
||||
const src = path.join(workspace, relPath);
|
||||
const src = path.join(sourceRoot, relPath);
|
||||
const dest = path.join(repoDir, relPath);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
existingSyncPaths.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
run(['add', ...SYNC_PATHS], repoDir);
|
||||
if (existingSyncPaths.length > 0) {
|
||||
run(['add', ...existingSyncPaths], repoDir);
|
||||
}
|
||||
|
||||
const status = run(['status', '--porcelain'], repoDir);
|
||||
if (!status) {
|
||||
|
||||
+34
-11
@@ -8,14 +8,18 @@ import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js';
|
||||
// --- helpers ---
|
||||
function makeTmpWorkspace() {
|
||||
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
|
||||
// Pre-create repo dir so clone branch is skipped
|
||||
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
|
||||
return ws;
|
||||
}
|
||||
|
||||
function makeActionSource() {
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
|
||||
for (const relPath of SYNC_PATHS) {
|
||||
const fullPath = path.join(ws, relPath);
|
||||
const fullPath = path.join(sourceRoot, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, relPath);
|
||||
}
|
||||
return ws;
|
||||
return sourceRoot;
|
||||
}
|
||||
|
||||
// Default stub: all commands succeed, status returns changes
|
||||
@@ -35,9 +39,12 @@ function makeSpawn(overrides = {}) {
|
||||
|
||||
describe('commitAndPush', () => {
|
||||
let workspace;
|
||||
let sourceRoot;
|
||||
|
||||
before(() => { workspace = makeTmpWorkspace(); });
|
||||
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||
before(() => { sourceRoot = makeActionSource(); });
|
||||
after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); });
|
||||
beforeEach(() => {
|
||||
for (const f of fs.readdirSync(workspace)) {
|
||||
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
||||
@@ -46,7 +53,7 @@ describe('commitAndPush', () => {
|
||||
|
||||
it('does not embed token in any git command argument', async () => {
|
||||
const spawn = makeSpawn();
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||
|
||||
for (const { args } of spawn.calls) {
|
||||
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||
@@ -55,7 +62,7 @@ describe('commitAndPush', () => {
|
||||
|
||||
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||
const spawn = makeSpawn();
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||
|
||||
const networkOps = ['fetch', 'push', 'clone'];
|
||||
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
||||
@@ -67,31 +74,33 @@ describe('commitAndPush', () => {
|
||||
});
|
||||
|
||||
it('cleans up askpass script after successful run', async () => {
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
|
||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||
});
|
||||
|
||||
it('cleans up askpass script even when git fails', async () => {
|
||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot);
|
||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
||||
});
|
||||
|
||||
it('skips commit when status shows no changes', async () => {
|
||||
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
||||
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||
});
|
||||
|
||||
it('adds skill and entry files together with findings', async () => {
|
||||
const spawn = makeSpawn();
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||
const addCall = spawn.calls.find(c => c.args[0] === 'add');
|
||||
assert.ok(addCall, 'expected git add to run');
|
||||
assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md'));
|
||||
assert.ok(addCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
|
||||
assert.ok(addCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
|
||||
assert.ok(addCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
|
||||
assert.ok(addCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
|
||||
assert.ok(addCall.args.includes('.github/copilot-instructions.md'));
|
||||
@@ -101,12 +110,26 @@ describe('commitAndPush', () => {
|
||||
assert.ok(!addCall.args.includes('README.md'));
|
||||
});
|
||||
|
||||
it('keeps repo copies when the source sync file is missing', async () => {
|
||||
const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md');
|
||||
fs.rmSync(missingPath, { force: true });
|
||||
const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md');
|
||||
fs.writeFileSync(repoPath, 'stale');
|
||||
const spawn = makeSpawn();
|
||||
|
||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||
|
||||
const rmCall = spawn.calls.find(c => c.args[0] === 'rm');
|
||||
assert.equal(rmCall, undefined, 'git rm should not run for missing source files');
|
||||
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
||||
});
|
||||
|
||||
it('overwrites existing repo copies with workspace files', async () => {
|
||||
const repoDir = path.join(workspace, 'repo');
|
||||
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
|
||||
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
|
||||
|
||||
await commitAndPush(workspace, repoDir, makeSpawn());
|
||||
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md');
|
||||
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
|
||||
@@ -114,7 +137,7 @@ describe('commitAndPush', () => {
|
||||
|
||||
it('does not throw when git command fails', async () => {
|
||||
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
|
||||
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user