OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues / lib / hyper-issues.js

582 lines · javascript ref:
/**
 * HyperIssues — P2P issue tracking built on hyper-rooms
 *
 * Multi-writer, replicating, offline-first issue tracker. Wraps a Room with
 * the issuesSchema and provides a high-level API for issues, comments,
 * labels, and (optionally) git refs.
 *
 * Usage:
 *
 *   const tracker = new HyperIssues(corestore, { namespace: 'my-project' })
 *   await tracker.ready()
 *
 *   const id = await tracker.create({ title: 'Bug', body: 'Details', author: 'alice' })
 *   const issue = await tracker.get(id)
 *   await tracker.comment(id, { body: 'reproduced', author: 'bob' })
 *   const issues = await tracker.list({ status: 'open' })
 *
 *   tracker.localKey  // exchange via P2P for addWriter on the inviter
 */

import { Room, createRoomPairing, joinRoomPairing, resumeRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms'
import { issuesSchema, DEFAULT_LABELS, ZERO_OID } from './schema.js'

export class HyperIssues {
  /**
   * @param {Corestore} corestore - ignored when `options.room` is given
   * @param {object} options
   * @param {string} [options.namespace='issues']
   * @param {Buffer|string|null} [options.bootstrapKey=null]
   * @param {Room} [options.room] - wrap an EXISTING Room instead of
   *   creating one. Used for "linked-core" composites: an org Room built
   *   with composeSchemas([orgsSchema, issuesSchema]) already handles
   *   issue ops, and a HyperIssues wrapping it provides the issue API
   *   over that one autobase. The Room's schema MUST handle issue ops.
   * @param {boolean} [options.ownsRoom] - whether close() closes the
   *   Room. Defaults to true when this instance created the Room, false
   *   when wrapping an external one (so closing the issue API doesn't
   *   tear down the org that owns the Room).
   */
  constructor (corestore, options = {}) {
    if (options.room) {
      this._room = options.room
      this._ownsRoom = options.ownsRoom === true
    } else {
      this._room = new Room(corestore, {
        schema: issuesSchema,
        namespace: options.namespace || 'issues',
        bootstrapKey: options.bootstrapKey || null
      })
      this._ownsRoom = options.ownsRoom !== false
    }
    // invite string -> live blind-pairing handle (held so outstanding repo
    // invites keep accepting joiners and are closed with this tracker).
    this._pairings = new Map()
  }

  // ── Lifecycle / properties ──────────────────────────────────

  async ready () {
    await this._room.ready()
  }

  async sync () { await this._room.base.update() }
  async close () {
    for (const pairing of this._pairings.values()) {
      try { await pairing.close() } catch {}
    }
    this._pairings.clear()
    if (this._ownsRoom) await this._room.close()
  }

  get key () { return this._room.key }
  get keyHex () { return this._room.keyHex }
  get discoveryKey () { return this._room.discoveryKey }
  get localKey () { return this._room.localKey }
  get localKeyHex () { return this._room.localKeyHex }
  get writable () { return this._room.writable }
  get view () { return this._room.view }
  get base () { return this._room.base }
  get room () { return this._room }

  // ── Writer management (delegated to Room) ───────────────────

  async addWriter (userId, options = {}) {
    return this._room.addWriter(userId, options)
  }

  async removeWriter (userId) {
    return this._room.removeWriter(userId)
  }

  async listWriters () {
    return this._room.listWriters()
  }

  // ── Repo invites via blind-pairing (P2P, no HTTP) ────────────
  // A repo tracker is its own autobase. createInvite mints a hyper-rooms
  // RoomPairing invite (repo name in meta); a joiner pairs over the swarm
  // and, for contributor+, is addWritered DURING pairing — see the
  // joinRepoPairing() factory below. The old bearer-invite + owner-enroll
  // (enrollFromInvite) path is retired; the write-grant rides the same P2P
  // channel as discovery.

  /**
   * Host side. Returns a shareable invite + a close() for the live pairing
   * member (also closed by this tracker's close()).
   * @param {object} opts
   * @param {string} [opts.name]   repo name (advisory; travels in meta)
   * @param {string} [opts.role='reader']
   * @param {object} opts.swarm    shared Hyperswarm (injected)
   * @returns {{ invitation: string, close: () => Promise<void> }}
   */
  createInvite (opts = {}) {
    if (!opts.swarm) throw new Error('createInvite requires { swarm } (P2P pairing transport)')
    const pairing = createRoomPairing(this._room, {
      role: opts.role || 'reader',
      swarm: opts.swarm,
      meta: { kind: 'repo', name: opts.name || null }
    })
    this._pairings.set(pairing.invite, pairing)
    // descriptor lets the daemon persist + re-register this invite on restart.
    return { invitation: pairing.invite, descriptor: pairing.descriptor, close: pairing.close }
  }

  /**
   * Re-register a persisted repo-invite pairing member after a restart so an
   * already-shared invite keeps working.
   * @param {object} descriptor  from a prior createInvite()
   * @param {object} swarm       shared Hyperswarm (injected)
   */
  resumePairing (descriptor, swarm) {
    if (!swarm) throw new Error('resumePairing requires { swarm }')
    const pairing = resumeRoomPairing(this._room, descriptor, swarm)
    this._pairings.set(descriptor.invitation || ('repo:' + descriptor.discoveryKey), pairing)
    return pairing
  }

  async getWriter (userId) {
    return this._room.getWriter(userId)
  }

  // ── Issues ──────────────────────────────────────────────────

  /**
   * Create an issue with auto-incrementing ID.
   * @param {object} options
   * @param {string} options.title
   * @param {string} [options.body]
   * @param {string} options.author
   * @param {string[]} [options.labels]
   * @param {string[]} [options.images]
   * @returns {Promise<number>} the issue ID
   */
  async create (options) {
    const { title, body, author, labels, images } = options
    await this._room.base.update()
    const counter = await this._room.view.get('counter:issues')
    const id = counter ? counter.value.next : 1

    await this._room.append({
      type: 'create-issue',
      id,
      title,
      body: body || '',
      userId: author,
      labels: labels || [],
      images: images || [],
      timestamp: Date.now()
    })
    return id
  }

  /**
   * Create an issue with an explicit id — used for migration from v1.
   * Idempotent: apply skips the write if an issue with this id already exists.
   * Accepts status and createdAt/updatedAt to preserve v1 metadata verbatim.
   *
   * @param {number} id
   * @param {object} options
   * @param {string} options.title
   * @param {string} [options.body]
   * @param {string} options.author
   * @param {string[]} [options.labels]
   * @param {string[]} [options.images]
   * @param {'open'|'closed'} [options.status='open']
   * @param {number} [options.createdAt] - ms epoch
   * @param {number} [options.updatedAt] - ms epoch
   * @returns {Promise<number>} id (same as input, for symmetry with create())
   */
  async createWithId (id, options) {
    if (typeof id !== 'number') throw new Error('createWithId requires numeric id')
    const { title, body, author, labels, images, status, createdAt, updatedAt } = options
    await this._room.append({
      type: 'create-issue',
      id,
      title,
      body: body || '',
      userId: author,
      labels: labels || [],
      images: images || [],
      status: status || 'open',
      createdAt,
      updatedAt,
      timestamp: createdAt || Date.now()
    })
    return id
  }

  /**
   * Update an issue (title, body, status, labels, images).
   * @param {number} id
   * @param {object} updates
   * @param {string} userId
   */
  async update (id, updates, userId) {
    await this._room.append({
      type: 'update-issue',
      id,
      ...updates,
      userId,
      timestamp: Date.now()
    })
  }

  /** Get a single issue by ID. */
  async get (id) {
    await this._room.base.update()
    const entry = await this._room.view.get(`issue:${id}`)
    return entry ? entry.value : null
  }

  /**
   * List issues with optional filtering and sorting.
   * @param {object} [filters]
   * @param {'open'|'closed'|'all'} [filters.status='all']
   * @param {string[]} [filters.labels] - must have ALL listed labels
   * @param {string} [filters.search] - search in title and body
   * @param {string} [filters.author] - filter by author userId
   * @param {'newest'|'oldest'|'recently-updated'} [filters.sort='newest']
   * @param {number} [filters.limit=0]
   * @param {number} [filters.offset=0]
   */
  async list (filters = {}) {
    const {
      status = 'all',
      labels = [],
      search = '',
      author = '',
      sort = 'newest',
      limit = 0,
      offset = 0
    } = filters

    await this._room.base.update()
    const issues = []

    for await (const entry of this._room.view.createReadStream({
      gt: 'issue:', lt: 'issue:\xff'
    })) {
      const issue = entry.value

      if (status !== 'all' && issue.status !== status) continue

      if (labels.length > 0) {
        const issueLabels = issue.labels || []
        const hasAll = labels.every(l => {
          const normalized = l.toLowerCase().replace(/\s+/g, '-')
          return issueLabels.includes(normalized)
        })
        if (!hasAll) continue
      }

      if (search) {
        const s = search.toLowerCase()
        const titleMatch = issue.title && issue.title.toLowerCase().includes(s)
        const bodyMatch = issue.body && issue.body.toLowerCase().includes(s)
        if (!titleMatch && !bodyMatch) continue
      }

      if (author && issue.author !== author) continue
      issues.push(issue)
    }

    switch (sort) {
      case 'oldest':
        issues.sort((a, b) => a.id - b.id)
        break
      case 'recently-updated':
        issues.sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt))
        break
      default:
        issues.sort((a, b) => b.id - a.id)
        break
    }

    const total = issues.length
    const paginated = limit > 0 ? issues.slice(offset, offset + limit) : issues

    if (limit > 0) {
      return { items: paginated, total, hasMore: offset + limit < total }
    }
    return issues
  }

  /** Issue counts by status. */
  async counts () {
    await this._room.base.update()
    let open = 0
    let closed = 0
    for await (const entry of this._room.view.createReadStream({
      gt: 'issue:', lt: 'issue:\xff'
    })) {
      if (entry.value.status === 'closed') closed++
      else open++
    }
    return { open, closed, total: open + closed }
  }

  // ── Comments ────────────────────────────────────────────────

  async comment (issueId, options) {
    const { body, author } = options
    const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

    await this._room.append({
      type: 'create-comment',
      issueId,
      commentId,
      body,
      userId: author,
      timestamp: Date.now()
    })
    return commentId
  }

  /**
   * Create a comment with explicit commentId — migration use.
   * Idempotent: apply skips if (issueId, commentId) exists.
   * @param {number} issueId
   * @param {string} commentId - preserves v1's commentId format
   * @param {object} options
   * @param {string} options.body
   * @param {string} options.author
   * @param {number} [options.createdAt] - ms epoch
   * @param {boolean} [options.skipUpdate=true] - don't bump issue.updatedAt (migration runs many comments in bulk)
   */
  async commentWithId (issueId, commentId, options) {
    if (typeof issueId !== 'number') throw new Error('commentWithId requires numeric issueId')
    if (!commentId) throw new Error('commentWithId requires commentId')
    const { body, author, createdAt, skipUpdate = true } = options
    await this._room.append({
      type: 'create-comment',
      issueId,
      commentId,
      body,
      userId: author,
      createdAt,
      timestamp: createdAt || Date.now(),
      skipUpdate
    })
    return commentId
  }

  async getComments (issueId) {
    await this._room.base.update()
    const comments = []
    for await (const entry of this._room.view.createReadStream({
      gt: `comment:${issueId}:`, lt: `comment:${issueId}:\xff`
    })) {
      comments.push({
        commentId: entry.key.split(':').slice(2).join(':'),
        ...entry.value
      })
    }
    return comments
  }

  async editComment (issueId, commentId, body, userId) {
    await this._room.append({
      type: 'update-comment',
      issueId,
      commentId,
      body,
      userId,
      timestamp: Date.now()
    })
  }

  async deleteComment (issueId, commentId, userId) {
    await this._room.append({
      type: 'delete-comment',
      issueId,
      commentId,
      userId,
      timestamp: Date.now()
    })
  }

  // ── Labels ──────────────────────────────────────────────────

  async createLabel (name, color, description, displayName) {
    await this._room.append({
      type: 'create-label',
      name,
      color: color || '#ccc',
      description: description || '',
      displayName: displayName || name
    })
  }

  async updateLabel (name, updates) {
    await this._room.append({ type: 'update-label', name, ...updates })
  }

  async deleteLabel (name) {
    await this._room.append({ type: 'delete-label', name })
  }

  async getLabel (name) {
    await this._room.base.update()
    const normalized = name.toLowerCase().replace(/\s+/g, '-')
    const entry = await this._room.view.get(`label:${normalized}`)
    return entry ? { name: normalized, ...entry.value } : null
  }

  async listLabels () {
    await this._room.base.update()
    const labels = []
    for await (const entry of this._room.view.createReadStream({
      gt: 'label:', lt: 'label:\xff'
    })) {
      labels.push({ name: entry.key.slice('label:'.length), ...entry.value })
    }
    return labels.sort((a, b) => a.name.localeCompare(b.name))
  }

  async initializeDefaultLabels () {
    const existing = await this.listLabels()
    if (existing.length > 0) return existing
    for (const label of DEFAULT_LABELS) {
      await this.createLabel(label.name, label.color, label.description, label.name)
    }
    return this.listLabels()
  }

  async addLabels (id, labelNames, userId) {
    const issue = await this.get(id)
    if (!issue) throw new Error('Issue not found')

    const currentLabels = issue.labels || []
    const newLabels = [...currentLabels]
    for (const labelName of labelNames) {
      const normalized = labelName.toLowerCase().replace(/\s+/g, '-')
      const label = await this.getLabel(normalized)
      if (!label) throw new Error(`Label "${labelName}" not found`)
      if (!newLabels.includes(normalized)) newLabels.push(normalized)
    }
    await this.update(id, { labels: newLabels }, userId)
    return this.get(id)
  }

  async removeLabels (id, labelNames, userId) {
    const issue = await this.get(id)
    if (!issue) throw new Error('Issue not found')
    const toRemove = new Set(labelNames.map(l => l.toLowerCase().replace(/\s+/g, '-')))
    const newLabels = (issue.labels || []).filter(l => !toRemove.has(l))
    await this.update(id, { labels: newLabels }, userId)
    return this.get(id)
  }

  async setLabels (id, labelNames, userId) {
    const normalized = []
    for (const labelName of labelNames) {
      const n = labelName.toLowerCase().replace(/\s+/g, '-')
      const label = await this.getLabel(n)
      if (!label) throw new Error(`Label "${labelName}" not found`)
      normalized.push(n)
    }
    await this.update(id, { labels: normalized }, userId)
    return this.get(id)
  }

  // ── Refs (optional, for git integration) ────────────────────

  // oldOid is the value the writer observed for `ref` (git's <oldrev>):
  // omit / null / zero-oid to create a ref that must not already exist.
  // The apply rejects the op if the view no longer matches oldOid (non-FF).
  async updateRef (ref, hash, userId, oldOid = null) {
    await this._room.append({ type: 'update-ref', ref, hash, userId, oldOid, timestamp: Date.now() })
  }

  async deleteRef (ref, userId, oldOid = null) {
    await this._room.append({ type: 'delete-ref', ref, userId, oldOid, timestamp: Date.now() })
  }

  async getRef (ref) {
    await this._room.base.update()
    const entry = await this._room.view.get(`ref:${ref}`)
    return entry ? entry.value : null
  }

  async listRefs () {
    await this._room.base.update()
    const refs = []
    for await (const entry of this._room.view.createReadStream({
      gt: 'ref:', lt: 'ref:\xff'
    })) {
      refs.push({ ref: entry.key.slice('ref:'.length), ...entry.value })
    }
    return refs
  }

  // ── Bundle chain (gip-overlay object transport) ─────────────
  //
  // Each push records the overlay content hash of its delta bundle, keyed by
  // the tip oid it produces, linked to the baseOid it builds on. A fetcher
  // walks oid -> baseOid back to genesis (ZERO_OID) to resolve which bundles
  // to pull. Idempotent: the first writer to record a given oid wins.

  async addBundle (info, userId) {
    if (!info || !info.oid) throw new Error('addBundle requires { oid }')
    if (!info.contentHash) throw new Error('addBundle requires { contentHash }')
    await this._room.append({
      type: 'add-bundle',
      oid: info.oid,
      baseOid: info.baseOid || ZERO_OID,
      contentHash: info.contentHash,
      size: info.size || 0,
      ref: info.ref || null,
      userId,
      timestamp: Date.now()
    })
  }

  async getBundle (oid) {
    await this._room.base.update()
    const entry = await this._room.view.get(`bundle:${oid}`)
    return entry ? entry.value : null
  }

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

  async setMeta (key, value) {
    await this._room.append({ type: 'set-meta', key, value })
  }

  async getMeta (key) {
    await this._room.base.update()
    const entry = await this._room.view.get(`meta:${key}`)
    return entry ? entry.value.value : null
  }
}

/**
 * Candidate side: join a repo tracker via a blind-pairing invite (the P2P
 * replacement for `new HyperIssues(store, { bootstrapKey })` + owner-enroll).
 * Mounts the repo Room over the swarm, presents its localKeyHex, and for
 * contributor+ becomes a writer DURING pairing. Returns a HyperIssues over
 * the mounted Room.
 * @param {string} invitation
 * @param {object} opts
 * @param {Corestore} opts.corestore
 * @param {object} opts.swarm
 * @param {string} [opts.namespace]
 * @returns {Promise<{ tracker: HyperIssues, role: string, name: string|null }>}
 */
export async function joinRepoPairing (invitation, opts = {}) {
  const { corestore, swarm } = opts
  if (!corestore) throw new Error('joinRepoPairing requires { corestore }')
  if (!swarm) throw new Error('joinRepoPairing requires { swarm }')
  const dec = decodeRoomPairingInvite(invitation)
  if (!dec.meta || dec.meta.kind !== 'repo') throw new Error('invite is not a repo pairing invite')
  const { room, role, meta } = await joinRoomPairing(invitation, {
    corestore,
    swarm,
    schema: issuesSchema,
    namespace: opts.namespace || ('repo-join/' + dec.bootstrapKey.slice(0, 16))
  })
  const tracker = new HyperIssues(corestore, { room, ownsRoom: true })
  return { tracker, role, name: meta && meta.name }
}