Files

301 lines
17 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { execFile } from 'node:child_process';
import { createServer } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { pinInitialBase, resolveTargetBranch } from '../task-base.mjs';
import { STATUS_LABELS } from '../factory-lib.mjs';
const repository = 'test/factory';
const initial = 'a'.repeat(40);
const advanced = 'b'.repeat(40);
const work = 'c'.repeat(40);
const bot = { login: 'github-actions[bot]', type: 'Bot' };
function fixture() {
const comments = [];
const refs = new Map([['develop', initial]]);
return {
repository, comments, refs,
async getRef(branch) { return refs.has(branch) ? { object: { sha: refs.get(branch) } } : null; },
async request(method, route, { query } = {}) {
assert.equal(method, 'GET');
assert.equal(route, '/issues/20/comments');
return comments.slice((query.page - 1) * 100, query.page * 100);
},
async addComment(number, body) {
assert.equal(number, 20);
const value = { id: comments.length + 1, body, user: bot };
comments.push(value);
return value;
},
};
}
const pull = (number, base = 'develop', state = 'open') => ({
number: 100 + number, state,
base: { ref: base },
head: { ref: `agent/issue-${number}`, repo: { full_name: repository } },
});
test('new omitted targets resolve to the repository default, not an issues-N branch', async () => {
const c = fixture();
assert.equal(await resolveTargetBranch(c, 20, null, [pull(10)], 'develop'), 'develop');
assert.equal(await resolveTargetBranch(c, 20, null, [], 'main'), 'main');
assert.deepEqual([...c.refs.keys()], ['develop']);
});
test('legacy omitted targets preserve their own PR or existing application branch', async () => {
const c = fixture();
assert.equal(await resolveTargetBranch(c, 20, null, [pull(20, 'apps/legacy')], 'develop'), 'apps/legacy');
assert.equal(await resolveTargetBranch(c, 20, null, [pull(20, 'issues-20', 'closed')], 'develop'), 'issues-20');
c.refs.set('issues-20', initial);
assert.equal(await resolveTargetBranch(c, 20, null, [], 'develop'), 'issues-20');
assert.equal(await resolveTargetBranch(c, 21, null, [], 'develop'), 'develop');
});
test('explicit branches are preserved; conflicting own PRs cannot be guessed', async () => {
const c = fixture();
assert.equal(await resolveTargetBranch(c, 20, 'apps/custom', [], 'develop'), 'apps/custom');
await assert.rejects(resolveTargetBranch(c, 20, null, [pull(20), pull(20, 'other')], 'develop'), /多个/);
await assert.rejects(resolveTargetBranch(c, 20, '../invalid', [], 'develop'), /无效/);
});
test('the first source SHA survives a moving default branch without another branch', async () => {
const c = fixture();
assert.equal(await pinInitialBase(c, 20, 'develop', initial), initial);
assert.equal(await pinInitialBase(c, 20, 'develop', advanced), initial);
assert.equal(c.comments.length, 1);
assert.deepEqual([...c.refs.keys()], ['develop']);
});
test('an ambiguous successful receipt write is reused on retry', async () => {
const c = fixture();
const add = c.addComment.bind(c);
c.addComment = async (...args) => { await add(...args); throw new Error('connection lost'); };
await assert.rejects(pinInitialBase(c, 20, 'develop', initial), /connection lost/);
c.addComment = add;
assert.equal(await pinInitialBase(c, 20, 'develop', advanced), initial);
assert.equal(c.comments.length, 1);
});
test('human receipts and quoted bot copies are not baseline authority', async () => {
const c = fixture();
await pinInitialBase(c, 20, 'develop', initial);
const body = c.comments[0].body;
c.comments[0].user = { login: 'test', type: 'User' };
c.comments.push({ user: bot, body: `Copied comment:\n${body}` });
for (let i = 0; i < 105; i++) c.comments.push({ user: bot, body: 'Other status' });
assert.equal(await pinInitialBase(c, 20, 'develop', advanced), advanced);
assert.equal(await pinInitialBase(c, 20, 'develop', initial), advanced);
});
for (const [name, mutate] of [
['corrupt', (body) => body.replace('{', '!')],
['wrong repository', (body) => body.replace(repository, 'other/repository')],
['wrong Issue', (body) => body.replace('"issueNumber":20', '"issueNumber":21')],
['wrong destination', (body) => body.replaceAll('develop', 'other')],
['invalid SHA', (body) => body.replace(initial, 'not-a-sha')],
]) {
test(`rejects ${name} baseline receipts rather than restarting from latest`, async () => {
const c = fixture();
await pinInitialBase(c, 20, 'develop', initial);
c.comments[0].body = mutate(c.comments[0].body);
await assert.rejects(pinInitialBase(c, 20, 'develop', advanced), /代码起点/);
assert.equal(c.comments.length, 1);
});
}
// Use the actual prepare and publish CLIs against one stateful HTTP fixture.
// Git refs, PR query filtering and bot comments behave like the REST API.
async function integration(t, { branch = '', existingWork = false, legacy = false,
state = 'open', labels = [], eventName = 'issues', payload = {} } = {}) {
const c = fixture();
if (existingWork) c.refs.set('agent/issue-20', work);
if (legacy) c.refs.set('issues-20', initial);
const issues = new Map([[20, {
number: 20, title: '[Code Agent] Test', state, user: { login: 'owner' }, labels,
body: `### 目标分支\n${branch}\n### 任务类型\n创建新系统\n### 业务需求\nBuild a real counter\n### 验收要求\nClick increments\n`,
}]]);
const pulls = [pull(10)];
if (existingWork) pulls.push(pull(20, legacy ? 'issues-20' : 'develop'));
const calls = [];
const server = createServer(async (req, res) => {
try {
let raw = ''; for await (const chunk of req) raw += chunk;
const body = raw ? JSON.parse(raw) : undefined;
const url = new URL(req.url, 'http://fixture');
const route = url.pathname.replace(`/repos/${repository}`, '');
calls.push({ method: req.method, route, body });
let value;
if (route === '') value = { default_branch: 'develop' };
else if (route === '/labels') value = Object.keys(STATUS_LABELS).map((name) => ({ name }));
else if (route === '/issues/20') {
if (req.method === 'PATCH') Object.assign(issues.get(20), body);
value = issues.get(20);
} else if (route === '/issues/20/comments') {
value = req.method === 'POST' ? await c.addComment(20, body.body) : c.comments;
} else if (route.startsWith('/git/ref/heads/')) value = await c.getRef(route.slice('/git/ref/heads/'.length));
else if (route === '/git/refs') {
c.refs.set(body.ref.replace('refs/heads/', ''), body.sha);
value = { object: { sha: body.sha } };
} else if (route === '/pulls' && req.method === 'GET') {
value = pulls.filter((p) =>
(!url.searchParams.get('state') || p.state === url.searchParams.get('state')) &&
(!url.searchParams.get('head') || url.searchParams.get('head') === `test:${p.head.ref}`) &&
(!url.searchParams.get('base') || url.searchParams.get('base') === p.base.ref));
} else if (route === '/pulls' && req.method === 'POST') {
value = { ...pull(20, body.base), html_url: 'https://github.com/test/factory/pull/120', body: body.body };
pulls.push(value);
} else if (route === '/pulls/120' && req.method === 'PATCH') {
value = pulls.find((p) => p.number === 120);
Object.assign(value, { body: body.body, base: { ref: body.base } });
} else throw new Error(`Unexpected request ${req.method} ${route}`);
res.writeHead(value == null ? 404 : 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(value ?? { message: 'Not found' }));
} catch (error) { res.writeHead(500); res.end(JSON.stringify({ message: error.message })); }
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const root = mkdtempSync(path.join(os.tmpdir(), 'factory-default-base-'));
t.after(() => { server.closeAllConnections(); server.close(); rmSync(root, { recursive: true, force: true }); });
const env = { ...process.env, GITHUB_TOKEN: 'test-only', GITHUB_REPOSITORY: repository, GITHUB_EVENT_NAME: eventName,
GITHUB_API_URL: `http://127.0.0.1:${server.address().port}`, GITHUB_RUN_ID: '101', GITHUB_SERVER_URL: 'https://github.com' };
const run = (script, args) => promisify(execFile)(process.execPath, [path.resolve(import.meta.dirname, '..', script), ...args], { env, timeout: 10000 });
const metadata = path.join(root, 'task.json');
const event = path.join(root, 'event.json');
writeFileSync(event, JSON.stringify({ issue: { number: 20 }, repository: { owner: { login: 'test' } }, ...payload }));
return { c, calls, pulls, metadata, issues,
async prepare() {
const output = path.join(root, 'output'); writeFileSync(output, '');
await run('prepare-task.mjs', ['--event', event, '--metadata', metadata, '--output', output]);
return { metadata: existsSync(metadata) ? JSON.parse(readFileSync(metadata, 'utf8')) : null, output: readFileSync(output, 'utf8') };
},
async publish(status = 'success', publicationRecord) {
c.refs.set('agent/issue-20', work);
const summary = path.join(root, 'summary.json');
writeFileSync(summary, JSON.stringify({ counts: { files: 1, added: 1, modified: 0, deleted: 0, renamed: 0 } }));
const publication = path.join(root, 'publication-base.json');
rmSync(publication, { force: true });
if (publicationRecord) writeFileSync(publication, JSON.stringify(publicationRecord));
await run('publish-pr.mjs', ['--metadata', metadata, '--summary', summary, '--status', status, '--publication', publication]);
},
};
}
for (const branch of ['', 'develop']) {
test(`actual prepare and publisher use develop without intermediate branch: ${branch || 'omitted'}`, async (t) => {
const f = await integration(t, { branch });
const first = await f.prepare();
assert.equal(first.metadata.task.targetBranch, 'develop');
assert.equal(first.metadata.workBranch, 'agent/issue-20');
assert.equal(first.metadata.targetCreated, false);
// A stable logical-run key is recorded once and survives retries of the same Issue.
assert.deepEqual(first.metadata.evaluation, { version: 1, runKey: `${repository}/issues/20/initial`, kind: 'initial', requiredChecks: [] });
assert.match(first.output, /status=ready/); // Another Issue already has a develop PR.
assert.match(first.output, new RegExp(`base_ref=develop\\nbase_sha=${initial}`));
f.c.refs.set('develop', advanced);
const retry = await f.prepare();
assert.match(retry.output, new RegExp(`base_sha=${initial}`));
assert.deepEqual(retry.metadata.evaluation, first.metadata.evaluation);
assert.equal(f.calls.filter((call) => call.route === '/git/refs').length, 0);
// develop moved only on factory files, so the publisher restacked.
await f.publish('success', {
version: 1, restacked: true, reason: 'factory-drift', head: advanced, drift: 3,
verifiedBase: { ref: 'develop', sha: initial }, publishedBase: { ref: 'develop', sha: advanced },
});
const published = f.calls.find((call) => call.route === '/pulls' && call.method === 'POST');
assert.match(published.body.body, new RegExp(`agent-verified-base-sha: ${initial}`));
assert.match(published.body.body, /已在 `develop @ aaaaaaaaaaaa` 上验证;发布时改基到 `develop @ bbbbbbbbbbbb`/);
assert.equal(published.body.base, 'develop');
assert.equal(published.body.head, 'agent/issue-20');
assert.match(published.body.body, /agent-target-branch: develop/);
const resumed = await f.prepare();
assert.match(resumed.output, new RegExp(`base_ref=agent/issue-20\\nbase_sha=${work}`));
assert.equal(resumed.metadata.existingPullRequest.number, 120);
await f.publish('success', {
version: 1, restacked: false, reason: 'existing-work-branch',
verifiedBase: { ref: 'agent/issue-20', sha: work }, publishedBase: { ref: 'agent/issue-20', sha: work },
});
const resumedBody = f.calls.filter((call) => call.route === '/pulls/120' && call.method === 'PATCH').at(-1).body.body;
assert.match(resumedBody, /代码起点:`agent\/issue-20 @ cccccccccccc`(已在此基线上验证)/);
assert.equal(f.calls.filter((call) => call.route === '/pulls' && call.method === 'POST').length, 1);
assert.equal(f.calls.filter((call) => call.route === '/pulls/120' && call.method === 'PATCH').length, 1);
});
}
test('failed implementation creates a failed PR and can later update the same PR successfully', async (t) => {
const f = await integration(t);
await f.prepare();
await f.publish('failed');
const created = f.calls.find(c => c.route === '/pulls' && c.method === 'POST');
assert.match(created.body.title, /^\[failed\]/);
assert.match(created.body.body, /factory-build-status: failed/);
assert.match(created.body.body, /搭建报告/);
assert.doesNotMatch(created.body.body, /构建:通过|业务验收:通过|启动检查:通过/);
assert.ok(f.issues.get(20).labels.includes('agent:failed'));
await f.publish();
assert.equal(f.calls.filter(c => c.route === '/pulls' && c.method === 'POST').length, 1);
const updated = f.calls.filter(c => c.route === '/pulls/120' && c.method === 'PATCH').at(-1);
assert.doesNotMatch(updated.body.title, /^\[failed\]/);
assert.match(updated.body.body, /factory-build-status: success/);
assert.ok(f.issues.get(20).labels.includes('agent:review'));
});
test('a previously successful PR is updated as failed without creating another PR', async (t) => {
const f = await integration(t, { existingWork: true });
await f.prepare();
await f.publish('failed');
assert.equal(f.calls.filter(c => c.route === '/pulls' && c.method === 'POST').length, 0);
const updated = f.calls.find(c => c.route === '/pulls/120' && c.method === 'PATCH');
assert.match(updated.body.title, /^\[failed\]/);
assert.match(updated.body.body, /factory-build-status: failed/);
assert.ok(f.issues.get(20).labels.includes('agent:failed'));
});
test('closed external submissions prepare and publish independently of an earlier application PR', async (t) => {
const f = await integration(t, { branch: 'apps/external', state: 'closed',
labels: [{ name: 'factory:external' }], eventName: 'workflow_dispatch',
payload: { inputs: { issue_number: 20, external_run_id: 'request-20' } } });
f.pulls[0].base.ref = 'apps/external';
const result = await f.prepare();
assert.match(result.output, /status=ready/);
assert.equal(result.metadata.workBranch, 'agent/issue-20');
assert.equal(result.metadata.task.targetBranch, 'apps/external');
await f.publish();
assert.equal(f.issues.get(20).state, 'closed');
assert.equal(f.calls.filter(c => c.route === '/pulls' && c.method === 'POST').length, 1);
assert.equal(f.calls.some(c => c.route === '/issues/20' && c.body?.state === 'open'), false);
});
for (const options of [
{ labels: [], eventName: 'workflow_dispatch', payload: { inputs: { external_run_id: 'request' } } },
{ labels: ['factory:external'], eventName: 'workflow_dispatch' },
{ labels: ['factory:external'], eventName: 'issues' },
]) {
test('closed Issues without both external label and explicit dispatch remain rejected: ' + JSON.stringify(options), async (t) => {
const f = await integration(t, { ...options, state: 'closed' });
const result = await f.prepare();
assert.match(result.output, /status=rejected/);
assert.equal(result.metadata, null);
assert.equal(f.calls.some(c => c.route === '/git/refs'), false);
});
}
for (const options of [
{ eventName: 'workflow_dispatch', payload: { inputs: { recovery_run_id: '100' } } },
{ eventName: 'repository_dispatch', payload: { action: 'code-agent-continue', client_payload: { previous_run_id: 100 } } },
]) {
test('closed external recovery and handoff remain runnable: ' + options.eventName, async (t) => {
const f = await integration(t, { ...options, state: 'closed', labels: ['factory:external'] });
assert.match((await f.prepare()).output, /status=ready/);
assert.equal(f.issues.get(20).state, 'closed');
});
}
test('ordinary custom application tasks still wait for an earlier open PR', async (t) => {
const f = await integration(t, { branch: 'apps/external' });
f.pulls[0].base.ref = 'apps/external';
assert.match((await f.prepare()).output, /status=waiting/);
});
test('actual prepare preserves a pre-existing legacy task rather than migrating its PR', async (t) => {
const f = await integration(t, { existingWork: true, legacy: true });
const result = await f.prepare();
assert.equal(result.metadata.task.targetBranch, 'issues-20');
assert.equal(result.metadata.existingPullRequest.number, 120);
assert.match(result.output, new RegExp(`base_sha=${work}`));
});