feat: support MCP elicitation and rebuild release
This commit is contained in:
243
scripts/gitea-webhook-workspace-unit.js
Normal file
243
scripts/gitea-webhook-workspace-unit.js
Normal file
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
DeliveryStore,
|
||||
createGiteaWebhookReceiver,
|
||||
createGiteaWebhookRoute,
|
||||
normalizeWebhookEvent,
|
||||
parseBotMention,
|
||||
verifyWebhookSignature,
|
||||
} = require('../lib/gitea-webhook');
|
||||
const { buildWorkflowMarker } = require('../lib/gitea-workflow-codex');
|
||||
const {
|
||||
BlockedWorkspaceError,
|
||||
createGiteaWorkspaceManager,
|
||||
createRepositoryLockManager,
|
||||
normalizeRepository,
|
||||
withTempGitCredentials,
|
||||
} = require('../lib/gitea-workspace');
|
||||
|
||||
function sign(body, secret) {
|
||||
return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
|
||||
}
|
||||
|
||||
function payloadFixture(overrides = {}) {
|
||||
return {
|
||||
action: 'created',
|
||||
repository: {
|
||||
owner: { login: 'alice' },
|
||||
name: 'demo',
|
||||
full_name: 'alice/demo',
|
||||
clone_url: 'https://gitea.example/alice/demo.git',
|
||||
default_branch: 'main',
|
||||
},
|
||||
issue: { number: 7, title: '修复问题' },
|
||||
comment: {
|
||||
id: 19,
|
||||
body: '@ccweb-bot 请检查这个问题',
|
||||
user: { id: 11, login: 'alice' },
|
||||
},
|
||||
sender: { id: 11, login: 'alice' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const secret = 'webhook-secret';
|
||||
const body = Buffer.from(JSON.stringify(payloadFixture()));
|
||||
assert.equal(verifyWebhookSignature(body, sign(body, secret), secret), true);
|
||||
assert.equal(verifyWebhookSignature(body, sign(body, secret).slice(7), secret), true);
|
||||
assert.equal(verifyWebhookSignature(Buffer.from(`${body}\n`), sign(body, secret), secret), false);
|
||||
assert.equal(parseBotMention('@ccweb-bot 做事').instruction, '做事');
|
||||
assert.equal(parseBotMention('提及 @ccweb-bot-extra'), null);
|
||||
|
||||
const normalized = normalizeWebhookEvent({
|
||||
payload: payloadFixture(),
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1' },
|
||||
instanceId: 'default',
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
});
|
||||
assert.equal(normalized.ignored, false);
|
||||
assert.equal(normalized.repository.fullName, 'alice/demo');
|
||||
assert.equal(normalized.resource.key, 'issue:7');
|
||||
assert.equal(normalized.mention.instruction, '请检查这个问题');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: '@ccweb-bot 状态', user: { login: 'ccweb-bot' } }, sender: { login: 'ccweb-bot' } }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'bot_self_comment');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: '普通评论', user: { login: 'alice' } } }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'missing_mention');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: `@ccweb-bot 状态\n${buildWorkflowMarker({ taskId: 't', turnId: 'u', resourceKey: 'r' })}`, user: {} }, sender: {} }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'workflow_marker_comment');
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-webhook-unit-'));
|
||||
try {
|
||||
const deliveryFile = path.join(tempRoot, 'deliveries.json');
|
||||
const store = new DeliveryStore({ filePath: deliveryFile });
|
||||
const tasks = [];
|
||||
const receiver = createGiteaWebhookReceiver({
|
||||
secret,
|
||||
instanceId: 'default',
|
||||
deliveryStore: store,
|
||||
onTask: async (task) => tasks.push(task),
|
||||
});
|
||||
const accepted = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(accepted.statusCode, 202);
|
||||
assert.match(accepted.payload.taskId, /^gitea-task-/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(tasks.length, 1);
|
||||
const duplicate = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(duplicate.statusCode, 200);
|
||||
assert.equal(duplicate.payload.duplicate, true);
|
||||
const invalid = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-2', 'x-gitea-signature': 'sha256=bad' },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(invalid.statusCode, 401);
|
||||
assert.equal(store.has('default:d-2'), false);
|
||||
const ignored = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'push', 'x-gitea-delivery': 'd-3', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(ignored.statusCode, 200);
|
||||
assert.equal(ignored.payload.ignored, true);
|
||||
const reloaded = new DeliveryStore({ filePath: deliveryFile });
|
||||
assert.equal(reloaded.has('default:d-1'), true);
|
||||
|
||||
const route = createGiteaWebhookRoute(receiver);
|
||||
const request = new EventEmitter();
|
||||
request.method = 'POST';
|
||||
request.url = '/api/gitea/webhook';
|
||||
request.headers = { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-route', 'x-gitea-signature': sign(body, secret) };
|
||||
const responseDone = new Promise((resolve) => {
|
||||
const response = {
|
||||
headersSent: false,
|
||||
writeHead(statusCode) { this.statusCode = statusCode; this.headersSent = true; },
|
||||
end(value) { this.body = JSON.parse(value); resolve(this); },
|
||||
};
|
||||
assert.equal(route(request, response, new URL('http://localhost/api/gitea/webhook')), true);
|
||||
request.emit('data', body);
|
||||
request.emit('end');
|
||||
});
|
||||
const response = await responseDone;
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.body.state, 'queued');
|
||||
assert.equal(route({ method: 'GET', url: '/api/gitea/webhook', headers: {} }, {}, '/api/gitea/webhook'), false);
|
||||
|
||||
receiver.updateConfig({ botLogin: 'renamed-bot' });
|
||||
const renamedBody = Buffer.from(JSON.stringify(payloadFixture({
|
||||
comment: { id: 20, body: '@renamed-bot 继续处理', user: { id: 11, login: 'alice' } },
|
||||
})));
|
||||
const renamed = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-renamed', 'x-gitea-signature': sign(renamedBody, secret) },
|
||||
rawBody: renamedBody,
|
||||
});
|
||||
assert.equal(renamed.statusCode, 202);
|
||||
assert.equal(renamed.payload.status, 'accepted');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workspace-unit-'));
|
||||
try {
|
||||
assert.throws(() => normalizeRepository({ owner: '../bad', name: 'demo', cloneUrl: 'https://gitea.example/a/demo.git' }), /owner/);
|
||||
assert.throws(() => normalizeRepository({ owner: 'alice', name: 'demo', cloneUrl: 'http://gitea.example/a/demo.git' }), /HTTPS/);
|
||||
const calls = [];
|
||||
const manager = createGiteaWorkspaceManager({
|
||||
workspaceRoot,
|
||||
instanceId: 'default',
|
||||
botUsername: 'ccweb-bot',
|
||||
gitRunner: async ({ args, cwd, env }) => {
|
||||
calls.push({ args, cwd, env });
|
||||
if (args[0] === 'clone') {
|
||||
fs.mkdirSync(path.join(args[args.length - 1], '.git'), { recursive: true });
|
||||
}
|
||||
if (args[0] === 'status') return { code: 0, stdout: '', stderr: '' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const repository = { owner: 'alice', name: 'demo', cloneUrl: 'https://gitea.example/alice/demo.git', defaultBranch: 'main' };
|
||||
const first = await manager.ensureRepository(repository, { token: 'token-value' });
|
||||
assert.equal(first.status, 'ready');
|
||||
assert.equal(calls[0].args[0], 'clone');
|
||||
assert.deepEqual(calls[0].args.slice(0, 7), ['clone', '--origin', 'origin', '--depth', '1', '--single-branch', '--branch']);
|
||||
assert.equal(calls[0].args[7], 'main');
|
||||
assert.equal(calls[0].args.includes('token-value'), false);
|
||||
assert.equal(calls[0].env.GIT_ASKPASS.endsWith('askpass.sh'), true);
|
||||
await manager.ensureRepository(repository, { token: 'token-value' });
|
||||
assert.deepEqual(calls.slice(1).map((item) => item.args[0]), ['status', 'fetch', 'checkout', 'merge']);
|
||||
|
||||
const dirtyManager = createGiteaWorkspaceManager({
|
||||
workspaceRoot: path.join(workspaceRoot, 'dirty'),
|
||||
instanceId: 'default',
|
||||
gitRunner: async ({ args }) => {
|
||||
if (args[0] === 'clone') return { code: 0, stdout: '', stderr: '' };
|
||||
if (args[0] === 'status') return { code: 0, stdout: ' M tracked.txt\n', stderr: '' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const dirtyPath = dirtyManager.workspacePath(repository);
|
||||
fs.mkdirSync(path.join(dirtyPath, '.git'), { recursive: true });
|
||||
await assert.rejects(() => dirtyManager.ensureRepository(repository, { token: 'token-value' }), (error) => {
|
||||
assert(error instanceof BlockedWorkspaceError);
|
||||
assert.equal(error.code, 'blocked_workspace');
|
||||
return true;
|
||||
});
|
||||
const dirtyContinuation = await dirtyManager.ensureRepository(repository, {
|
||||
token: 'token-value', allowDirty: true, dirtySessionKey: 'default:alice/demo:issue:7',
|
||||
});
|
||||
assert.equal(dirtyContinuation.status, 'dirty_ready');
|
||||
assert.equal(dirtyContinuation.dirtySessionKey, 'default:alice/demo:issue:7');
|
||||
} finally {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-lock-unit-'));
|
||||
try {
|
||||
const locks = createRepositoryLockManager({ lockRoot, retryMs: 5, waitTimeoutMs: 1000 });
|
||||
const order = [];
|
||||
await Promise.all([
|
||||
locks.withLock('same-repo', async () => {
|
||||
order.push('first-start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
order.push('first-end');
|
||||
}),
|
||||
locks.withLock('same-repo', async () => order.push('second')),
|
||||
]);
|
||||
assert.deepEqual(order, ['first-start', 'first-end', 'second']);
|
||||
} finally {
|
||||
fs.rmSync(lockRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
let seenEnv;
|
||||
await withTempGitCredentials({ token: 'secret-token', username: 'bot' }, async (env) => {
|
||||
seenEnv = env;
|
||||
});
|
||||
assert.equal(seenEnv.CCWEB_GIT_TOKEN, 'secret-token');
|
||||
assert.equal(fs.existsSync(seenEnv.GIT_ASKPASS), false, '临时凭据脚本必须在命令完成后清理');
|
||||
console.log('Gitea webhook/workspace unit checks passed');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user