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