OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / issues.js

861 lines · javascript ref:
/**
 * Issue Routes
 *
 * Handles:
 * - POST /:username/:repoName/issues - Create issue
 * - GET /:username/:repoName/issues/:number - View issue
 * - PATCH /:username/:repoName/issues/:number - Update issue (close/reopen)
 * - POST /:username/:repoName/issues/:number/comments - Add comment
 *
 * All issue data is stored in per-repo LinkedRepo Autobases (multiwriter).
 * Field names are normalized to match the CLI expectations.
 */

import { html, json, notFound, unauthorized, forbidden, badRequest, readJson, escapeHtml } from '../response.js'
import { optionalAuth, requireAuth } from '../middleware/require-auth.js'
import { renderIdentityScript } from '../identity-ui.js'
import { renderNav } from '../nav-ui.js'
import { parseMarkdown, getLabelStyles } from '../markdown.js'
import { renderIssuePage } from '../issue-page-ui.js'

/**
 * Check if auth has a specific permission.
 * Handles both bearer tokens (permissions object) and user-based auth.
 * Returns { allowed, user } where user may be null for bearer tokens.
 */
async function checkPermission (auth, server, repoName, ...requiredPerms) {
  if (!auth) return { allowed: false, user: null }

  // Bearer tokens carry permissions directly
  if (auth.method === 'bearer-token') {
    const perms = auth.permissions || {}
    const allowed = requiredPerms.some(p => perms[p] || perms.admin)
    return { allowed, user: null }
  }

  // Other auth methods — look up user
  const { authManager } = server.auth
  const userResult = await authManager.getUserWithIdentities(auth.identityId)
  if (!userResult) return { allowed: false, user: null }

  const { user } = userResult
  const allowed = requiredPerms.some(p => user.globalPermissions.includes(p)) ||
    user.globalPermissions.includes('admin') ||
    user.repos.some(r =>
      (r.name === repoName || r.name === '*') &&
      requiredPerms.some(p => r.permissions.includes(p) || r.permissions.includes('admin'))
    )

  return { allowed, user }
}

/**
 * Get the LinkedRepo for a given repo name.
 * Every repo should have one (created on startup).
 */
function getLinkedRepo (server, repoName) {
  return server.repoData.linkedRepos.get(repoName)
}

/**
 * Normalize a LinkedRepo issue to the field names the CLI and HTML expect.
 * LinkedRepo uses: id, body, author
 * CLI/HTML expect: number, description, createdBy
 */
function normalizeIssue (issue, comments) {
  if (!issue) return null
  return {
    number: issue.id,
    title: issue.title,
    description: issue.body,
    status: issue.status,
    labels: issue.labels || [],
    images: issue.images || [],
    createdBy: issue.author,
    createdAt: issue.createdAt,
    updatedAt: issue.updatedAt,
    imageData: issue.imageData || {},
    comments: comments
      ? comments.map((c, i) => ({
          id: i + 1,
          commentId: c.commentId,
          text: c.body,
          author: c.author,
          createdAt: c.createdAt,
          imageData: {}
        }))
      : []
  }
}

/**
 * Register issue routes
 */
export function registerIssueRoutes(router) {
  // Create issue
  router.post('/:username/:repoName/issues', handleCreateIssue)

  // Add comment (must come before view issue due to more specific pattern)
  router.post('/:username/:repoName/issues/:issueNumber/comments', handleAddComment)

  // Issue labels
  router.post('/:username/:repoName/issues/:issueNumber/labels', handleAddIssueLabels)
  router.delete('/:username/:repoName/issues/:issueNumber/labels/:labelName', handleRemoveIssueLabel)

  // View/update issue
  router.get('/:username/:repoName/issues/:issueNumber', handleViewIssue)
  router.patch('/:username/:repoName/issues/:issueNumber', handleUpdateIssue)

  // List issues (JSON API + HTML)
  router.get('/:username/:repoName/issues', handleListIssues)

  // Repository labels (CRUD)
  router.get('/:username/:repoName/labels', handleListLabels)
  router.post('/:username/:repoName/labels', handleCreateLabel)
  router.patch('/:username/:repoName/labels/:labelName', handleUpdateLabel)
  router.delete('/:username/:repoName/labels/:labelName', handleDeleteLabel)
}

/**
 * Create issue
 * Requires authentication and create-issue permission
 */
async function handleCreateIssue(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repo not found')
    return
  }

  // Check authentication and permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)

    if (!auth) {
      console.log(`[Issue] Denied create: Not authenticated for ${repoName}`)
      json(res, {
        error: 'Authentication required to create issues',
        hint: 'Make sure X-Wirecore-PublicKey header is set'
      }, 401)
      return
    }

    // Bearer tokens carry permissions directly
    if (auth.method === 'bearer-token') {
      const perms = auth.permissions || {}
      const hasPermission = perms.admin || perms.write || perms['create-issue']
      if (!hasPermission) {
        forbidden(res, 'create-issue permission required')
        return
      }
      console.log(`[Issue] Authorized create: bearer-token (username: ${auth.username})`)
      req.auth = auth
    } else {
      // Check permission via user record
      const userResult = await authManager.getUserWithIdentities(auth.identityId)
      if (!userResult) {
        forbidden(res, 'User not found')
        return
      }

      const { user } = userResult
      const hasPermission = user.globalPermissions.includes('admin') ||
                           user.globalPermissions.includes('write') ||
                           user.globalPermissions.includes('create-issue') ||
                           user.repos.some(r =>
                             (r.name === repoName || r.name === '*') &&
                             (r.permissions.includes('admin') ||
                              r.permissions.includes('write') ||
                              r.permissions.includes('create-issue'))
                           )

      if (!hasPermission) {
        console.log(`[Issue] Denied create: User ${user.userId} lacks create-issue permission for ${repoName}`)
        forbidden(res, 'create-issue permission required. Contact a repo owner or admin to request access.')
        return
      }

      console.log(`[Issue] Authorized create: ${auth.method} (userId: ${user.userId})`)
      req.auth = auth
    }
  }

  try {
    const { title, description, images } = await readJson(req)

    if (!title || !title.trim()) {
      badRequest(res, 'Title is required')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const userId = (req.auth && (req.auth.userId || req.auth.username)) || 'anonymous'

    // Store images if provided
    const imageHashes = []
    let finalDescription = description || ''
    if (images && images.length > 0) {
      for (const imageData of images) {
        const hash = await linked.storeImage(imageData)
        imageHashes.push(hash)
        finalDescription += `\n![](${hash})`
      }
    }

    const issueId = await linked.createIssue(title.trim(), finalDescription, userId, {
      images: imageHashes
    })
    const issue = await linked.getIssue(issueId)

    json(res, { success: true, issue: normalizeIssue(issue) })
  } catch (err) {
    console.error('[Server] Create issue error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Update issue (close/reopen)
 */
async function handleUpdateIssue(req, res, server) {
  const { repos, repoRegistry } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName, issueNumber } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repo not found')
    return
  }

  let auth = null
  if (authEnabled) {
    auth = await authManager.authenticate(req)

    if (!auth) {
      console.log(`[Issue] Denied update: Not authenticated for ${repoName}`)
      json(res, { error: 'Authentication required to update issues' }, 401)
      return
    }
  }

  try {
    const body = await readJson(req)
    const { status, title, description } = body

    // Must provide at least one field to update
    if (status === undefined && title === undefined && description === undefined) {
      badRequest(res, 'Provide at least one field to update: status, title, or description')
      return
    }

    if (status !== undefined && !['open', 'closed'].includes(status)) {
      badRequest(res, 'Invalid status. Must be "open" or "closed"')
      return
    }

    const parsedIssueNumber = parseInt(issueNumber, 10)
    if (isNaN(parsedIssueNumber)) {
      badRequest(res, 'Invalid issue number')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const issue = await linked.getIssue(parsedIssueNumber)
    if (!issue) {
      notFound(res, 'Issue not found')
      return
    }

    // Check authorization
    if (authEnabled && auth) {
      // Bearer tokens carry permissions directly
      if (auth.method === 'bearer-token') {
        const perms = auth.permissions || {}
        const hasManageIssues = perms.admin || perms['manage-issues'] || perms.write
        if (!hasManageIssues) {
          forbidden(res, 'manage-issues permission required')
          return
        }
        console.log(`[Issue] Authorized update: bearer-token (username: ${auth.username})`)
      } else {
        const userResult = await authManager.getUserWithIdentities(auth.identityId)
        if (!userResult) {
          forbidden(res, 'User not found')
          return
        }

        const { user } = userResult

        // Check if user is repo owner
        const repoData = await repoRegistry.get(repoName)
        const isRepoOwner = repoData && repoData.owner === user.userId

        // Check for manage-issues permission
        const hasManageIssues = user.globalPermissions.includes('admin') ||
                               user.globalPermissions.includes('manage-issues') ||
                               isRepoOwner ||
                               user.repos.some(r =>
                                 (r.name === repoName || r.name === '*') &&
                                 (r.permissions.includes('admin') || r.permissions.includes('manage-issues'))
                               )

        if (!hasManageIssues && issue.author !== user.userId) {
          console.log(`[Issue] Denied: User ${user.userId} cannot modify issue created by ${issue.author}`)
          forbidden(res, 'You can only close/reopen issues you created. Contact a repo owner or admin for manage-issues permission.')
          return
        }

        console.log(`[Issue] Authorized update: ${auth.method} (userId: ${user.userId}, canManageAll: ${hasManageIssues}, isRepoOwner: ${isRepoOwner}, isIssueCreator: ${issue.author === user.userId})`)
      }
    }

    let userId = auth ? auth.userId : null
    // Bearer tokens don't carry userId — resolve from token username or admin fallback
    if (!userId && auth && auth.method === 'bearer-token') {
      const allUsers = await authManager.identityRegistry.listAllUsers()
      if (auth.username) {
        const match = allUsers.find(u => u.username === auth.username || u.name === auth.username)
        if (match) userId = match.userId
      }
      // Admin tokens: fall back to first active admin user
      if (!userId && auth.permissions && auth.permissions.admin) {
        const adminUser = allUsers.find(u => !u.deactivatedAt && (u.globalPermissions || []).includes('admin'))
        if (adminUser) userId = adminUser.userId
      }
    }
    const updates = {}
    if (status !== undefined) updates.status = status
    if (title !== undefined) updates.title = title
    if (description !== undefined) updates.body = description
    await linked.updateIssue(parsedIssueNumber, updates, userId)
    const updatedIssue = await linked.getIssue(parsedIssueNumber)
    json(res, { success: true, issue: normalizeIssue(updatedIssue) })
  } catch (err) {
    console.error('[Server] Update issue error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Add comment to issue
 */
async function handleAddComment(req, res, server) {
  const { authEnabled, authManager } = server.auth
  const { username, repoName, issueNumber } = req.params

  try {
    let auth = null
    let userId = null
    let author = 'Anonymous'

    if (authEnabled) {
      auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Issue auth error:', err.message); return null })

      if (auth) {
        const userResult = await authManager.getUserWithIdentities(auth.identityId).catch(err => { console.error('[Auth] getUserWithIdentities error:', err.message); return null })
        if (userResult) {
          userId = userResult.user.userId
          author = userResult.user.username || userResult.user.userId
        }
      }
    }

    const { text, images } = await readJson(req)

    if (!text || !text.trim()) {
      badRequest(res, 'Comment text is required')
      return
    }

    const parsedIssueNum = parseInt(issueNumber, 10)
    if (isNaN(parsedIssueNum)) {
      badRequest(res, 'Invalid issue number')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    // Store comment images if provided
    let finalText = text.trim()
    if (images && images.length > 0) {
      for (const imageData of images) {
        const hash = await linked.storeImage(imageData)
        finalText += `\n![](${hash})`
      }
    }

    const commentId = await linked.createComment(parsedIssueNum, finalText, userId || author)

    // Read back the comments to find the new one and get its position
    const comments = await linked.getComments(parsedIssueNum)
    const newComment = comments.find(c => c.commentId === commentId)
    const commentIndex = comments.indexOf(newComment)

    json(res, {
      success: true,
      comment: {
        id: commentIndex + 1,
        commentId,
        text: newComment ? newComment.body : finalText,
        author: newComment ? newComment.author : (userId || author),
        createdAt: newComment ? newComment.createdAt : Date.now()
      }
    })
  } catch (err) {
    console.error('[Server] Add comment error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * View issue
 */
async function handleViewIssue(req, res, server) {
  const { pathPrefix } = server.config
  const { username, repoName, issueNumber } = req.params

  try {
    const parsedIssueNum = parseInt(issueNumber, 10)
    if (isNaN(parsedIssueNum)) {
      notFound(res, 'Invalid issue number')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      notFound(res, 'Issue storage not initialized')
      return
    }

    const [rawIssue, comments, labels] = await Promise.all([
      linked.getIssueWithImages(parsedIssueNum),
      linked.getComments(parsedIssueNum),
      linked.listLabels()
    ])

    if (!rawIssue) {
      notFound(res, 'Issue not found')
      return
    }

    const issue = normalizeIssue(rawIssue, comments)

    // JSON content negotiation for CLI
    const accept = req.headers.accept || ''
    if (accept.includes('application/json')) {
      json(res, { issue })
      return
    }

    const descriptionHtml = parseMarkdown(issue.description, issue.imageData || {})

    const content = renderIssuePage(repoName, issue, descriptionHtml, pathPrefix, labels, username)
    html(res, content)
  } catch (err) {
    console.error('Error viewing issue:', err)
    html(res, 'Internal Server Error', 500)
  }
}

/**
 * Render issue page HTML
 */

/**
 * List issues for a repository
 * Supports JSON (Accept: application/json) and HTML
 */
async function handleListIssues(req, res, server) {
  const { repos } = server.repoData
  const { pathPrefix } = server.config
  const { username, repoName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  try {
    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const url = new URL(req.url, `http://${req.headers.host}`)
    const filters = {
      status: url.searchParams.get('status') || 'open',
      labels: url.searchParams.get('label') ? [url.searchParams.get('label')] : [],
      author: url.searchParams.get('author') || '',
      search: url.searchParams.get('q') || ''
    }

    const [issues, counts] = await Promise.all([
      linked.listIssues(filters),
      linked.getIssueCounts()
    ])

    const accept = req.headers.accept || ''
    if (accept.includes('application/json')) {
      json(res, {
        issues: issues.map(i => ({
          number: i.id,
          title: i.title,
          status: i.status,
          labels: (i.labels || []).join(', '),
          createdBy: i.author,
          createdAt: i.createdAt,
          commentCount: 0
        })),
        counts
      })
      return
    }

    // Default: redirect to repo page (issues are shown there)
    res.statusCode = 302
    res.setHeader('Location', `${pathPrefix}/${username}/${repoName}`)
    res.end()
  } catch (err) {
    console.error('[Issues] List error:', err)
    json(res, { error: err.message }, 500)
  }
}

// ========================================
// Label API Handlers
// ========================================

/**
 * List all labels for a repository
 */
async function handleListLabels(req, res, server) {
  const { repos } = server.repoData
  const { username, repoName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  try {
    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const labels = await linked.listLabels()
    json(res, { labels })
  } catch (err) {
    console.error('[Labels] List error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Create a new label
 * Requires admin or manage-issues permission
 */
async function handleCreateLabel(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // Check permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)
    if (!auth) {
      unauthorized(res, 'Authentication required')
      return
    }

    const { allowed } = await checkPermission(auth, server, repoName, 'manage-issues', 'admin')
    if (!allowed) {
      forbidden(res, 'manage-issues permission required to create labels')
      return
    }
  }

  try {
    const { name, color, description } = await readJson(req)

    if (!name || !name.trim()) {
      badRequest(res, 'Label name is required')
      return
    }

    if (!color) {
      badRequest(res, 'Label color is required')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const normalizedName = name.trim().toLowerCase().replace(/\s+/g, '-')
    await linked.createLabel(normalizedName, color, description || '', name.trim())
    const label = await linked.getLabel(normalizedName)

    json(res, { success: true, label })
  } catch (err) {
    console.error('[Labels] Create error:', err)
    json(res, { error: err.message }, err.message.includes('already exists') ? 400 : 500)
  }
}

/**
 * Update a label
 * Requires admin or manage-issues permission
 */
async function handleUpdateLabel(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName, labelName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // Check permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)
    if (!auth) {
      unauthorized(res, 'Authentication required')
      return
    }

    const { allowed } = await checkPermission(auth, server, repoName, 'manage-issues', 'admin')
    if (!allowed) {
      forbidden(res, 'manage-issues permission required to update labels')
      return
    }
  }

  try {
    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const updates = await readJson(req)
    await linked.updateLabel(labelName, updates)
    const label = await linked.getLabel(labelName)
    json(res, { success: true, label })
  } catch (err) {
    console.error('[Labels] Update error:', err)
    json(res, { error: err.message }, err.message.includes('not found') ? 404 : 500)
  }
}

/**
 * Delete a label
 * Requires admin or manage-issues permission
 */
async function handleDeleteLabel(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName, labelName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // Check permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)
    if (!auth) {
      unauthorized(res, 'Authentication required')
      return
    }

    const { allowed } = await checkPermission(auth, server, repoName, 'manage-issues', 'admin')
    if (!allowed) {
      forbidden(res, 'manage-issues permission required to delete labels')
      return
    }
  }

  try {
    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    // Remove label from all issues first (cascade)
    const issues = await linked.listIssues()
    for (const issue of issues) {
      if (issue.labels && issue.labels.includes(labelName)) {
        await linked.removeLabelsFromIssue(issue.id, [labelName], 'system')
      }
    }

    await linked.deleteLabel(labelName)
    json(res, { success: true })
  } catch (err) {
    console.error('[Labels] Delete error:', err)
    json(res, { error: err.message }, err.message.includes('not found') ? 404 : 500)
  }
}

/**
 * Add labels to an issue
 * Requires create-issue or manage-issues permission
 */
async function handleAddIssueLabels(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName, issueNumber } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // Check permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)
    if (!auth) {
      unauthorized(res, 'Authentication required')
      return
    }

    const { allowed } = await checkPermission(auth, server, repoName, 'create-issue', 'manage-issues', 'write', 'admin')
    if (!allowed) {
      forbidden(res, 'create-issue permission required to add labels')
      return
    }
  }

  try {
    const { labels } = await readJson(req)

    if (!labels || !Array.isArray(labels)) {
      badRequest(res, 'labels array is required')
      return
    }

    const parsedIssueNumber = parseInt(issueNumber, 10)
    if (isNaN(parsedIssueNumber)) {
      badRequest(res, 'Invalid issue number')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const userId = req.auth ? req.auth.userId : 'system'
    const issue = await linked.addLabelsToIssue(parsedIssueNumber, labels, userId)
    json(res, { success: true, issue: normalizeIssue(issue) })
  } catch (err) {
    console.error('[Labels] Add to issue error:', err)
    json(res, { error: err.message }, err.message.includes('not found') ? 404 : 500)
  }
}

/**
 * Remove a label from an issue
 * Requires create-issue or manage-issues permission
 */
async function handleRemoveIssueLabel(req, res, server) {
  const { repos } = server.repoData
  const { authEnabled, authManager } = server.auth
  const { username, repoName, issueNumber, labelName } = req.params

  const repo = repos.find(r => r.name === repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // Check permissions
  if (authEnabled) {
    const auth = await authManager.authenticate(req)
    if (!auth) {
      unauthorized(res, 'Authentication required')
      return
    }

    const { allowed } = await checkPermission(auth, server, repoName, 'create-issue', 'manage-issues', 'write', 'admin')
    if (!allowed) {
      forbidden(res, 'create-issue permission required to remove labels')
      return
    }
  }

  try {
    const parsedIssueNumber = parseInt(issueNumber, 10)
    if (isNaN(parsedIssueNumber)) {
      badRequest(res, 'Invalid issue number')
      return
    }

    const linked = getLinkedRepo(server, repoName)
    if (!linked) {
      json(res, { error: 'Issue storage not initialized for this repo' }, 500)
      return
    }

    const userId = req.auth ? req.auth.userId : 'system'
    const issue = await linked.removeLabelsFromIssue(parsedIssueNumber, [labelName], userId)
    json(res, { success: true, issue: normalizeIssue(issue) })
  } catch (err) {
    console.error('[Labels] Remove from issue error:', err)
    json(res, { error: err.message }, err.message.includes('not found') ? 404 : 500)
  }
}

export {
  handleCreateIssue,
  handleViewIssue,
  handleUpdateIssue,
  handleAddComment,
  handleListIssues,
  handleListLabels,
  handleCreateLabel,
  handleUpdateLabel,
  handleDeleteLabel,
  handleAddIssueLabels,
  handleRemoveIssueLabel
}