OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / issues.js

381 lines · javascript ref:
/**
 * 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 ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[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}">&larr; ${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)
}