import Hyperblobs from 'hyperblobs'
import crypto from 'hypercore-crypto'
/**
* Issue Manager
*
* Manages issues with image attachments stored in Hyperblobs.
* Follows wirecore image upload patterns:
* - Hash data URLs (not raw bytes)
* - Store images separately in Hyperblobs
* - Add markdown references to descriptions
*/
export class IssueManager {
constructor(options = {}) {
this.registry = options.registry
this.corestore = options.corestore
// Create Hyperblobs for issue images
// Get a dedicated core for the blobs
const blobCore = this.corestore.get({ name: 'issue-images' })
this.blobs = new Hyperblobs(blobCore)
}
async ready() {
await this.blobs.ready()
}
/**
* Create new issue with optional images and labels
* @param {string} repoName - Repository name
* @param {Object} issueData - Issue data (title, description, images, labels, createdBy)
* @returns {Promise<Object>} Created issue
*/
async createIssue(repoName, issueData) {
const { title, description, images = [], labels = [], createdBy } = issueData
// Get next issue number
let maxNumber = 0
for await (const entry of this.registry.createReadStream({
gt: `issue:${repoName}:`,
lt: `issue:${repoName}:\xff`
})) {
if (entry.value.number > maxNumber) {
maxNumber = entry.value.number
}
}
const issueNumber = maxNumber + 1
// Process images
const imageHashes = []
let finalDescription = description
for (const imageData of images) {
// CRITICAL: Hash the data URL, not raw bytes (wirecore lesson)
const hash = await this.storeImage(imageData)
imageHashes.push(hash)
// Add markdown reference to description
finalDescription += `\n`
}
// Validate and normalize labels
const validLabels = []
for (const labelName of labels) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (label) {
validLabels.push(normalizedName)
}
}
const issue = {
number: issueNumber,
title,
description: finalDescription,
images: imageHashes,
labels: validLabels,
status: 'open',
createdBy: createdBy || null, // userId of creator
createdAt: Date.now(),
updatedAt: Date.now(),
comments: []
}
// Store in registry
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Store image in Hyperblobs
* @param {string} dataUrl - Full data URL (data:image/jpeg;base64,...)
* @returns {Promise<string>} Hash of the image
*/
async storeImage(dataUrl) {
// CRITICAL: Hash the data URL string, not raw bytes
// This matches wirecore/wiredove pattern
const hashBuffer = crypto.hash(Buffer.from(dataUrl))
const hash = hashBuffer.toString('hex')
// Store in Hyperblobs
const blobId = await this.blobs.put(Buffer.from(dataUrl))
// Store hash -> blobId mapping in registry
// blobId is an object with { blockOffset, blockLength, byteOffset, byteLength }
await this.registry.put(`image:${hash}`, {
hash,
blobId: JSON.stringify(blobId),
createdAt: Date.now()
})
console.log(`[IssueManager] Stored image: hash=${hash}, blobId=${JSON.stringify(blobId)}`)
return hash
}
/**
* Get image from Hyperblobs
* @param {string} hash - Image hash
* @returns {Promise<string|null>} Data URL or null
*/
async getImage(hash) {
try {
// Get blobId from registry mapping
const mapping = await this.registry.get(`image:${hash}`)
if (!mapping) {
console.error(`[IssueManager] No mapping found for image hash: ${hash}`)
return null
}
const blobId = JSON.parse(mapping.value.blobId)
// Get blob from Hyperblobs using the blobId object
const blob = await this.blobs.get(blobId)
if (!blob) {
console.error(`[IssueManager] Blob not found: ${JSON.stringify(blobId)}`)
return null
}
return blob.toString('utf-8')
} catch (err) {
console.error('[IssueManager] Failed to get image:', err.message, err.stack)
return null
}
}
/**
* Get issue with images loaded
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @returns {Promise<Object|null>} Issue with images
*/
async getIssue(repoName, issueNumber) {
try {
const entry = await this.registry.get(`issue:${repoName}:${issueNumber}`)
if (!entry) return null
const issue = entry.value || entry // Handle both old and new format
// Load images
const images = {}
for (const hash of issue.images || []) {
const dataUrl = await this.getImage(hash)
if (dataUrl) {
images[hash] = dataUrl
}
}
return {
...issue,
imageData: images
}
} catch (err) {
console.error('[IssueManager] Failed to get issue:', err.message)
return null
}
}
/**
* List issues for repository with optional filtering
* @param {string} repoName - Repository name
* @param {Object} filters - Optional filters
* @param {string} filters.status - Filter by status ('open', 'closed', 'all')
* @param {Array<string>} filters.labels - Filter by labels (issues must have ALL listed labels)
* @param {string} filters.search - Search in title and description
* @param {string} filters.author - Filter by creator userId
* @param {string} filters.sort - Sort by ('newest', 'oldest', 'recently-updated')
* @returns {Promise<Array>} List of issues
*/
async listIssues(repoName, filters = {}) {
const {
status = 'all',
labels = [],
search = '',
author = '',
sort = 'newest'
} = filters
const issues = []
for await (const entry of this.registry.createReadStream({
gt: `issue:${repoName}:`,
lt: `issue:${repoName}:\xff`
})) {
const issue = entry.value
// Filter by status
if (status !== 'all' && issue.status !== status) {
continue
}
// Filter by labels (must have ALL specified labels)
if (labels.length > 0) {
const issueLabels = issue.labels || []
const hasAllLabels = labels.every(label => {
const normalized = label.toLowerCase().replace(/\s+/g, '-')
return issueLabels.includes(normalized)
})
if (!hasAllLabels) {
continue
}
}
// Filter by search term (title or description)
if (search) {
const searchLower = search.toLowerCase()
const titleMatch = issue.title && issue.title.toLowerCase().includes(searchLower)
const descMatch = issue.description && issue.description.toLowerCase().includes(searchLower)
if (!titleMatch && !descMatch) {
continue
}
}
// Filter by author
if (author && issue.createdBy !== author) {
continue
}
issues.push(issue)
}
// Sort results
switch (sort) {
case 'oldest':
issues.sort((a, b) => a.number - b.number)
break
case 'recently-updated':
issues.sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt))
break
case 'newest':
default:
issues.sort((a, b) => b.number - a.number)
break
}
return issues
}
/**
* Get issue counts by status
* @param {string} repoName - Repository name
* @returns {Promise<Object>} Counts { open, closed, total }
*/
async getIssueCounts(repoName) {
let open = 0
let closed = 0
for await (const entry of this.registry.createReadStream({
gt: `issue:${repoName}:`,
lt: `issue:${repoName}:\xff`
})) {
if (entry.value.status === 'closed') {
closed++
} else {
open++
}
}
return { open, closed, total: open + closed }
}
/**
* Add comment to issue
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @param {Object} commentData - Comment data
* @returns {Promise<Object>} Updated issue
*/
async addComment(repoName, issueNumber, commentData) {
const issue = await this.getIssue(repoName, issueNumber)
if (!issue) throw new Error('Issue not found')
const { text, images = [] } = commentData
// Process comment images
const imageHashes = []
let finalText = text
for (const imageData of images) {
const hash = await this.storeImage(imageData)
imageHashes.push(hash)
finalText += `\n`
}
const comment = {
id: issue.comments.length + 1,
text: finalText,
images: imageHashes,
createdAt: Date.now()
}
issue.comments.push(comment)
issue.updatedAt = Date.now()
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Update issue status
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @param {string} status - New status (open/closed)
* @returns {Promise<Object>} Updated issue
*/
async updateStatus(repoName, issueNumber, status) {
const issue = await this.getIssue(repoName, issueNumber)
if (!issue) throw new Error('Issue not found')
issue.status = status
issue.updatedAt = Date.now()
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Parse markdown with image references
* @param {string} markdown - Markdown text
* @param {Object} images - Hash -> data URL map
* @returns {string} HTML
*/
parseMarkdown(markdown, images = {}) {
if (!markdown) return ''
// Escape HTML
let html = markdown
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
// Code blocks first (with language support): ```lang\ncode```
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
return `<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`
})
// Inline code: `code`
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headers (must be at start of line)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold and italic (order matters - bold+italic first)
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
// Links: [text](url) - but not images
html = html.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Images:  -> <img>
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, hash) => {
const dataUrl = images[hash]
if (dataUrl) {
return `<img src="${dataUrl}" alt="${alt || 'Image'}" class="issue-image" />`
}
return match // Keep original if image not found
})
// Unordered lists
html = html.replace(/^[\*\-] (.+)$/gm, '<li>$1</li>')
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
// Blockquotes
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
html = html.replace(/^---$/gm, '<hr>')
html = html.replace(/^\*\*\*$/gm, '<hr>')
// Paragraphs (double newline = new paragraph)
html = html.replace(/\n\n/g, '</p><p>')
html = '<p>' + html + '</p>'
// Single line breaks
html = html.replace(/\n/g, '<br>')
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '')
html = html.replace(/<p>\s*<\/p>/g, '')
return html
}
/**
* Close all connections
*/
async close() {
await this.blobs.close()
}
// ========================================
// Label Management
// ========================================
/**
* Default labels created for new repositories
*/
static DEFAULT_LABELS = [
{ 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: 'question', color: '#d876e3', description: 'Further information is requested' },
{ name: 'help-wanted', color: '#008672', description: 'Extra attention is needed' },
{ name: 'good-first-issue', color: '#7057ff', description: 'Good for newcomers' },
{ name: 'duplicate', color: '#cfd3d7', description: 'This issue already exists' },
{ name: 'wontfix', color: '#ffffff', description: 'This will not be worked on' }
]
/**
* Initialize default labels for a repository
* @param {string} repoName - Repository name
* @returns {Promise<Array>} Created labels
*/
async initializeLabels(repoName) {
const existingLabels = await this.getLabels(repoName)
if (existingLabels.length > 0) {
return existingLabels // Already initialized
}
const labels = []
for (const labelData of IssueManager.DEFAULT_LABELS) {
const label = await this.createLabel(repoName, labelData)
labels.push(label)
}
console.log(`[IssueManager] Initialized ${labels.length} default labels for ${repoName}`)
return labels
}
/**
* Create a label for a repository
* @param {string} repoName - Repository name
* @param {Object} labelData - Label data { name, color, description }
* @returns {Promise<Object>} Created label
*/
async createLabel(repoName, labelData) {
const { name, color, description = '' } = labelData
// Normalize label name (lowercase, no spaces)
const normalizedName = name.toLowerCase().replace(/\s+/g, '-')
// Check if label already exists
const existing = await this.getLabel(repoName, normalizedName)
if (existing) {
throw new Error(`Label "${normalizedName}" already exists`)
}
const label = {
name: normalizedName,
displayName: name,
color: color.startsWith('#') ? color : `#${color}`,
description,
createdAt: Date.now()
}
await this.registry.put(`label:${repoName}:${normalizedName}`, label)
return label
}
/**
* Get a specific label
* @param {string} repoName - Repository name
* @param {string} labelName - Label name
* @returns {Promise<Object|null>} Label or null
*/
async getLabel(repoName, labelName) {
try {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const entry = await this.registry.get(`label:${repoName}:${normalizedName}`)
return entry ? (entry.value || entry) : null
} catch (err) {
return null
}
}
/**
* Get all labels for a repository
* @param {string} repoName - Repository name
* @returns {Promise<Array>} List of labels
*/
async getLabels(repoName) {
const labels = []
for await (const entry of this.registry.createReadStream({
gt: `label:${repoName}:`,
lt: `label:${repoName}:\xff`
})) {
labels.push(entry.value)
}
// Sort alphabetically by name
return labels.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Update a label
* @param {string} repoName - Repository name
* @param {string} labelName - Label name to update
* @param {Object} updates - Fields to update { color, description, displayName }
* @returns {Promise<Object>} Updated label
*/
async updateLabel(repoName, labelName, updates) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (!label) {
throw new Error(`Label "${labelName}" not found`)
}
// Apply updates
if (updates.color) {
label.color = updates.color.startsWith('#') ? updates.color : `#${updates.color}`
}
if (updates.description !== undefined) {
label.description = updates.description
}
if (updates.displayName) {
label.displayName = updates.displayName
}
label.updatedAt = Date.now()
await this.registry.put(`label:${repoName}:${normalizedName}`, label)
return label
}
/**
* Delete a label
* @param {string} repoName - Repository name
* @param {string} labelName - Label name to delete
* @returns {Promise<boolean>} Success
*/
async deleteLabel(repoName, labelName) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (!label) {
throw new Error(`Label "${labelName}" not found`)
}
await this.registry.del(`label:${repoName}:${normalizedName}`)
// Also remove this label from all issues
const issues = await this.listIssues(repoName)
for (const issue of issues) {
if (issue.labels && issue.labels.includes(normalizedName)) {
issue.labels = issue.labels.filter(l => l !== normalizedName)
await this.registry.put(`issue:${repoName}:${issue.number}`, issue)
}
}
return true
}
/**
* Add labels to an issue
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @param {Array<string>} labelNames - Label names to add
* @returns {Promise<Object>} Updated issue
*/
async addLabelsToIssue(repoName, issueNumber, labelNames) {
const issue = await this.getIssue(repoName, issueNumber)
if (!issue) throw new Error('Issue not found')
// Initialize labels array if not present
if (!issue.labels) {
issue.labels = []
}
// Validate and add labels
for (const labelName of labelNames) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (!label) {
throw new Error(`Label "${labelName}" not found`)
}
if (!issue.labels.includes(normalizedName)) {
issue.labels.push(normalizedName)
}
}
issue.updatedAt = Date.now()
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Remove labels from an issue
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @param {Array<string>} labelNames - Label names to remove
* @returns {Promise<Object>} Updated issue
*/
async removeLabelsFromIssue(repoName, issueNumber, labelNames) {
const issue = await this.getIssue(repoName, issueNumber)
if (!issue) throw new Error('Issue not found')
if (!issue.labels) {
return issue // No labels to remove
}
for (const labelName of labelNames) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
issue.labels = issue.labels.filter(l => l !== normalizedName)
}
issue.updatedAt = Date.now()
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Set all labels for an issue (replaces existing)
* @param {string} repoName - Repository name
* @param {number} issueNumber - Issue number
* @param {Array<string>} labelNames - Label names to set
* @returns {Promise<Object>} Updated issue
*/
async setIssueLabels(repoName, issueNumber, labelNames) {
const issue = await this.getIssue(repoName, issueNumber)
if (!issue) throw new Error('Issue not found')
// Validate all labels exist
const normalizedNames = []
for (const labelName of labelNames) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (!label) {
throw new Error(`Label "${labelName}" not found`)
}
normalizedNames.push(normalizedName)
}
issue.labels = normalizedNames
issue.updatedAt = Date.now()
await this.registry.put(`issue:${repoName}:${issueNumber}`, issue)
return issue
}
/**
* Get label color for display
* @param {string} color - Hex color
* @returns {Object} CSS styles { background, foreground }
*/
static getLabelStyles(color) {
// Handle invalid input
if (!color) {
return { background: '#ccc', foreground: '#000' }
}
// Calculate brightness to determine text color
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return {
background: color,
foreground: brightness > 125 ? '#000000' : '#ffffff'
}
}
/**
* Migrate user data from one user to another (for account merging)
* Updates createdBy on issues and author on comments
* @param {string} fromUserId - Source user ID
* @param {string} toUserId - Target user ID
* @returns {Promise<Object>} Migration stats { issuesMigrated, commentsMigrated }
*/
async migrateUserData(fromUserId, toUserId) {
let issuesMigrated = 0
let commentsMigrated = 0
// Find all issues created by the source user
for await (const entry of this.registry.createReadStream({
gte: 'issue:',
lt: 'issue:\xff'
})) {
const issue = entry.value
let updated = false
// Update issue creator
if (issue.createdBy === fromUserId) {
issue.createdBy = toUserId
updated = true
issuesMigrated++
}
// Update comment authors
if (issue.comments && Array.isArray(issue.comments)) {
for (const comment of issue.comments) {
if (comment.author === fromUserId) {
comment.author = toUserId
commentsMigrated++
updated = true
}
}
}
// Save updated issue
if (updated) {
await this.registry.put(entry.key, issue)
}
}
console.log(`[IssueManager] Migrated ${issuesMigrated} issues and ${commentsMigrated} comments from ${fromUserId} to ${toUserId}`)
return { issuesMigrated, commentsMigrated }
}
}