OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / migrate-issues.test.js

141 lines · javascript ref:
// 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')
})