From 721ba343cc27d1dff2ceb527eea9a6141cd0b24d Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 06:58:02 +0000 Subject: [PATCH 1/6] test: reproduce import-batch token abort --- test/import-batch.test.js | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/import-batch.test.js diff --git a/test/import-batch.test.js b/test/import-batch.test.js new file mode 100644 index 0000000..0435722 --- /dev/null +++ b/test/import-batch.test.js @@ -0,0 +1,93 @@ +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 child = spawn(process.execPath, [CLI, ...args], { + env: { ...process.env, ...env }, + }); + 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 })); + }); +} + +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 requests = []; + const server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + requests.push({ method: req.method, url: req.url, body: JSON.parse(body) }); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + full_name: 'destination/imported-second', + html_url: 'https://forge.test/destination/imported-second', + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + 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 }); + } +}); From 1bb4bd608c709eaef13ad18398ab988481a09166 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 06:58:57 +0000 Subject: [PATCH 2/6] fix: isolate import-batch token failures --- src/cli.js | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/cli.js b/src/cli.js index d3f8eb1..5e97206 100755 --- a/src/cli.js +++ b/src/cli.js @@ -655,30 +655,30 @@ repo continue; } - const service = item.service || 'github'; - const isPrivate = item.public ? false : Boolean(item.private); - const payload = { - clone_addr: from, - repo_name: name, - repo_owner: item.owner || item.repo_owner || config.login, - service, - description: item.description || undefined, - private: isPrivate, - issues: normalizeBool(item.issues, true), - labels: normalizeBool(item.labels, true), - milestones: normalizeBool(item.milestones, true), - pull_requests: normalizeBool(item.pull_requests, true), - releases: normalizeBool(item.releases, true), - wiki: normalizeBool(item.wiki, true), - lfs: normalizeBool(item.lfs, false), - auth_token: resolveSourceToken(item.github_token, service), - }; - - Object.keys(payload).forEach((key) => { - if (payload[key] === undefined) delete payload[key]; - }); - try { + const service = item.service || 'github'; + const isPrivate = item.public ? false : Boolean(item.private); + const payload = { + clone_addr: from, + repo_name: name, + repo_owner: item.owner || item.repo_owner || config.login, + service, + description: item.description || undefined, + private: isPrivate, + issues: normalizeBool(item.issues, true), + labels: normalizeBool(item.labels, true), + milestones: normalizeBool(item.milestones, true), + pull_requests: normalizeBool(item.pull_requests, true), + releases: normalizeBool(item.releases, true), + wiki: normalizeBool(item.wiki, true), + lfs: normalizeBool(item.lfs, false), + auth_token: resolveSourceToken(item.github_token, service), + }; + + Object.keys(payload).forEach((key) => { + if (payload[key] === undefined) delete payload[key]; + }); + const result = await client.migrateRepo(payload); console.log(`Imported: ${result.full_name} -> ${result.html_url}`); results.push({ name, status: 'ok', url: result.html_url }); From 5f2f58a249bf0425eef82538b852c7044ae554e1 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 06:59:58 +0000 Subject: [PATCH 3/6] test: cover import-batch result boundaries --- test/import-batch.test.js | 111 ++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/test/import-batch.test.js b/test/import-batch.test.js index 0435722..689c0bc 100644 --- a/test/import-batch.test.js +++ b/test/import-batch.test.js @@ -24,27 +24,33 @@ function run(args, env = {}) { }); } -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'; +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', () => { - requests.push({ method: req.method, url: req.url, body: JSON.parse(body) }); + 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/imported-second', - html_url: 'https://forge.test/destination/imported-second', + 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}`, @@ -91,3 +97,90 @@ test('repo import-batch continues after one item has no source token', async () 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 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 }); + } +}); From 4333ce63bf5f5761247094a3fc798b69239c4b20 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 07:00:17 +0000 Subject: [PATCH 4/6] docs: note import-batch token handling --- changelog.d/65.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/65.md diff --git a/changelog.d/65.md b/changelog.d/65.md new file mode 100644 index 0000000..ab64a1e --- /dev/null +++ b/changelog.d/65.md @@ -0,0 +1 @@ +- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65). From 82494e94fdf3e2ee5618cec96095ec016cbec612 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 07:30:26 +0000 Subject: [PATCH 5/6] test: support node engine floor --- test/import-batch.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/import-batch.test.js b/test/import-batch.test.js index 689c0bc..16a96cf 100644 --- a/test/import-batch.test.js +++ b/test/import-batch.test.js @@ -10,8 +10,13 @@ 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: { ...process.env, ...env }, + env: childEnv, }); let stdout = ''; let stderr = ''; From 37e6a2ad5a0141a344272004d0ef5c3d85d6de17 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Fri, 4 Sep 2026 07:31:26 +0000 Subject: [PATCH 6/6] test: cover explicit import token handling --- test/import-batch.test.js | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/import-batch.test.js b/test/import-batch.test.js index 16a96cf..cc7e46d 100644 --- a/test/import-batch.test.js +++ b/test/import-batch.test.js @@ -134,6 +134,63 @@ test('repo import-batch preserves successful batch output', async () => { } }); +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');