// migrateIssues (#42): replay org issues from a v1 separate tracker into a v2
// target. Two levels: (1) pure transform with injected sinks, (2) end-to-end
// real v1 HyperIssues -> real v2 HyperIssues with parity asserted. Fixtures
// build REAL HyperIssues on fresh tmp stores — no prod data, no copy.
import test from 'brittle'
import Corestore from 'corestore'
import { HyperIssues } from 'hyper-issues'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { readOldIssues, readOldLabels } from '../lib/services/migrate/read-old-issues.js'
import { migrateIssues } from '../lib/services/migrate/migrate-issues.js'
async function makeTracker (t, ns) {
const store = new Corestore(mkdtempSync(join(tmpdir(), 'mig-issues-')))
const tracker = new HyperIssues(store, { namespace: ns })
await tracker.ready()
t.teardown(async () => {
try { await tracker.close() } catch {}
try { await store.close() } catch {}
})
return tracker
}
test('migrateIssues (transform): replays labels then issues+comments to sinks', async (t) => {
const src = await makeTracker(t, 'src')
await src.createLabel('bug', '#d73a4a', 'broken', 'Bug')
const id1 = await src.create({ title: 'A', body: 'aa', author: 'alice', labels: ['bug'] })
const id2 = await src.create({ title: 'B', author: 'bob' })
await src.comment(id1, { body: 'c1', author: 'bob' })
await src.comment(id1, { body: 'c2', author: 'carol' })
const order = []
const labels = []
const issues = []
const comments = []
const summary = await migrateIssues({
oldIssues: readOldIssues(src),
oldLabels: readOldLabels(src),
createLabel: async (n) => { order.push('label'); labels.push(n) },
createIssue: async (id, o) => { order.push('issue'); issues.push({ id, ...o }) },
createComment: async (iid, cid, o) => { comments.push({ iid, cid, ...o }) }
})
t.is(summary.labels, 1, 'one label migrated')
t.is(summary.issues, 2, 'two issues migrated')
t.is(summary.comments, 2, 'two comments migrated')
t.is(order[0], 'label', 'labels are replayed before issues')
t.alike(issues.map(i => i.id).sort((a, b) => a - b), [id1, id2])
const a = issues.find(i => i.id === id1)
t.is(a.status, 'open', 'status carried')
t.ok(a.createdAt && a.updatedAt, 'timestamps carried')
t.alike(comments.map(c => c.iid).sort((x, y) => x - y), [id1, id1], 'both comments belong to issue 1')
})
test('migrateIssues (transform): seen set skips already-migrated ids (resumable)', async (t) => {
const src = await makeTracker(t, 'src')
const id1 = await src.create({ title: 'A', author: 'alice' })
const id2 = await src.create({ title: 'B', author: 'bob' })
const seen = new Set([id1])
const got = []
const summary = await migrateIssues({
oldIssues: readOldIssues(src),
createIssue: async (id) => got.push(id),
seen
})
t.is(summary.issues, 1, 'only the un-seen issue migrated')
t.is(summary.skipped, 1, 'the seen issue skipped')
t.alike(got, [id2], 'migrated exactly the un-seen id')
})
test('migrateIssues (transform): requires { oldIssues, createIssue }', async (t) => {
await t.exception(() => migrateIssues({}), /requires \{ oldIssues, createIssue \}/)
await t.exception(() => migrateIssues({ oldIssues: [] }), /requires \{ oldIssues, createIssue \}/)
})
test('migrateIssues end-to-end: v1 tracker -> v2 tracker, parity verified', async (t) => {
const src = await makeTracker(t, 'src')
const dst = await makeTracker(t, 'dst')
await src.createLabel('bug', '#d73a4a', 'broken', 'Bug')
const id1 = await src.create({ title: 'First', body: 'body1', author: 'alice', labels: ['bug'] })
const id2 = await src.create({ title: 'Second', body: 'body2', author: 'bob' })
await src.update(id2, { status: 'closed' }, 'bob')
await src.comment(id1, { body: 'reproduced', author: 'bob' })
await src.comment(id1, { body: 'patch up', author: 'carol' })
const summary = await migrateIssues({
oldIssues: readOldIssues(src),
oldLabels: readOldLabels(src),
createIssue: (id, o) => dst.createWithId(id, o),
createComment: (iid, cid, o) => dst.commentWithId(iid, cid, o),
createLabel: (n, c, d, dn) => dst.createLabel(n, c, d, dn)
})
t.is(summary.errors, 0, 'no errors')
t.is(summary.issues, 2, 'both issues migrated')
// PARITY: dst has the same issues with preserved ids / status / author
const a = await dst.get(id1)
const b = await dst.get(id2)
t.is(a.title, 'First', 'issue 1 title')
t.is(a.author, 'alice', 'issue 1 author preserved')
t.alike(a.labels, ['bug'], 'issue 1 labels preserved')
t.is(b.status, 'closed', 'issue 2 closed status preserved')
const aComments = await dst.getComments(id1)
t.is(aComments.length, 2, 'issue 1 comments preserved')
t.alike(aComments.map(c => c.body).sort(), ['patch up', 'reproduced'], 'comment bodies preserved')
const labels = await dst.listLabels()
t.ok(labels.find(l => l.name === 'bug'), 'label migrated')
// counter advanced past the migrated ids (so new issues get fresh ids)
const id3 = await dst.create({ title: 'native', author: 'dave' })
t.ok(id3 > id2, 'counter advanced past migrated ids (' + id3 + ' > ' + id2 + ')')
})
test('migrateIssues end-to-end is idempotent: re-running clobbers nothing', async (t) => {
const src = await makeTracker(t, 'src')
const dst = await makeTracker(t, 'dst')
const id1 = await src.create({ title: 'Only', body: 'orig', author: 'alice' })
await src.comment(id1, { body: 'one comment', author: 'bob' })
const run = () => migrateIssues({
oldIssues: readOldIssues(src),
createIssue: (id, o) => dst.createWithId(id, o),
createComment: (iid, cid, o) => dst.commentWithId(iid, cid, o)
})
await run()
await run() // second pass — apply skip-if-exists must keep it stable
const all = await dst.list()
t.is(all.length, 1, 'still exactly one issue after re-run')
t.is(all[0].body, 'orig', 'body not clobbered by the replay')
const comments = await dst.getComments(id1)
t.is(comments.length, 1, 'still exactly one comment after re-run')
})