/**
* Issues pages: list, show, create, comment, close/reopen, labels.
*
* GET /issues[?labels=bug,ui] → list issues with status + label filter
* GET /issues/:id → issue detail with comments + attachments + label editor
* POST /issues/create → create an issue (images, labels)
* POST /issues/:id/comment → add a comment
* POST /issues/:id/labels → replace labels
* POST /issues/:id/close|reopen → toggle status
*
* Labels are stored by hyper-issues; orchestrator just surfaces them.
*/
import { Buffer } from '#node:buffer'
import { layout, escapeHtml } from '../helpers.js'
import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'
import { renderOrgTabs, ORG_TAB_STYLES } from '../components/org-tabs.js'
import { isAdmin } from '../../auth/check.js'
/**
* Build a userId → display-name map for a set of author ids in one
* pass. We look up the User record by its userId (canonical attribution
* for any issue/comment created after 2026-05-11) and render
* user.name || user.username || short(userId). If the id doesn't resolve
* — legacy entries created when the form had a free-form `author` field
* (literal strings like "session-2026-04-20", "anon", etc.) — the raw
* string is rendered as-is. Renames propagate automatically because the
* lookup happens at render time.
*/
async function buildAuthorMap (svc, ids) {
const reg = svc && svc.identityRegistry
const map = new Map()
if (!reg) return map
const unique = [...new Set(ids.filter(Boolean))]
for (const id of unique) {
// One lookup gives the display name AND a representative identity pubkey.
// The pubkey lets us link the author to /profile/:pubkey (which resolves
// back to this userId). Legacy free-form string authors don't resolve →
// no name, no pubkey, rendered as a plain (unlinked) string.
const w = await reg.getUserWithIdentities(id).catch(() => null)
const u = w && w.user
const name = u ? (u.name || u.username || null) : null
const idents = (w && w.identities) || []
const hit = idents.find(i => /^[0-9a-f]{64}$/i.test(i.identityId))
const pubkey = hit ? hit.identityId : null
if (name || pubkey) map.set(id, { name, pubkey })
}
return map
}
// Returns an HTML fragment (escaped label, optionally wrapped in a profile
// link). Callers must NOT escapeHtml() the result — it is already safe.
function displayAuthor (authorId, map, prefix = '') {
if (!authorId) return 'unknown'
const hit = map && map.get(authorId)
const name = hit && hit.name
// Legacy/unresolved: render the raw value; truncate long hex to stay compact.
const label = name || (/^[0-9a-f]{32,}$/i.test(authorId) ? authorId.slice(0, 12) + '…' : authorId)
const safe = escapeHtml(label)
const pubkey = hit && hit.pubkey
return pubkey ? `<a href="${prefix}/profile/${encodeURIComponent(pubkey)}">${safe}</a>` : safe
}
/**
* Encode a markdown source as a base64-attribute-safe string the
* client-side `ISSUE_MD_SCRIPT` decodes + renders via marked. Base64
* sidesteps HTML-attribute escaping pitfalls (quotes, newlines, etc.)
* and survives unicode cleanly.
*/
function renderMarkdownBody (src, extraStyle = '') {
if (!src) return ''
const b64 = Buffer.from(src, 'utf8').toString('base64')
return `<div class="markdown-body issue-md"${extraStyle ? ' style="' + extraStyle + '"' : ''} data-md-src-b64="${b64}"></div>`
}
/**
* Single bottom-of-page script that finds every .issue-md div and
* renders its base64 source via marked v14. Identical adapter pattern
* to the README / org About cards: token-shape-aware renderer.link/
* image, try/catch fallback to plain-text.
*
* Security note: marked v14 doesn't sanitize HTML in the source.
* Issue + comment bodies come from authenticated users (or 'anon');
* they CAN inject raw HTML / scripts. This is the same risk surface
* as the README card. A DOMPurify pass over the rendered HTML is the
* standard mitigation; deferred as a follow-up.
*/
const ISSUE_MD_SCRIPT = `
<script>
(function(){
function decodeB64Utf8 (b64) {
// Browser atob() returns a binary string; we round-trip through
// escape/unescape (deprecated but universal) to recover UTF-8.
try { return decodeURIComponent(escape(atob(b64))) }
catch (e) { return atob(b64) }
}
function renderOne (el) {
var b64 = el.getAttribute('data-md-src-b64')
if (!b64) return
var src = decodeB64Utf8(b64)
var fallback = src.replace(/[<>&]/g, function (c) { return ({ '<': '<', '>': '>', '&': '&' })[c] })
if (typeof marked !== 'undefined') {
try {
var renderer = new marked.Renderer()
var origLink = renderer.link.bind(renderer)
var origImage = renderer.image.bind(renderer)
// marked v14 passes a token object — shallow-clone so we don't
// mutate marked's input. We don't rewrite href in the issue
// context (no implied blob base); links pass through as-is.
renderer.link = function (tokOrHref, title, text) {
if (tokOrHref && typeof tokOrHref === 'object' && 'href' in tokOrHref) return origLink(tokOrHref)
return origLink(tokOrHref, title, text)
}
renderer.image = function (tokOrHref, title, text) {
if (tokOrHref && typeof tokOrHref === 'object' && 'href' in tokOrHref) return origImage(tokOrHref)
return origImage(tokOrHref, title, text)
}
el.innerHTML = marked.parse(src, { gfm: true, breaks: true, renderer: renderer })
return
} catch (err) {
try { console.error('[issue-md] marked.parse failed; falling back to plain text:', err && err.message ? err.message : err) } catch (_) {}
}
}
el.innerHTML = '<pre style="white-space:pre-wrap">' + fallback + '</pre>'
}
function renderAll () {
var els = document.querySelectorAll('.issue-md')
for (var i = 0; i < els.length; i++) renderOne(els[i])
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', renderAll)
else renderAll()
})()
</script>`
const LABEL_STYLES = `
<style>
.label-chip { display: inline-block; padding: 0.1rem 0.5rem; margin: 0.1rem 0.15rem 0.1rem 0; border-radius: 12px; font-size: 0.7rem; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; font-weight: 500; }
.label-chip a { color: inherit; text-decoration: none; }
.label-chip:hover { background: #1f6feb33; }
.label-filter-bar { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; margin: 0.3rem 0 0.6rem; }
</style>`
function renderImages (images, prefix) {
if (!images || images.length === 0) return ''
return '<div style="margin-top:0.6rem">' +
images.map(hash =>
`<a href="${prefix}/files/raw?path=${encodeURIComponent('/attachments/' + hash)}" target="_blank">` +
`<img src="${prefix}/files/raw?path=${encodeURIComponent('/attachments/' + hash)}" ` +
`style="max-width:300px;max-height:200px;border-radius:4px;margin:0.3rem 0.3rem 0 0" loading="lazy">` +
'</a>'
).join('') + '</div>'
}
/**
* Chips for an issue's labels. Each chip is a link back to the list
* filtered by that label so clicking a chip drills into "all issues
* tagged X".
*/
function renderLabelChips (labels, baseUrl) {
if (!labels || labels.length === 0) return ''
return '<span>' + labels.map(l =>
`<a class="label-chip" href="${baseUrl}?labels=${encodeURIComponent(l)}">${escapeHtml(l)}</a>`
).join('') + '</span>'
}
/**
* Scope is required — either { repo: '<name>' } or { org: '<name>' }.
* P.2 removed the daemon-wide issue tracker; every view is scoped.
*/
export async function renderIssuesIndex (svc, status = 'open', ctx, scope, labelFilter) {
const prefix = (ctx && ctx.prefix) || ''
if (!scope || (!scope.repo && !scope.org)) {
throw new Error('renderIssuesIndex requires a scope with { repo } or { org }')
}
const issues = scope.repo
? await svc.issueRegistry.getForRepo(scope.repo)
: await svc.issueRegistry.getForOrg(scope.org)
const filters = status === 'all' ? {} : { status }
if (labelFilter && labelFilter.length > 0) filters.labels = labelFilter
filters.sort = 'newest'
const list = await issues.list(filters)
const counts = await issues.counts()
const authorMap = await buildAuthorMap(svc, list.map(i => i.author))
const scopePath = prefix + (scope.repo
? '/repos/' + encodeURIComponent(scope.repo)
: '/orgs/' + encodeURIComponent(scope.org))
const scopeName = scope.repo || scope.org
const scopeKind = scope.repo ? 'repo' : 'org'
const baseUrl = scopePath + '/issues'
const repoLabel = ' for ' + escapeHtml(scopeName)
const filterLink = (s, label, n) => {
const params = new URLSearchParams()
params.set('status', s)
if (labelFilter && labelFilter.length > 0) params.set('labels', labelFilter.join(','))
return `<a href="${baseUrl}?${params.toString()}" class="${s === status ? 'active' : ''}" style="margin-right:0.6rem">${label} (${n})</a>`
}
// Render the shared tab chrome — repo tabs for repo-scope, org tabs
// for org-scope. Both have an "Issues" tab so the nav stays consistent
// and operators can hop sideways from issues to other tabs without
// backing out to a parent page first.
let tabsChrome = ''
if (scope.repo) {
tabsChrome = REPO_TAB_STYLES + renderRepoTabs(scope.repo, 'issues', { issues: counts.total }, prefix)
} else if (scope.org) {
const org = await svc.modules.orgs.get(scope.org)
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const orgCounts = {
issues: counts.total,
repos: org ? org.resources.length : 0,
members: org ? org.members.length : 0
}
tabsChrome = ORG_TAB_STYLES + renderOrgTabs(scope.org, 'issues', orgCounts, prefix, canAdmin)
}
const body = `
${LABEL_STYLES}
${tabsChrome}
<div class="card"><h2><a href="${scopePath}">← ${escapeHtml(scopeName)}</a> (${scopeKind}) / issues</h2></div>
<div class="card">
<h2>Filter${repoLabel}</h2>
<div>
${filterLink('open', 'open', counts.open)}
${filterLink('closed', 'closed', counts.closed)}
${filterLink('all', 'all', counts.total)}
</div>
${labelFilter && labelFilter.length > 0 ? `
<div class="label-filter-bar">
<span class="meta">labels:</span>
${labelFilter.map(l => '<span class="label-chip">' + escapeHtml(l) + '</span>').join('')}
<a href="${baseUrl}?status=${status}" class="meta" style="margin-left:0.5rem">clear</a>
</div>` : ''}
</div>
<div class="card">
<h2>${list.length} ${status} issue${list.length === 1 ? '' : 's'}${labelFilter && labelFilter.length > 0 ? ' (filtered)' : ''}</h2>
${list.length === 0 ? `<div class="empty">${labelFilter && labelFilter.length > 0
? 'No issues match these filters. <a href="' + baseUrl + '">Clear filters</a> or try another status.'
: 'No ' + status + ' issues yet. Use the <a href="#create-issue">Create issue</a> form below to report one.'
}</div>` : list.map(i => `
<div class="item ${i.status}">
<div>
<span class="meta">#${i.id}</span>
<a class="title" href="${baseUrl}/${i.id}">${escapeHtml(i.title)}</a>
<span class="badge badge-${i.status}">${i.status}</span>
${(i.images || []).length > 0 ? '<span class="meta" style="margin-left:0.3rem">[' + i.images.length + ' attachment' + (i.images.length === 1 ? '' : 's') + ']</span>' : ''}
${renderLabelChips(i.labels, baseUrl)}
</div>
<div class="meta">by ${displayAuthor(i.author, authorMap, prefix)} · ${new Date(i.createdAt).toLocaleString()}</div>
</div>`).join('')}
</div>
<div class="card" id="create-issue">
<h2>Create issue</h2>
<form action="${baseUrl}/create" method="POST" class="pgv3-upload-form">
<div class="field"><label for="new-issue-title">Title</label><input id="new-issue-title" name="title" required></div>
<div class="field"><label for="new-issue-body">Body</label><textarea id="new-issue-body" name="body"></textarea></div>
<div class="field"><label for="new-issue-labels">Labels</label><input id="new-issue-labels" name="labels" placeholder="comma-separated: bug, ui, p1"></div>
<div class="field"><label for="new-issue-attachment">Attachment</label><input id="new-issue-attachment" type="file" name="attachment"></div>
<div class="upload-preview meta" style="margin-bottom:0.5rem"></div>
<input type="hidden" name="images" value="">
<button type="submit">Create</button>
</form>
<div class="meta" style="margin-top:0.4rem">You'll be attributed by your current display name — change it any time on <a href="${prefix}/profile">/profile</a>.</div>
</div>
<script src="${prefix}/static/pgv3-upload.js"></script>
`
return layout('issues', '/issues', body, ctx)
}
/**
* Error page for failed issue mutations (create/comment/labels/close).
* Mirrors renderOrgError — a focused card with the error message plus a
* scope-aware back link so the user can retry. Scope is { repo } or { org }.
*/
export function renderIssueError (scope, err, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const scopePath = prefix + (scope && scope.repo
? '/repos/' + encodeURIComponent(scope.repo)
: scope && scope.org
? '/orgs/' + encodeURIComponent(scope.org)
: '')
const scopeName = (scope && (scope.repo || scope.org)) || 'issues'
const body = `
<div class="card">
<h2>error</h2>
<pre>${escapeHtml(err.message || String(err))}</pre>
<div style="margin-top:0.6rem"><a href="${scopePath}/issues">← back to issues</a></div>
</div>
`
return layout(`error · ${scopeName}`, scopePath + '/issues', body, ctx)
}
export async function renderIssueShow (svc, id, ctx, scope) {
const prefix = (ctx && ctx.prefix) || ''
if (!scope || (!scope.repo && !scope.org)) {
throw new Error('renderIssueShow requires a scope with { repo } or { org }')
}
const issues = scope.repo
? await svc.issueRegistry.getForRepo(scope.repo)
: await svc.issueRegistry.getForOrg(scope.org)
const issue = await issues.get(id)
if (!issue) return null
const comments = await issues.getComments(id)
const authorMap = await buildAuthorMap(svc, [issue.author, ...comments.map(c => c.author)])
const scopePath = prefix + (scope.repo
? '/repos/' + encodeURIComponent(scope.repo)
: '/orgs/' + encodeURIComponent(scope.org))
const baseUrl = scopePath + '/issues'
const currentLabels = (issue.labels || []).join(', ')
let tabsChrome = ''
if (scope.repo) {
tabsChrome = REPO_TAB_STYLES + renderRepoTabs(scope.repo, 'issues', {}, prefix)
} else if (scope.org) {
const org = await svc.modules.orgs.get(scope.org)
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const orgCounts = org
? { repos: org.resources.length, members: org.members.length }
: {}
tabsChrome = ORG_TAB_STYLES + renderOrgTabs(scope.org, 'issues', orgCounts, prefix, canAdmin)
}
const body = `
${LABEL_STYLES}
${tabsChrome}
<div class="card">
<div class="meta" style="margin-bottom:0.3rem">
${scope.repo
? `<a href="${prefix}/repos">repos</a> / <a href="${prefix}/repos/${encodeURIComponent(scope.repo)}">${escapeHtml(scope.repo)}</a> / <a href="${baseUrl}">issues</a> / #${issue.id}`
: `<a href="${prefix}/orgs">orgs</a> / <a href="${prefix}/orgs/${encodeURIComponent(scope.org)}">${escapeHtml(scope.org)}</a> / <a href="${baseUrl}">issues</a> / #${issue.id}`
}
</div>
<h2>#${issue.id} <span class="badge badge-${issue.status}">${issue.status}</span></h2>
<div><strong>${escapeHtml(issue.title)}</strong></div>
<div style="margin-top:0.4rem">${renderLabelChips(issue.labels, baseUrl)}</div>
<div class="meta" style="margin-top:0.4rem">by ${displayAuthor(issue.author, authorMap, prefix)} · ${new Date(issue.createdAt).toLocaleString()}</div>
${issue.body ? renderMarkdownBody(issue.body, 'margin-top:0.8rem') : ''}
${renderImages(issue.images, prefix)}
<form action="${baseUrl}/${issue.id}/${issue.status === 'open' ? 'close' : 'reopen'}" method="POST" style="margin-top:0.8rem">
<button class="btn-secondary" type="submit">${issue.status === 'open' ? 'Close' : 'Reopen'}</button>
</form>
</div>
<div class="card">
<h2>Labels</h2>
<form action="${baseUrl}/${issue.id}/labels" method="POST">
<div class="field"><label for="edit-labels" class="sr-only">Labels</label><input id="edit-labels" name="labels" value="${escapeHtml(currentLabels)}" placeholder="comma-separated: bug, ui, p1"></div>
<button class="btn-secondary" type="submit">Update labels</button>
</form>
</div>
<div class="card">
<h2>${comments.length} comment${comments.length === 1 ? '' : 's'}</h2>
${comments.length === 0 ? '<div class="empty">(no comments)</div>' : comments.map(c => `
<div class="item">
<div class="meta">${displayAuthor(c.author, authorMap, prefix)} · ${new Date(c.createdAt).toLocaleString()}${c.editedAt ? ' (edited)' : ''}</div>
${renderMarkdownBody(c.body, 'margin-top:0.3rem')}
</div>`).join('')}
<form action="${baseUrl}/${issue.id}/comment" method="POST" class="pgv3-upload-form" style="margin-top:0.8rem">
<div class="field"><label for="comment-body">Body</label><textarea id="comment-body" name="body" required></textarea></div>
<div class="field"><label for="comment-attachment">Attachment</label><input id="comment-attachment" type="file" name="attachment"></div>
<div class="upload-preview meta" style="margin-bottom:0.5rem"></div>
<input type="hidden" name="images" value="">
<button type="submit">Comment</button>
</form>
</div>
<script src="${prefix}/static/pgv3-upload.js"></script>
${ISSUE_MD_SCRIPT}
`
return layout(`issue #${issue.id}`, '/issues', body, ctx)
}