diff --git a/Dockerfile b/Dockerfile index ee322d2..cbf25e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,13 @@ WORKDIR /action COPY app/package.json /action/app/ 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 entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/app/git.js b/app/git.js index eeac89f..027b2c3 100644 --- a/app/git.js +++ b/app/git.js @@ -1,8 +1,10 @@ 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, @@ -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); 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; // 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)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); diff --git a/app/git.test.js b/app/git.test.js index 59ecb02..055b3f2 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -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,28 +74,28 @@ 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')); @@ -102,13 +109,13 @@ describe('commitAndPush', () => { }); 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 }); 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); + 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'); @@ -120,7 +127,7 @@ describe('commitAndPush', () => { 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'); @@ -128,7 +135,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)); }); });