OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / facades / repo-facade.js

451 lines · javascript ref:
/**
 * RepoFacade — LinkedRepo-compatible facade over unified MetadataBase
 *
 * Implements the exact same public API as LinkedRepo but delegates
 * all operations to MetadataBase. Parameterized by repoName.
 * Zero changes needed in route files.
 *
 * Key renames (all scoped by repoName):
 *   writer:{userId}              → repo-writer:{repoName}:{userId}
 *   ref:{refPath}                → repo-ref:{repoName}:{refPath}
 *   issue:{id}                   → issue:{repoName}:{id}
 *   comment:{issueId}:{cid}      → comment:{repoName}:{issueId}:{cid}
 *   label:{name}                 → label:{repoName}:{name}
 *   meta:{key}                   → repo-meta:{repoName}:{key}
 *   counter:issues               → issue-counter:{repoName}
 */

import crypto from 'bare-crypto'

export class RepoFacade {
  constructor ({ metadataBase, repoName }) {
    this._meta = metadataBase
    this.repoName = repoName
    this._imageBlobs = null
    this._imageRegistry = null
  }

  // ── Properties (delegate to MetadataBase) ────────────────────

  get bootstrapKey () { return this._meta.key }
  get bootstrapKeyHex () { return this._meta.bootstrapKeyHex }
  get discoveryKey () { return this._meta.discoveryKey }
  get localKey () { return this._meta.localKey }
  get writable () { return this._meta.writable }
  get view () { return this._meta.view }

  // Expose _base for wakeup protocol compatibility
  get _base () { return this._meta._base }

  async ready () {}

  // ── Image store ──────────────────────────────────────────────

  setImageStore (blobs, imageRegistry) {
    this._imageBlobs = blobs
    this._imageRegistry = imageRegistry
  }

  async storeImage (dataUrl) {
    if (!this._imageBlobs || !this._imageRegistry) return null
    const hash = crypto.createHash('sha256').update(dataUrl).digest('hex').slice(0, 16)
    const existing = await this._imageRegistry.get(`image:${hash}`)
    if (existing) return hash

    const buf = Buffer.from(dataUrl)
    const blobId = await this._imageBlobs.put(buf)
    await this._imageRegistry.put(`image:${hash}`, { blobId, size: buf.length, storedAt: Date.now() })
    return hash
  }

  async getImage (hash) {
    if (!this._imageBlobs || !this._imageRegistry) return null
    const entry = await this._imageRegistry.get(`image:${hash}`)
    if (!entry) return null
    const buf = await this._imageBlobs.get(entry.value.blobId)
    return buf ? buf.toString() : null
  }

  // ── Writer management ────────────────────────────────────────

  async addWriter (userId, serverKey, identityKey, proof, role = 'writer') {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'add-writer',
      repoName: this.repoName,
      userId,
      serverKey,
      identityKey: identityKey || null,
      proof: proof || null,
      role,
      addedBy: null,
      timestamp: Date.now()
    })
  }

  async removeWriter (userId, reason) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'remove-writer',
      repoName: this.repoName,
      userId,
      reason: reason || null,
      timestamp: Date.now()
    })
  }

  async getWriter (userId) {
    return this._meta.getByKey(`repo-writer:${this.repoName}:${userId}`)
  }

  async listWriters () {
    await this._meta.update()
    const results = []
    const prefix = `repo-writer:${this.repoName}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const userId = entry.key.slice(prefix.length)
      results.push({ userId, ...entry.value })
    }
    return results
  }

  // ── Ref management ───────────────────────────────────────────

  async updateRef (ref, hash, userId) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'update-ref',
      repoName: this.repoName,
      ref,
      hash,
      userId,
      timestamp: Date.now()
    })
  }

  async deleteRef (ref, userId) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'delete-ref',
      repoName: this.repoName,
      ref,
      userId,
      timestamp: Date.now()
    })
  }

  async getRef (ref) {
    return this._meta.getByKey(`repo-ref:${this.repoName}:${ref}`)
  }

  async listRefs () {
    await this._meta.update()
    const results = []
    const prefix = `repo-ref:${this.repoName}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const ref = entry.key.slice(prefix.length)
      results.push({ ref, ...entry.value })
    }
    return results
  }

  // ── Issue management ─────────────────────────────────────────

  async createIssue (title, body, userId, options = {}) {
    await this._meta.update()
    const counter = await this._meta.view.get(`issue-counter:${this.repoName}`)
    const id = counter ? counter.value.next : 0

    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'create-issue',
      repoName: this.repoName,
      id,
      title,
      body,
      userId,
      labels: options.labels || [],
      images: options.images || [],
      timestamp: Date.now()
    })

    return id
  }

  async updateIssue (id, updates, userId) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'update-issue',
      repoName: this.repoName,
      id,
      ...updates,
      userId,
      timestamp: Date.now()
    })
  }

  async createComment (issueId, body, userId) {
    const commentId = crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'create-comment',
      repoName: this.repoName,
      issueId,
      commentId,
      body,
      userId,
      timestamp: Date.now()
    })
    return commentId
  }

  async getIssue (id) {
    return this._meta.getByKey(`issue:${this.repoName}:${id}`)
  }

  async getIssueWithImages (id) {
    const issue = await this.getIssue(id)
    if (!issue) return null
    if (issue.images && issue.images.length > 0 && this._imageRegistry) {
      const imageData = {}
      for (const hash of issue.images) {
        const dataUrl = await this.getImage(hash)
        if (dataUrl) imageData[hash] = dataUrl
      }
      issue.imageData = imageData
    }
    return issue
  }

  async listIssues (filters = {}) {
    await this._meta.update()
    const results = []
    const prefix = `issue:${this.repoName}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const issue = entry.value
      // Apply filters
      if (filters.status && issue.status !== filters.status) continue
      if (filters.author && issue.author !== filters.author) continue
      if (filters.labels && filters.labels.length > 0) {
        const hasLabel = filters.labels.some(l => issue.labels && issue.labels.includes(l))
        if (!hasLabel) continue
      }
      if (filters.search) {
        const q = filters.search.toLowerCase()
        const inTitle = issue.title && issue.title.toLowerCase().includes(q)
        const inBody = issue.body && issue.body.toLowerCase().includes(q)
        if (!inTitle && !inBody) continue
      }
      results.push(issue)
    }

    // Sort
    const sort = filters.sort || 'newest'
    if (sort === 'newest') {
      results.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
    } else if (sort === 'oldest') {
      results.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
    } else if (sort === 'updated') {
      results.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
    }

    return results
  }

  async getIssueCounts () {
    await this._meta.update()
    let open = 0; let closed = 0
    const prefix = `issue:${this.repoName}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      if (entry.value.status === 'open') open++
      else closed++
    }
    return { open, closed, total: open + closed }
  }

  async getComments (issueId) {
    await this._meta.update()
    const results = []
    const prefix = `comment:${this.repoName}:${issueId}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const commentId = entry.key.slice(prefix.length)
      results.push({ commentId, ...entry.value })
    }
    return results
  }

  // ── Label management ─────────────────────────────────────────

  async createLabel (name, color, description, displayName) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'create-label',
      repoName: this.repoName,
      name,
      color: color || '#ccc',
      description: description || '',
      displayName: displayName || name
    })
  }

  async updateLabel (name, updates) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'update-label',
      repoName: this.repoName,
      name,
      ...updates
    })
  }

  async deleteLabel (name) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'delete-label',
      repoName: this.repoName,
      name
    })
  }

  async getLabel (name) {
    const normalized = name.toLowerCase().trim()
    const val = await this._meta.getByKey(`label:${this.repoName}:${normalized}`)
    if (!val) return null
    return { name: normalized, ...val }
  }

  async listLabels () {
    await this._meta.update()
    const results = []
    const prefix = `label:${this.repoName}:`
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const name = entry.key.slice(prefix.length)
      results.push({ name, ...entry.value })
    }
    results.sort((a, b) => a.name.localeCompare(b.name))
    return results
  }

  async initializeDefaultLabels () {
    const existing = await this.listLabels()
    if (existing.length > 0) return existing

    const defaults = [
      { name: 'bug', color: '#d73a4a', description: 'Something isn\'t working' },
      { name: 'enhancement', color: '#a2eeef', description: 'New feature or request' },
      { name: 'documentation', color: '#0075ca', description: 'Improvements or additions to documentation' },
      { name: 'good first issue', color: '#7057ff', description: 'Good for newcomers' },
      { name: 'help wanted', color: '#008672', description: 'Extra attention is needed' },
      { name: 'question', color: '#d876e3', description: 'Further information is requested' }
    ]

    for (const label of defaults) {
      await this.createLabel(label.name, label.color, label.description)
    }

    return this.listLabels()
  }

  async addLabelsToIssue (id, labelNames, userId) {
    await this._meta.update()
    const issue = await this.getIssue(id)
    if (!issue) return null

    // Verify labels exist
    const validLabels = []
    for (const name of labelNames) {
      const label = await this.getLabel(name)
      if (label) validLabels.push(name.toLowerCase().trim())
    }

    const merged = [...new Set([...(issue.labels || []), ...validLabels])]
    await this.updateIssue(id, { labels: merged }, userId)

    return this.getIssue(id)
  }

  async removeLabelsFromIssue (id, labelNames, userId) {
    await this._meta.update()
    const issue = await this.getIssue(id)
    if (!issue) return null

    const removeSet = new Set(labelNames.map(n => n.toLowerCase().trim()))
    const filtered = (issue.labels || []).filter(l => !removeSet.has(l))
    await this.updateIssue(id, { labels: filtered }, userId)

    return this.getIssue(id)
  }

  async setIssueLabels (id, labelNames, userId) {
    // Verify labels exist
    const validLabels = []
    for (const name of labelNames) {
      const label = await this.getLabel(name)
      if (label) validLabels.push(name.toLowerCase().trim())
    }

    await this.updateIssue(id, { labels: validLabels }, userId)
    return this.getIssue(id)
  }

  // ── Meta ─────────────────────────────────────────────────────

  async setMeta (key, value) {
    await this._meta.append({
      v: 2,
      ns: 'issue',
      type: 'set-meta',
      repoName: this.repoName,
      key,
      value
    })
  }

  async getMeta (key) {
    const val = await this._meta.getByKey(`repo-meta:${this.repoName}:${key}`)
    return val ? val.value : null
  }

  // ── Lifecycle ────────────────────────────────────────────────

  async replicate (stream) {
    return stream
  }

  async update () {
    await this._meta.update()
  }

  async close () {
    // MetadataBase owns the Autobase lifecycle
  }
}