OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / issue-index.js

185 lines · javascript ref:
/**
 * Issue-author index — "recent issues this user has posted, across any repo
 * or org". Powers the public profile "Recent issues" list.
 *
 * WHY an index (not a live query): issues live in one autobase PER repo/org
 * (lazy-loaded). Aggregating "issues by author" across the fleet would mean
 * opening ~every tracker autobase on each profile view — the same cold-start
 * cost the /repos page just shed. Instead we record (userId → issue ref) at
 * create time (where the canonical author userId is already resolved), so the
 * profile read is O(1). Like the push-index, this captures issues created
 * AFTER deploy; a backfill (scan trackers once, offline) can seed history.
 *
 * Storage: <storage>/issue-index.json
 *   { version, users: { <userId>: { "<scope>:<name>:<id>": {scope,name,id,title,status,createdAt} } } }
 */

import { readFile, writeFile, rename } from '#node:fs/promises'
import { join } from '#node:path'

const index = new Map() // userId -> Map<key, entry>

let persistPath = null
let loaded = false
let dirty = false
let saveTimer = null

const keyOf = (e) => `${e.scope}:${e.name}:${e.id}`

/**
 * Drop ALL in-memory state so the next configureIssueIndex() re-reads the
 * index file — i.e. model a daemon restart within one process.
 *
 * Needed because a test cannot get a fresh copy of this module under Bare:
 * `import('./issue-index.js?fresh=x')` returns a DISTINCT module under Node
 * but the SAME object under Bare, which resolves by realpath and ignores the
 * query. Without this, every test in a file shares one index and entries
 * recorded by an earlier test are still counted by a later one.
 *
 * Test seam only — nothing in the daemon calls it. Deliberately does NOT touch
 * the index FILE, only process-local state.
 */
export function resetIssueIndexForTests () {
  index.clear()
  persistPath = null
  loaded = false
  dirty = false
  if (saveTimer) {
    clearTimeout(saveTimer)
    saveTimer = null
  }
}

/** Point the index at a storage dir and load persisted entries. Idempotent. */
export async function configureIssueIndex (storageDir) {
  if (!storageDir) return
  persistPath = join(storageDir, 'issue-index.json')
  if (loaded) return
  loaded = true
  try {
    const obj = JSON.parse(await readFile(persistPath, 'utf8'))
    if (obj && obj.users && typeof obj.users === 'object') {
      for (const [userId, entries] of Object.entries(obj.users)) {
        if (!entries || typeof entries !== 'object') continue
        const m = new Map()
        for (const [k, e] of Object.entries(entries)) {
          if (e && e.name && (e.id || e.id === 0)) m.set(k, e)
        }
        if (m.size) index.set(userId, m)
      }
    }
  } catch {
    // No file yet (first boot) or unreadable — start empty.
  }
}

/**
 * Record an issue authored by `userId`. Best-effort; a falsy userId (an
 * unattributable create) is ignored. Upserts by (scope,name,id) so a
 * re-record refreshes title/status without duplicating.
 * @param {string} userId
 * @param {{scope:'repo'|'org', name:string, id:number, title?:string, status?:string, createdAt?:number}} entry
 */
export function recordIssue (userId, entry, now = Date.now()) {
  if (!userId || !entry || !entry.name || (entry.id === undefined || entry.id === null)) return
  let entries = index.get(userId)
  if (!entries) { entries = new Map(); index.set(userId, entries) }
  const e = {
    scope: entry.scope === 'org' ? 'org' : 'repo',
    name: entry.name,
    id: entry.id,
    title: entry.title || '',
    status: entry.status || 'open',
    createdAt: entry.createdAt || now
  }
  entries.set(keyOf(e), e)
  dirty = true
  schedulePersist()
}

/**
 * Issues authored by `userId`, most-recent first.
 * @returns {Array<{scope,name,id,title,status,createdAt}>}
 */
export function listUserIssues (userId, limit = 10) {
  const entries = index.get(userId)
  if (!entries) return []
  return [...entries.values()]
    .sort((a, b) => b.createdAt - a.createdAt)
    .slice(0, limit)
}

/**
 * One-time backfill from existing issue history. Enumerates EVERY tracker via
 * issueRegistry.keys() (repos + orgs — the persisted index, not just the
 * in-memory/lazy set), opens each, and records every issue's author. Upserts,
 * so it's safe to run over an already-partially-populated index. Best-effort
 * per tracker (a tracker that fails to open is skipped, not fatal).
 *
 * @returns {Promise<{indexed:number, repos:number, orgs:number}>}
 */
export async function backfillIssueIndex (svc) {
  if (!svc || !svc.issueRegistry || typeof svc.issueRegistry.keys !== 'function') {
    return { indexed: 0, repos: 0, orgs: 0 }
  }
  const allKeys = await svc.issueRegistry.keys().catch(() => ({ repos: {}, orgs: {} }))
  const repos = Object.keys((allKeys && allKeys.repos) || {})
  const orgs = Object.keys((allKeys && allKeys.orgs) || {})
  let indexed = 0

  const drain = async (scope, name, tracker) => {
    const list = await tracker.list({ status: 'all' }).catch(() => [])
    for (const it of list) {
      // The materialized view record's author field is `author` (the op's
      // `userId` value is projected to `author`); keep `userId` as a fallback.
      const authorId = it && (it.author || it.userId)
      if (!authorId || it.id == null) continue
      recordIssue(authorId, {
        scope,
        name,
        id: it.id,
        title: it.title,
        status: it.status,
        createdAt: it.createdAt || it.timestamp
      }, it.createdAt || it.timestamp || undefined)
      indexed++
    }
  }

  for (const name of repos) {
    try { await drain('repo', name, await svc.issueRegistry.getForRepo(name)) } catch {}
  }
  for (const name of orgs) {
    try { await drain('org', name, await svc.issueRegistry.getForOrg(name)) } catch {}
  }

  await flushIssueIndex()
  return { indexed, repos: repos.length, orgs: orgs.length }
}

/** Force any pending persist immediately (graceful shutdown + tests). */
export async function flushIssueIndex () {
  if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
  await persistNow()
}

function schedulePersist () {
  if (!persistPath || saveTimer) return
  saveTimer = setTimeout(() => { saveTimer = null; persistNow().catch(() => {}) }, 1000)
  if (saveTimer && typeof saveTimer.unref === 'function') saveTimer.unref()
}

async function persistNow () {
  if (!persistPath || !dirty) return
  dirty = false
  const users = {}
  for (const [userId, entries] of index) users[userId] = Object.fromEntries(entries)
  const tmp = persistPath + '.tmp'
  try {
    await writeFile(tmp, JSON.stringify({ version: 1, users }))
    await rename(tmp, persistPath)
  } catch {
    dirty = true
  }
}