/**
* 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 }
}