OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / issue-manager.js

750 lines · javascript ref:
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![](${hash})`
    }

    // 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![](${hash})`
    }

    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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;')

    // 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: ![alt](hash) -> <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 }
  }
}