/**
* issue-index: record authored issues, upsert-by-ref, recency order,
* disk persistence + restart survival. Powers the public profile
* "Recent issues" list.
*/
import test from 'brittle'
import { mkdtemp, rm, mkdir, readFile } from '#node:fs/promises'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import { issueHandlers } from '../lib/handlers/issues.js'
import { listUserIssues } from '../lib/services/issue-index.js'
function fresh (tag) { return import('../lib/services/issue-index.js?fresh=' + tag) }
test('issue-index: record, upsert, order, persistence + restart', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'ii-'))
const storage = join(root, 's')
await mkdir(storage, { recursive: true })
t.teardown(() => rm(root, { recursive: true, force: true }))
const m = await fresh('1')
await m.configureIssueIndex(storage)
m.recordIssue('alice', { scope: 'repo', name: 'app', id: 1, title: 'first', status: 'open', createdAt: 1000 })
m.recordIssue('alice', { scope: 'org', name: 'acme', id: 7, title: 'org bug', status: 'open', createdAt: 3000 })
m.recordIssue('alice', { scope: 'repo', name: 'app', id: 1, title: 'first', status: 'closed', createdAt: 1000 }) // upsert status
m.recordIssue('bob', { scope: 'repo', name: 'app', id: 2, title: 'bobs', status: 'open', createdAt: 2000 })
m.recordIssue(null, { scope: 'repo', name: 'app', id: 9 }) // unattributable → ignored
m.recordIssue('carol', { scope: 'repo', name: 'app' }) // no id → ignored
const alice = m.listUserIssues('alice')
t.is(alice.length, 2, 'alice authored 2 distinct issues (upsert did not duplicate)')
t.is(alice[0].id, 7, 'most-recent (createdAt) first')
const app1 = alice.find(i => i.id === 1)
t.is(app1.status, 'closed', 'upsert refreshed status')
t.is(app1.scope, 'repo', 'scope preserved')
t.is(m.listUserIssues('bob').length, 1, 'bob has 1')
t.is(m.listUserIssues('carol').length, 0, 'no-id issue ignored')
t.is(m.listUserIssues('nobody').length, 0, 'unknown user → empty')
// limit
m.recordIssue('alice', { scope: 'repo', name: 'app', id: 3, title: 't3', createdAt: 4000 })
t.is(m.listUserIssues('alice', 1).length, 1, 'limit honored')
t.is(m.listUserIssues('alice', 1)[0].id, 3, 'limit returns newest')
await m.flushIssueIndex()
const disk = JSON.parse(await readFile(join(storage, 'issue-index.json'), 'utf8'))
t.ok(disk.users.alice && disk.users.alice['repo:app:1'], 'persisted keyed by scope:name:id')
const m2 = await fresh('2')
await m2.configureIssueIndex(storage)
const alice2 = m2.listUserIssues('alice')
t.is(alice2.length, 3, 'survived restart (2 + the extra)')
t.is(alice2[0].id, 3, 'order preserved after restart')
})
test('issue-index: backfill enumerates all trackers and records authors', async (t) => {
const m = await fresh('backfill')
// No storage configured → persist is a no-op; we only assert in-memory state.
//
// The index is module state, and `fresh(tag)` does NOT give a distinct
// module under Bare — it resolves by realpath and ignores the ?fresh query,
// so every test in this file shares one index. Without this reset the
// previous test's alice entries are still present and the counts below read
// 5 instead of 3. (Under Node the imports really are distinct, which is why
// this only started failing when the suite moved to Bare.)
m.resetIssueIndexForTests()
const fakeSvc = {
issueRegistry: {
keys: async () => ({ repos: { app: {}, tools: {} }, orgs: { acme: {} } }),
// Real view records carry the author as `author`, not `userId`.
getForRepo: async (name) => ({
list: async () => name === 'app'
? [
{ id: 1, title: 'a1', author: 'alice', status: 'open', createdAt: 1000 },
{ id: 2, title: 'a2', author: 'bob', status: 'closed', createdAt: 2000 }
]
: [{ id: 1, title: 't1', author: 'alice', status: 'open', createdAt: 4000 }]
}),
getForOrg: async () => ({
list: async () => [{ id: 5, title: 'o5', author: 'alice', status: 'open', createdAt: 3000 }]
})
}
}
const r = await m.backfillIssueIndex(fakeSvc)
t.is(r.repos, 2, 'scanned 2 repos')
t.is(r.orgs, 1, 'scanned 1 org')
t.is(r.indexed, 4, 'recorded 4 issues')
const alice = m.listUserIssues('alice')
t.is(alice.length, 3, 'alice authored 3 (app#1, tools#1, acme#5)')
t.is(alice[0].id, 1, 'newest first: tools#1 @4000')
t.is(alice[0].name, 'tools', 'tools repo')
t.is(alice[0].scope, 'repo')
t.is(alice.find(i => i.scope === 'org').name, 'acme', 'org issue captured')
t.is(m.listUserIssues('bob').length, 1, 'bob authored 1')
})
test('P2: comment records participation; update refreshes status under original author', async (t) => {
let stored = { id: 5, title: 'T', author: 'orig', status: 'open', createdAt: 1000 }
const svc = {
identityRegistry: {},
issueRegistry: {
getForRepo: async () => ({
comment: async () => 1,
update: async (id, upd) => { stored = { ...stored, ...upd } },
get: async () => stored
})
}
}
const H = issueHandlers(svc)
const comment = H.find(h => h.name === 'comment-issue')
const update = H.find(h => h.name === 'update-issue')
await comment.fn({ transport: 'uds' }, { repo: 'app', id: 5, body: 'hi', author: 'commenter' })
t.ok(listUserIssues('commenter').some(i => i.id === 5), 'commented-on issue indexed under the commenter')
await update.fn({ transport: 'uds' }, { repo: 'app', id: 5, status: 'closed', author: 'someone-else' })
const orig = listUserIssues('orig').find(i => i.id === 5)
t.ok(orig, 'issue indexed under the ORIGINAL author on update (not the updater)')
t.is(orig.status, 'closed', 'status refreshed to closed')
t.absent(listUserIssues('someone-else').some(i => i.id === 5), 'updater is NOT credited as author')
})