OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / migrate / migrate-issues.js

97 lines · javascript ref:
/**
 * 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
}