/**
* 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 || {}) }
}
}
]
}