fix: keep import-batch running after token failures #69
3 changed files with 272 additions and 23 deletions
1
changelog.d/65.md
Normal file
1
changelog.d/65.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65).
|
||||
|
|
@ -655,6 +655,7 @@ repo
|
|||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const service = item.service || 'github';
|
||||
const isPrivate = item.public ? false : Boolean(item.private);
|
||||
const payload = {
|
||||
|
|
@ -678,7 +679,6 @@ repo
|
|||
if (payload[key] === undefined) delete payload[key];
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.migrateRepo(payload);
|
||||
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
|
||||
results.push({ name, status: 'ok', url: result.html_url });
|
||||
|
|
|
|||
248
test/import-batch.test.js
Normal file
248
test/import-batch.test.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||
|
||||
function run(args, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childEnv = { ...process.env, ...env };
|
||||
childEnv.NODE_OPTIONS = [
|
||||
childEnv.NODE_OPTIONS,
|
||||
'--disable-warning=ExperimentalWarning',
|
||||
].filter(Boolean).join(' ');
|
||||
const child = spawn(process.execPath, [CLI, ...args], {
|
||||
env: childEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function startMigrationServer() {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.parse(body);
|
||||
requests.push({ method: req.method, url: req.url, body: payload });
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
full_name: `destination/${payload.repo_name}`,
|
||||
html_url: `https://forge.test/destination/${payload.repo_name}`,
|
||||
}));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, requests };
|
||||
}
|
||||
|
||||
test('repo import-batch continues after one item has no source token', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const emptyPath = path.join(dir, 'bin');
|
||||
const forgeToken = 'forge-token-must-not-be-printed';
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.mkdirSync(emptyPath);
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: forgeToken,
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'missing-token', from: 'https://github.com/source/first.git', service: 'github' },
|
||||
{ name: 'imported-second', from: 'https://git.example/source/second.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
|
||||
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Failed to import missing-token: No GitHub token found\./);
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/imported-second -> https://forge.test/destination/imported-second\n'
|
||||
+ '\nBatch complete: 1/2 imported.\n');
|
||||
assert.deepEqual(requests, [{
|
||||
method: 'POST',
|
||||
url: '/api/v1/repos/migrate',
|
||||
body: {
|
||||
clone_addr: 'https://git.example/source/second.git',
|
||||
repo_name: 'imported-second',
|
||||
repo_owner: 'destination',
|
||||
service: 'git',
|
||||
private: false,
|
||||
issues: true,
|
||||
labels: true,
|
||||
milestones: true,
|
||||
pull_requests: true,
|
||||
releases: true,
|
||||
wiki: true,
|
||||
lfs: false,
|
||||
},
|
||||
}]);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(forgeToken));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch preserves successful batch output', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-success-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'first', from: 'https://git.example/source/first.git', service: 'git' },
|
||||
{ name: 'second', from: 'https://git.example/source/second.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/first -> https://forge.test/destination/first\n'
|
||||
+ 'Imported: destination/second -> https://forge.test/destination/second\n'
|
||||
+ '\nBatch complete: 2/2 imported.\n');
|
||||
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['first', 'second']);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch sends an explicit GitHub token without printing it', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-token-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const emptyPath = path.join(dir, 'bin');
|
||||
const sourceToken = 'github-token-must-not-be-printed';
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.mkdirSync(emptyPath);
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([{
|
||||
name: 'from-github',
|
||||
from: 'https://github.com/source/repository.git',
|
||||
service: 'github',
|
||||
github_token: sourceToken,
|
||||
}]));
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
|
||||
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/from-github -> https://forge.test/destination/from-github\n'
|
||||
+ '\nBatch complete: 1/1 imported.\n');
|
||||
assert.deepEqual(requests, [{
|
||||
method: 'POST',
|
||||
url: '/api/v1/repos/migrate',
|
||||
body: {
|
||||
clone_addr: 'https://github.com/source/repository.git',
|
||||
repo_name: 'from-github',
|
||||
repo_owner: 'destination',
|
||||
service: 'github',
|
||||
private: false,
|
||||
issues: true,
|
||||
labels: true,
|
||||
milestones: true,
|
||||
pull_requests: true,
|
||||
releases: true,
|
||||
wiki: true,
|
||||
lfs: false,
|
||||
auth_token: sourceToken,
|
||||
},
|
||||
}]);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(sourceToken));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch keeps file and JSON errors at batch level', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-invalid-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const missingFile = path.join(dir, 'missing.json');
|
||||
const malformedFile = path.join(dir, 'malformed.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: 'https://forge.test',
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(malformedFile, '{not json');
|
||||
|
||||
try {
|
||||
const missing = await run(['--config', configFile, 'repo', 'import-batch', '--file', missingFile]);
|
||||
const malformed = await run(['--config', configFile, 'repo', 'import-batch', '--file', malformedFile]);
|
||||
|
||||
assert.equal(missing.status, 1);
|
||||
assert.match(missing.stderr, /^Batch import failed: ENOENT:/);
|
||||
assert.equal(missing.stdout, '');
|
||||
assert.equal(malformed.status, 1);
|
||||
assert.match(malformed.stderr, /^Batch import failed: /);
|
||||
assert.match(malformed.stderr, /JSON/);
|
||||
assert.equal(malformed.stdout, '');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch excludes skipped invalid entries from the summary', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-skip-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'missing-source' },
|
||||
{ name: 'valid', from: 'https://git.example/source/valid.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, 'Skipping invalid manifest entry: {"name":"missing-source"}\n');
|
||||
assert.match(result.stdout, /Batch complete: 1\/1 imported\./);
|
||||
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['valid']);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue