Compare commits

...

2 Commits

4 changed files with 43 additions and 15 deletions
+12
View File
@@ -164,6 +164,18 @@
"location": "app/llm.js", "location": "app/llm.js",
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為" "suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
}, },
{
"location": "Dockerfile, app/git.js, app/git.test.js",
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
},
{
"location": "Dockerfile",
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
},
{
"location": "Dockerfile",
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
},
{ {
"role": "Aria", "role": "Aria",
"location": "Dockerfile", "location": "Dockerfile",
+7
View File
@@ -10,6 +10,13 @@ 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 .claude/ /action/.claude/
COPY .gemini/ /action/.gemini/
COPY .github/ /action/.github/
COPY CLAUDE.md /action/
COPY GEMINI.md /action/
COPY app/ /action/app/ COPY app/ /action/app/
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
+4 -2
View File
@@ -1,8 +1,10 @@
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const SYNC_PATHS = [ export const SYNC_PATHS = [
FINDINGS_PATH, FINDINGS_PATH,
@@ -57,7 +59,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); const run = makeRunner(_spawnSync);
try { try {
@@ -70,7 +72,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync)
// Copy action skill files into the target repo. Existing files are overwritten; // 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. // missing source files are ignored so we do not delete target repo content.
for (const relPath of SYNC_PATHS) { for (const relPath of SYNC_PATHS) {
const src = path.join(workspace, relPath); const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath); const dest = path.join(repoDir, relPath);
if (fs.existsSync(src)) { if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.mkdirSync(path.dirname(dest), { recursive: true });
+20 -13
View File
@@ -8,14 +8,18 @@ import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js';
// --- helpers --- // --- helpers ---
function makeTmpWorkspace() { function makeTmpWorkspace() {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-')); const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
// Pre-create repo dir so clone branch is skipped
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true }); fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
return ws;
}
function makeActionSource() {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
for (const relPath of SYNC_PATHS) { 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.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, relPath); fs.writeFileSync(fullPath, relPath);
} }
return ws; return sourceRoot;
} }
// Default stub: all commands succeed, status returns changes // Default stub: all commands succeed, status returns changes
@@ -35,9 +39,12 @@ function makeSpawn(overrides = {}) {
describe('commitAndPush', () => { describe('commitAndPush', () => {
let workspace; let workspace;
let sourceRoot;
before(() => { workspace = makeTmpWorkspace(); }); before(() => { workspace = makeTmpWorkspace(); });
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
before(() => { sourceRoot = makeActionSource(); });
after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); });
beforeEach(() => { beforeEach(() => {
for (const f of fs.readdirSync(workspace)) { for (const f of fs.readdirSync(workspace)) {
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f)); if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
@@ -46,7 +53,7 @@ describe('commitAndPush', () => {
it('does not embed token in any git command argument', async () => { it('does not embed token in any git command argument', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
for (const { args } of spawn.calls) { for (const { args } of spawn.calls) {
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`); assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
@@ -55,7 +62,7 @@ describe('commitAndPush', () => {
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => { it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const networkOps = ['fetch', 'push', 'clone']; const networkOps = ['fetch', 'push', 'clone'];
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0])); const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
@@ -67,28 +74,28 @@ describe('commitAndPush', () => {
}); });
it('cleans up askpass script after successful run', async () => { it('cleans up askpass script after successful run', async () => {
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn()); await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up'); assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
}); });
it('cleans up askpass script even when git fails', async () => { it('cleans up askpass script even when git fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn); await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure'); assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
}); });
it('skips commit when status shows no changes', async () => { it('skips commit when status shows no changes', async () => {
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) }); const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit'); const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
assert.equal(commitCalled, false, 'commit should not run when there are no changes'); assert.equal(commitCalled, false, 'commit should not run when there are no changes');
}); });
it('adds skill and entry files together with findings', async () => { it('adds skill and entry files together with findings', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const addCall = spawn.calls.find(c => c.args[0] === 'add'); const addCall = spawn.calls.find(c => c.args[0] === 'add');
assert.ok(addCall, 'expected git add to run'); assert.ok(addCall, 'expected git add to run');
assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md')); assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md'));
@@ -102,13 +109,13 @@ describe('commitAndPush', () => {
}); });
it('keeps repo copies when the source sync file is missing', async () => { it('keeps repo copies when the source sync file is missing', async () => {
const missingPath = path.join(workspace, '.amazonq/rules/triage-findings.md'); const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md');
fs.rmSync(missingPath, { force: true }); fs.rmSync(missingPath, { force: true });
const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md'); const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md');
fs.writeFileSync(repoPath, 'stale'); fs.writeFileSync(repoPath, 'stale');
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const rmCall = spawn.calls.find(c => c.args[0] === 'rm'); 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(rmCall, undefined, 'git rm should not run for missing source files');
@@ -120,7 +127,7 @@ describe('commitAndPush', () => {
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.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, '.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'); assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
@@ -128,7 +135,7 @@ describe('commitAndPush', () => {
it('does not throw when git command fails', async () => { it('does not throw when git command fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn)); await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
}); });
}); });