/**
* Migration upgrade function: org issues v1 (a separate `issues:org:<name>`
* autobase per org) -> v2 (#42 linked-core: issues live IN the org's own
* room). Per-repo issue trackers are UNCHANGED across v1->v2 (their own
* autobase) and are NOT touched here — they pass through.
*
* Pure transformation, dependency-injected so it's unit-testable without a
* live corestore. The real wiring mounts each org's old separate tracker
* READ-ONLY (readOldIssues/readOldLabels) and replays onto the NEW org room's
* HyperIssues (createWithId/commentWithId/createLabel — all idempotent at the
* apply layer: a create-issue/create-comment with an explicit id won't
* clobber an existing one). Source is never mutated.
*
* Old issue record shape (v1 HyperIssues view, with comments attached by
* readOldIssues):
* { id, title, body, author, status, labels, images, createdAt, updatedAt,
* comments: [ { commentId, body, author, createdAt } ] }
*
* deps:
* oldIssues async iterable | array of old issue records
* oldLabels? async iterable | array of old label records
* createIssue(id, opts) target.createWithId — preserves id/status/timestamps
* createComment(iid, cid, o) target.commentWithId — preserves commentId/createdAt
* createLabel(name, color, desc, displayName) target.createLabel (optional)
* seen Set of issue ids already migrated (resumability)
* onError(key, err) observer (optional)
*
* Labels are replayed FIRST (issues reference them). Idempotent at the issue
* level via `seen` (skip already-migrated ids) AND at the apply layer.
* Returns a summary { issues, comments, labels, skipped, errors }.
*/
export async function migrateIssues (deps = {}) {
const {
oldIssues, oldLabels, createIssue, createComment, createLabel,
seen = new Set(), onError
} = deps
if (!oldIssues || typeof createIssue !== 'function') {
throw new Error('migrateIssues requires { oldIssues, createIssue }')
}
const summary = { issues: 0, comments: 0, labels: 0, skipped: 0, errors: 0 }
// Labels first — issues carry label names that should already resolve.
if (oldLabels && typeof createLabel === 'function') {
for await (const l of oldLabels) {
if (!l || !l.name) continue
try {
await createLabel(l.name, l.color, l.description, l.displayName)
summary.labels++
} catch (err) {
summary.errors++
if (onError) onError('label:' + l.name, err)
}
}
}
for await (const issue of oldIssues) {
if (!issue || typeof issue.id !== 'number') continue
if (seen.has(issue.id)) { summary.skipped++; continue }
try {
await createIssue(issue.id, {
title: issue.title,
body: issue.body || '',
author: issue.author,
labels: issue.labels || [],
images: issue.images || [],
status: issue.status || 'open',
createdAt: issue.createdAt,
updatedAt: issue.updatedAt
})
summary.issues++
for (const c of (issue.comments || [])) {
if (!c || !c.commentId) continue
// create-comment validate requires a non-empty body; a body-less v1
// comment can't be replayed — skip rather than abort the issue.
if (!c.body) continue
if (typeof createComment === 'function') {
await createComment(issue.id, c.commentId, {
body: c.body,
author: c.author,
createdAt: c.createdAt
})
summary.comments++
}
}
seen.add(issue.id)
} catch (err) {
summary.errors++
if (onError) onError('issue:' + issue.id, err)
}
}
return summary
}