OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / issues.js

278 lines · javascript ref:
/**
 * Issue RPC handlers.
 *
 * Every handler requires a scope — either `{ repo: <repoName> }` or
 * `{ org: <orgName> }`. There is no global scope (P.2 removed it).
 * Mutating handlers call requireWriter(ctx) — UDS bypasses, HTTP needs
 * a valid session.
 */

import process from '#node:process'
import fs from '#node:fs'
import { join } from '#node:path'
import { requireWriter } from '../auth/check.js'
import { recordIssue } from '../services/issue-index.js'
import { assertValidRepoName } from '../repo-name.js'

// Attribute the create / comment to the caller's canonical userId.
// HTTP: resolve session pubkey → User → userId. Reject if no session.
// UDS: explicit params.author wins (CLIs / migrations pass it directly);
//      otherwise fall through to canonical attribution via the daemon's
//      own identity (the only sensible UDS default — local owner).
// The stored author is ALWAYS a userId (or, for UDS, whatever string
// the caller chose). Display happens at render time via getUser, so a
// later display-name change propagates automatically.
async function resolveAuthor (ctx, params, registry) {
  if (ctx && ctx.transport === 'uds') {
    if (params && typeof params.author === 'string' && params.author) return params.author
    // No explicit author from UDS — leave attribution to the caller's
    // discretion by stamping 'uds-local'. We don't try to back-derive
    // an HTTP user here; UDS callers are the daemon admin.
    return 'uds-local'
  }
  const pubkey = ctx && ctx.identity && ctx.identity.publicKey
  if (!pubkey) {
    const err = new Error('authentication required')
    err.code = 'EAUTH'
    err.status = 401
    throw err
  }
  const u = await registry.getUserByIdentity(pubkey).catch(() => null)
  if (!u || !u.userId) {
    const err = new Error('no user record for this session')
    err.code = 'EAUTH'
    err.status = 401
    throw err
  }
  return u.userId
}

export function issueHandlers (svc) {
  const registry = () => svc.identityRegistry

  // Resolve the correct HyperIssues instance from scope params.
  // Exactly one of { repo, org } must be present.
  //
  // On a strict-bootstrap mirror, getFor* throws ETRACKER_NOT_READY when
  // the tracker's bootstrap key hasn't been replicated yet. We re-throw
  // with a code so the RPC layer can map to a 503-equivalent and SSR
  // pages can render a "syncing — bootstrap not yet replicated" hint
  // rather than a 500.
  // Is this a repo the daemon ALREADY has? Either a materialized bare repo on
  // disk, or a tracker already open/indexed. Deliberately NOT a name check —
  // the whole point is to distinguish "exists" from "well-formed".
  const repoExists = (name) => {
    try {
      if (svc._reposDir && fs.existsSync(join(svc._reposDir, name + '.git'))) return true
    } catch {}
    try {
      const open = svc.issueRegistry.listRepos ? svc.issueRegistry.listRepos() : []
      if (Array.isArray(open) && open.includes(name)) return true
    } catch {}
    return false
  }

  /**
   * @param {object} params
   * @param {object} [opts]
   * @param {boolean} [opts.mustExist] - READ paths set this. See below.
   */
  const getIssues = async (params, opts = {}) => {
    if (!svc.issueRegistry) throw new Error('issue registry not available')
    const hasRepo = !!params.repo
    const hasOrg = !!params.org
    if (hasRepo && hasOrg) throw new Error('issues require exactly one of { repo, org }, not both')
    if (!hasRepo && !hasOrg) throw new Error('issues require either { repo } or { org }')
    // getForRepo MINTS a tracker autobase on first reference (and the
    // ref-reconciler `git init --bare`s alongside it), so an unvalidated
    // client string became a permanent repo. See lib/repo-name.js.
    if (hasRepo) {
      assertValidRepoName(params.repo, 'repo')
      // A READ must never create. Name validation cannot catch this class:
      // `create`, `list`, `new`, `help` and `comment` are all VALID repo
      // names, indistinguishable from a repo legitimately called that — and
      // every one of them exists as an empty repo on the live fleet because a
      // read minted it. lib/repo-name.js names this as the remaining gap and
      // the required fix: "require the repo to already exist".
      //
      // Writes still auto-create, deliberately: filing the first issue against
      // a repo whose tracker has not been opened yet is a legitimate flow.
      if (opts.mustExist && !repoExists(params.repo)) {
        const err = new Error(`repo "${params.repo}" not found`)
        err.code = 'ENOREPO'
        err.status = 404
        throw err
      }
      return svc.issueRegistry.getForRepo(params.repo)
    }
    return svc.issueRegistry.getForOrg(params.org)
  }

  // Shape the scope onto the response so callers can distinguish results.
  const scopeOf = (params) => params.repo
    ? { repo: params.repo }
    : { org: params.org }

  return [
    {
      name: 'list-issues',
      fn: async (ctx, params = {}) => {
        const issues = await getIssues(params, { mustExist: true })
        const filters = {}
        if (params.status) filters.status = params.status
        if (params.author) filters.author = params.author
        if (params.search) filters.search = params.search
        if (params.labels) filters.labels = params.labels
        if (params.sort) filters.sort = params.sort
        if (params.limit) filters.limit = params.limit
        if (params.offset) filters.offset = params.offset
        const result = await issues.list(filters)
        return { ...scopeOf(params), issues: result }
      }
    },

    {
      name: 'get-issue',
      fn: async (ctx, params = {}) => {
        if (typeof params.id !== 'number') throw new Error('get-issue requires { id }')
        const issues = await getIssues(params, { mustExist: true })
        const issue = await issues.get(params.id)
        if (!issue) throw new Error(`issue #${params.id} not found`)
        const comments = await issues.getComments(params.id)
        return { ...issue, ...scopeOf(params), comments }
      }
    },

    {
      name: 'create-issue',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!params.title) throw new Error('create-issue requires { title }')
        const issues = await getIssues(params)
        const author = await resolveAuthor(ctx, params, registry())
        const id = await issues.create({
          title: params.title,
          body: params.body,
          author,
          labels: params.labels,
          images: params.images
        })
        const created = await issues.get(id)
        // Index the author→issue ref for the public profile "recent issues"
        // list (best-effort; unattributable creates are skipped).
        recordIssue(author, {
          scope: params.repo ? 'repo' : 'org',
          name: params.repo || params.org,
          id,
          title: params.title,
          status: (created && created.status) || 'open',
          createdAt: (created && created.createdAt) || undefined
        })
        return { ...created, ...scopeOf(params) }
      }
    },

    {
      name: 'update-issue',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (typeof params.id !== 'number') throw new Error('update-issue requires { id }')
        const issues = await getIssues(params)
        const author = params.author || ctx.identity?.userId || 'anon'
        const updates = {}
        if (params.title !== undefined) updates.title = params.title
        if (params.body !== undefined) updates.body = params.body
        if (params.status !== undefined) updates.status = params.status
        if (params.labels !== undefined) updates.labels = params.labels
        await issues.update(params.id, updates, author)
        const updated = await issues.get(params.id)
        // Keep the issue-index status fresh under the ORIGINAL author (not the
        // updater) so a profile reflects close/reopen. Best-effort upsert.
        if (updated && updated.author) {
          recordIssue(updated.author, {
            scope: params.repo ? 'repo' : 'org',
            name: params.repo || params.org,
            id: params.id,
            title: updated.title,
            status: updated.status,
            createdAt: updated.createdAt
          })
        }
        return { ...updated, ...scopeOf(params) }
      }
    },

    {
      name: 'comment-issue',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (typeof params.id !== 'number' || !params.body) {
          throw new Error('comment-issue requires { id, body }')
        }
        const issues = await getIssues(params)
        const author = await resolveAuthor(ctx, params, registry())
        const commentId = await issues.comment(params.id, { body: params.body, author })
        // Surface commented-on issues on the commenter's profile too (issues
        // they've participated in, not only authored). Best-effort upsert.
        try {
          const issue = await issues.get(params.id)
          if (issue && author) {
            recordIssue(author, {
              scope: params.repo ? 'repo' : 'org',
              name: params.repo || params.org,
              id: params.id,
              title: issue.title,
              status: issue.status,
              createdAt: issue.createdAt
            })
          }
        } catch {}
        return { commentId, ...scopeOf(params) }
      }
    },

    {
      name: 'issue-counts',
      fn: async (ctx, params = {}) => {
        // If a scope is supplied, return counts for that scope alone.
        // Otherwise return aggregate counts across EVERY known tracker —
        // including those recorded in the disk index but not yet lazily
        // opened this process (else aggregate shows 0 after a restart
        // because the in-memory cache is empty until something touches
        // each tracker).
        if (params.repo || params.org) {
          const issues = await getIssues(params, { mustExist: true })
          return { ...scopeOf(params), ...(await issues.counts()) }
        }
        if (!svc.issueRegistry) return { total: 0, open: 0, closed: 0, repos: 0, orgs: 0 }
        const allKeys = await svc.issueRegistry.keys()
        for (const repo of Object.keys(allKeys.repos || {})) await svc.issueRegistry.getForRepo(repo)
        for (const org of Object.keys(allKeys.orgs || {})) await svc.issueRegistry.getForOrg(org)
        return svc.issueRegistry.counts()
      }
    },

    {
      name: 'list-issue-repos',
      fn: async () => {
        if (!svc.issueRegistry) return { repos: [] }
        // Return everything the daemon knows about from the persisted
        // index — not just trackers that happen to be in memory this
        // process. Otherwise a fresh boot shows an empty repos list
        // until something touches each tracker.
        const allKeys = await svc.issueRegistry.keys()
        return { repos: Object.keys(allKeys.repos || {}) }
      }
    },

    {
      name: 'list-issue-orgs',
      fn: async () => {
        if (!svc.issueRegistry) return { orgs: [] }
        const allKeys = await svc.issueRegistry.keys()
        return { orgs: Object.keys(allKeys.orgs || {}) }
      }
    }
  ]
}