337 lines
16 KiB
JavaScript
337 lines
16 KiB
JavaScript
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
|
|
|
// --- helpers ---
|
|
function makeTmpWorkspace() {
|
|
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
|
|
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(sourceRoot, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, relPath);
|
|
}
|
|
return sourceRoot;
|
|
}
|
|
|
|
// Default stub: all commands succeed, status returns changes
|
|
function makeSpawn(overrides = {}) {
|
|
const calls = [];
|
|
const spawn = (cmd, args, opts) => {
|
|
const key = args[0];
|
|
calls.push({ cmd, args, opts });
|
|
if (overrides[key]) return overrides[key](args, opts);
|
|
if (key === 'status') return { status: 0, stdout: 'M .gitea/ai-review/findings.json', stderr: '', error: null };
|
|
if (key === 'commit') return { status: 0, stdout: '[feature-branch abc1234] chore', stderr: '', error: null };
|
|
return { status: 0, stdout: '', stderr: '', error: null };
|
|
};
|
|
spawn.calls = calls;
|
|
return spawn;
|
|
}
|
|
|
|
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));
|
|
}
|
|
});
|
|
|
|
it('does not embed token in any git command argument', async () => {
|
|
const spawn = makeSpawn();
|
|
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(' ')}`);
|
|
}
|
|
});
|
|
|
|
it('tags auto commits with the bot marker for workflow filtering', async () => {
|
|
const spawn = makeSpawn();
|
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
|
|
|
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
|
|
assert.ok(commitCall, 'expected git commit to run');
|
|
assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
|
|
assert.ok(commitCall.args.some(arg => arg.includes('[success]')), 'expected commit message to include success outcome');
|
|
});
|
|
|
|
it('tags failed reviews with the failure outcome marker', async () => {
|
|
const spawn = makeSpawn();
|
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot, 'failure');
|
|
|
|
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
|
|
assert.ok(commitCall, 'expected git commit to run');
|
|
assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
|
|
assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome');
|
|
});
|
|
|
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
|
const spawn = makeSpawn();
|
|
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]));
|
|
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
|
|
|
|
for (const { args, opts } of networkCalls) {
|
|
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
|
}
|
|
});
|
|
|
|
it('cleans up askpass script after successful run', async () => {
|
|
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, 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, 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 repoDir = path.join(workspace, 'repo');
|
|
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.join(repoDir, '.gitea/ai-review'), { recursive: true });
|
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
|
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
|
|
const spawn = makeSpawn();
|
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
|
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'));
|
|
assert.ok(skillAddCall, 'expected git add for synced skill 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/exclusions.json'));
|
|
});
|
|
|
|
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('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 () => {
|
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
|
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
|
|
});
|
|
|
|
it('logs push failures separately from commit failures', async () => {
|
|
const repoDir = path.join(workspace, 'repo');
|
|
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.join(repoDir, '.gitea/ai-review'), { recursive: true });
|
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
|
|
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
|
|
|
|
const spawn = makeSpawn({
|
|
push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }),
|
|
});
|
|
const logs = [];
|
|
const originalLog = console.log;
|
|
const originalWarn = console.warn;
|
|
const capture = (...args) => { logs.push(args.join(' ')); };
|
|
console.log = capture;
|
|
console.warn = capture;
|
|
|
|
try {
|
|
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
|
|
} finally {
|
|
console.log = originalLog;
|
|
console.warn = originalWarn;
|
|
}
|
|
|
|
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
|
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
|
|
});
|
|
});
|
|
|
|
describe('cloneRepo', () => {
|
|
let workspace;
|
|
|
|
before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-test-')); });
|
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
|
|
|
it('clones repo when repoDir does not exist', () => {
|
|
const spawn = makeSpawn();
|
|
cloneRepo(workspace, spawn);
|
|
const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone');
|
|
assert.ok(cloneCalled, 'expected git clone to be called');
|
|
});
|
|
|
|
it('fetches and checks out when repoDir already exists', () => {
|
|
const repoDir = path.join(workspace, 'repo');
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
const spawn = makeSpawn();
|
|
cloneRepo(workspace, spawn);
|
|
const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone');
|
|
const fetchCalled = spawn.calls.some(c => c.args[0] === 'fetch');
|
|
assert.ok(!cloneCalled, 'clone should not run when repoDir exists');
|
|
assert.ok(fetchCalled, 'fetch should run when repoDir exists');
|
|
});
|
|
|
|
it('does not embed token in any git command argument', () => {
|
|
const spawn = makeSpawn();
|
|
cloneRepo(workspace, spawn);
|
|
for (const { args } of spawn.calls) {
|
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
|
}
|
|
});
|
|
|
|
it('uses GIT_ASKPASS for network operations', () => {
|
|
const spawn = makeSpawn();
|
|
cloneRepo(workspace, spawn);
|
|
const networkCalls = spawn.calls.filter(c => ['clone', 'fetch'].includes(c.args[0]));
|
|
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
|
|
for (const { args, opts } of networkCalls) {
|
|
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
|
}
|
|
});
|
|
|
|
it('cleans up askpass script after run', () => {
|
|
const spawn = makeSpawn();
|
|
cloneRepo(workspace, spawn);
|
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
|
});
|
|
|
|
it('returns repoDir path', () => {
|
|
const spawn = makeSpawn();
|
|
const result = cloneRepo(workspace, spawn);
|
|
assert.equal(result, path.join(workspace, 'repo'));
|
|
});
|
|
|
|
it('reads head commit message and detects bot auto commits', () => {
|
|
const spawn = makeSpawn({
|
|
show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }),
|
|
});
|
|
|
|
assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER));
|
|
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
|
});
|
|
});
|