Files

357 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { promisify } from 'node:util';
import { receiptBody } from '../comment-queue.mjs';
import { STATUS_LABELS } from '../factory-lib.mjs';
const exec = promisify(execFile);
const repository = 'gchust/nb3-factory';
const issueBody =
'### 目标分支\n\napps/demo\n\n### 任务类型\n\n创建新系统\n\n### 业务需求\n\nBuild a page\n\n### 验收要求\n\n1. Page works\n';
const issue = (number, label) => ({
number,
title: '[Pi] Test',
body: issueBody,
state: 'open',
user: { login: 'gchust' },
author_association: 'OWNER',
labels: [{ name: label }],
});
const pull = (number, ref) => ({
number,
head: { ref, repo: { full_name: repository } },
base: { ref: 'apps/demo' },
body: '<!-- pi-issue: 2 -->',
merged: true,
});
async function runFixture(script, event, handler) {
const root = mkdtempSync(path.join(os.tmpdir(), 'nb3-task-migration-'));
const requests = [];
const server = http.createServer(async (request, response) => {
let raw = '';
for await (const chunk of request) raw += chunk;
const url = new URL(request.url, 'http://fixture');
const call = {
method: request.method,
route: url.pathname.replace(`/repos/${repository}`, ''),
query: url.searchParams,
body: raw ? JSON.parse(raw) : undefined,
};
requests.push(call);
const value = handler(call);
response.writeHead(value === null ? 404 : 200, {
'Content-Type': 'application/json',
});
response.end(JSON.stringify(value ?? { message: 'Not found' }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const eventPath = path.join(root, 'event.json');
const metadataPath = path.join(root, 'metadata.json');
const outputPath = path.join(root, 'output');
writeFileSync(
eventPath,
JSON.stringify({ repository: { owner: { login: 'gchust' } }, ...event }),
);
const args =
script === 'prepare-task.mjs'
? [
'--event',
eventPath,
'--metadata',
metadataPath,
'--output',
outputPath,
]
: [eventPath];
await exec(
process.execPath,
[path.resolve(import.meta.dirname, '..', script), ...args],
{
env: {
...process.env,
GITHUB_TOKEN: 'fixture-token',
GITHUB_RUN_ID: '101',
GITHUB_REPOSITORY: repository,
GITHUB_API_URL: `http://127.0.0.1:${server.address().port}`,
GITHUB_EVENT_NAME:
script === 'complete-pr.mjs' ? 'pull_request_target' : 'issues',
},
timeout: 15000,
},
);
return {
requests,
...(script === 'prepare-task.mjs'
? {
metadata: JSON.parse(readFileSync(metadataPath, 'utf8')),
output: readFileSync(outputPath, 'utf8'),
}
: {}),
};
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(root, { recursive: true, force: true });
}
}
function baseHandler(call) {
if (call.route === '/labels')
return Object.keys(STATUS_LABELS).map((name) => ({ name }));
if (call.method !== 'GET') return { ...call.body };
if (call.route === '/issues/2') return issue(2, 'pi:review');
if (call.route === '') return { default_branch: 'develop' };
if (call.route === '/git/ref/heads/apps/demo')
return { object: { sha: 'base-sha' } };
return null;
}
test('prepare reuses the existing task PR without creating a new branch', async () => {
const result = await runFixture(
'prepare-task.mjs',
{ issue: { number: 2 } },
(call) => {
if (call.route === '/pulls') return [pull(3, 'agent/issue-2')];
if (call.route === '/git/ref/heads/agent/issue-2')
return { object: { sha: 'existing-work-sha' } };
return baseHandler(call);
},
);
assert.equal(result.metadata.workBranch, 'agent/issue-2');
assert.match(result.output, /base_sha=existing-work-sha/);
assert.equal(result.metadata.existingPullRequest.number, 3);
assert.equal(
result.requests.filter((call) => call.route === '/git/refs').length,
0,
);
});
test('another task PR blocks a new task on the same application branch', async () => {
const result = await runFixture(
'prepare-task.mjs',
{ issue: { number: 2 } },
(call) => {
if (call.route === '/pulls') return [pull(9, 'agent/issue-8')];
return baseHandler(call);
},
);
assert.equal(result.metadata.workBranch, 'agent/issue-2');
assert.match(result.output, /status=waiting/);
assert.ok(
result.requests.some((call) =>
call.body?.labels?.includes('agent:waiting'),
),
);
});
test('completion wakes the oldest agent:waiting task and no longer queries pi:waiting', async () => {
const result = await runFixture(
'complete-pr.mjs',
{ pull_request: pull(3, 'agent/issue-2') },
(call) => {
if (call.route === '/issues/2' && call.method === 'GET')
return {
...issue(2, 'agent:review'),
user: { login: 'external-user' },
};
if (call.route === '/issues' && call.method === 'GET') {
return call.query.get('labels') === 'agent:waiting'
? [
{ ...issue(4, 'agent:waiting'), user: { login: 'another-user' } },
issue(5, 'agent:waiting'),
]
: [];
}
return baseHandler(call);
},
);
const dispatches = result.requests.filter(
(call) => call.route === '/dispatches',
);
assert.equal(dispatches.length, 1);
assert.deepEqual(dispatches[0].body, {
event_type: 'code-agent-task',
client_payload: { issue_number: 4 },
});
assert.ok(
!result.requests.some(
(call) =>
call.route === '/issues' &&
call.query?.get?.('labels') === 'pi:waiting',
),
);
assert.ok(
result.requests.some(
(call) => call.route === '/issues/2' && call.body?.state === 'closed',
),
);
assert.ok(
result.requests.some(
(call) =>
call.route === '/issues/4' &&
call.body?.labels?.includes('agent:queued'),
),
);
});
test('a PR from a branch that is not a task branch completes nothing', async () => {
for (const ref of ['pi/issue-2', 'feature/issue-2']) {
const result = await runFixture(
'complete-pr.mjs',
{ pull_request: pull(3, ref) },
baseHandler,
);
assert.deepEqual(result.requests, []);
}
});
test('old application-branch completion workflow cannot dispatch a duplicate task', async () => {
const result = await exec(
process.execPath,
[path.resolve(import.meta.dirname, '..', 'complete-pr.mjs')],
{
env: {
...process.env,
GITHUB_EVENT_NAME: 'pull_request',
GITHUB_TOKEN: '',
},
timeout: 15000,
},
);
assert.equal(result.stdout, '');
});
for (const kind of ['build', 'reply']) {
test(`prepare selects existing PR HEAD and live ${kind} comment`, async () => {
const task = {
targetBranch: 'apps/demo',
taskType: '创建新系统',
requirements: 'Original',
acceptanceCriteria: 'Works',
sampleData: '是',
};
const result = await runFixture(
'prepare-task.mjs',
{ client_payload: { issue_number: 2, build_comment_id: 21 } },
(call) => {
if (call.route === '/issues/2/comments' && call.method === 'GET')
return [
{
id: 21,
user: { login: 'gchust', type: 'User' },
author_association: 'OWNER',
body: `${kind === 'build' ? '/build\n' : ''}Add supplier ratings`,
},
{
id: 1000,
user: { login: 'github-actions[bot]', type: 'Bot' },
body: receiptBody({
id: 21,
kind,
task,
prompt: 'Outdated instruction',
status: 'dispatched',
}),
},
];
if (call.route === '/pulls') return [pull(3, 'agent/issue-2')];
if (call.route === '/git/ref/heads/agent/issue-2')
return { object: { sha: 'latest-pr-head' } };
return baseHandler(call);
},
);
assert.equal(result.metadata.buildCommentId, 21);
assert.equal(result.metadata.task.commentKind, kind);
assert.match(result.metadata.task.requirements, /Add supplier ratings/);
assert.doesNotMatch(
result.metadata.task.requirements,
/Outdated instruction/,
);
assert.match(result.metadata.task.requirements, /Build a page/);
assert.equal(result.metadata.existingPullRequest.number, 3);
assert.match(result.output, /base_sha=latest-pr-head/);
assert.match(result.output, new RegExp(`comment_kind=${kind}`));
assert.equal(
result.requests.some((call) =>
call.body?.labels?.includes('agent:running'),
),
kind === 'build',
);
});
}
for (const event of [
{ issue: { number: 2 } },
{ inputs: { issue_number: '2' } },
{ client_payload: { issue_number: 2 } },
]) {
test(`prepare accepts a non-owner Issue through ${Object.keys(event)[0]}`, async () => {
const result = await runFixture('prepare-task.mjs', event, (call) => {
if (call.route === '/issues/2')
return {
...issue(2, 'agent:pending'),
user: { login: 'external-contributor', type: 'User' },
};
if (call.route === '/pulls') return [];
return baseHandler(call);
});
assert.equal(result.metadata.issue.author, 'external-contributor');
assert.equal(result.metadata.task.targetBranch, 'apps/demo');
assert.match(result.output, /status=ready/);
});
}
for (const [branch, supplied] of [
['issues-2', true],
['feature/CRM', true],
]) {
for (const exists of [false, true]) {
test(`prepare ${exists ? 'reuses' : 'creates'} target ${branch} without an apps namespace`, async () => {
const result = await runFixture(
'prepare-task.mjs',
{ issue: { number: 2 } },
(call) => {
if (call.route === '/issues/2' && call.method === 'GET')
return {
...issue(2, 'agent:pending'),
body: supplied
? issueBody.replace('apps/demo', branch)
: issueBody.replace('### 目标分支\n\napps/demo\n\n', ''),
};
if (call.route === `/git/ref/heads/${branch}`)
return exists ? { object: { sha: 'existing-target' } } : null;
if (call.route === '/git/ref/heads/develop')
return { object: { sha: 'default-sha' } };
if (call.route === '/git/refs' && call.method === 'POST')
return { object: { sha: call.body.sha } };
if (call.route === '/pulls') return [];
return baseHandler(call);
},
);
assert.equal(result.metadata.task.targetBranch, branch);
assert.equal(result.metadata.targetCreated, !exists);
assert.equal(result.metadata.workBranch, 'agent/issue-2');
assert.match(
result.output,
new RegExp(`base_sha=${exists ? 'existing-target' : 'default-sha'}`),
);
const created = result.requests.filter(
(call) => call.route === '/git/refs',
);
assert.equal(created.length, exists ? 0 : 1);
if (!exists)
assert.deepEqual(created[0].body, {
ref: `refs/heads/${branch}`,
sha: 'default-sha',
});
});
}
}