diff --git a/CHANGELOG.md b/CHANGELOG.md index 100a6784..69812a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Fixed - `@relayfile/adapter-linear` issue creates now accept synced `team.id` and label ids directly, resolve `team.key`/`team.name` plus label names through mounted team/label indexes, and keep explicit `teamId`/`labelIds` authoritative; adapter-core now enforces the schema's at-least-one team reference. +- `@relayfile/adapter-github` now backfills label names into legacy issue `_index.json` rows from materialized issue artifacts, keeping label-filtered consumers on the index-only path. - `@relayfile/adapter-core` direct HTTP create-draft writes now reuse the mount daemon's content identity, so a later mount echo coalesces onto the original revision and writeback operation instead of making its receipt context unreadable. - `@relayfile/relay-helpers` GitHub and Linear create helpers now return a discriminated `confirmed`/`pending`/`dropped` result, preserve late-receipt writes as non-throwing `pending`, and never disguise a Relayfile draft path as a provider URL. - `@relayfile/relay-helpers` final-write policies now compose monotonically in shared async scopes: authored rebinding cannot relax an outer denial or replace its canonical preview transport, overlapping Runs can be isolated, and out-of-order cleanup cannot resurrect stale policy. diff --git a/packages/github/src/__tests__/emit-auxiliary-files.test.ts b/packages/github/src/__tests__/emit-auxiliary-files.test.ts index 84cf7d0a..d99ab253 100644 --- a/packages/github/src/__tests__/emit-auxiliary-files.test.ts +++ b/packages/github/src/__tests__/emit-auxiliary-files.test.ts @@ -467,6 +467,226 @@ describe('emitGitHubAuxiliaryFiles', () => { assert.deepEqual(rows[0]?.labels, []); }); + it('backfills labels on legacy issue index rows from materialized by-id artifacts', async () => { + const indexPath = githubRepoIssuesIndexPath('acme', 'widgets'); + const client = createClient({ + initialFiles: { + [indexPath]: JSON.stringify([ + { + id: '7', + title: 'Factory issue', + updated: '2026-05-12T00:00:00Z', + number: 7, + state: 'open', + }, + ]), + [githubByIdAliasPath('acme', 'widgets', 'issues', 7)]: JSON.stringify({ + provider: 'github', + objectType: 'issue', + objectId: '7', + payload: { + owner: 'acme', + repo: 'widgets', + number: 7, + title: 'Factory issue', + state: 'open', + labels: [{ name: 'factory' }, { name: 'bug' }], + updated_at: '2026-05-12T00:00:00Z', + }, + }), + }, + }); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + issues: [ + { + owner: 'acme', + repo: 'widgets', + number: 8, + title: 'Support issue', + state: 'open', + labels: [{ name: 'support' }], + updated_at: '2026-05-13T00:00:00Z', + }, + ], + }); + + const rows = JSON.parse(client.files.get(indexPath) ?? '[]') as Array>; + assert.deepEqual(rows, [ + { + id: '8', + title: 'Support issue', + updated: '2026-05-13T00:00:00Z', + number: 8, + state: 'open', + labels: ['support'], + }, + { + id: '7', + title: 'Factory issue', + updated: '2026-05-12T00:00:00Z', + number: 7, + state: 'open', + labels: ['factory', 'bug'], + }, + ]); + }); + + // --- legacy label backfill vs. delete tombstones ------------------------- + // `IndexFileReconciler.flush` applies removes to the on-disk rows first and + // then merges every queued upsert back in, so an upsert always beats a remove + // for the same id. The backfill must therefore never queue a row that a + // tombstone in the same batch is deleting — and must keep backfilling every + // row that no tombstone targets. + + function legacyBackfillFixture(): CapturingClient { + return createClient({ + initialFiles: { + [githubRepoIssuesIndexPath('acme', 'widgets')]: JSON.stringify([ + // Legacy row: written before `labels` joined the index contract. + { id: '7', title: 'Factory issue', updated: '2026-05-12T00:00:00Z', number: 7, state: 'open' }, + { id: '22', title: 'Kept issue', updated: '2026-05-10T00:00:00Z', number: 22, state: 'open', labels: [] }, + ]), + [githubByIdAliasPath('acme', 'widgets', 'issues', 7)]: JSON.stringify({ + provider: 'github', + objectType: 'issue', + objectId: '7', + payload: { + owner: 'acme', + repo: 'widgets', + number: 7, + title: 'Factory issue', + state: 'open', + labels: [{ name: 'factory' }, { name: 'bug' }], + updated_at: '2026-05-12T00:00:00Z', + }, + }), + }, + }); + } + + function issueIndexRows(client: CapturingClient): Array> { + const indexPath = githubRepoIssuesIndexPath('acme', 'widgets'); + return JSON.parse(client.files.get(indexPath) ?? '[]') as Array>; + } + + // MUST-FIRE: the tombstoned legacy row must not come back. + it('does not resurrect a legacy issue row that a delete tombstone removes in the same batch', async () => { + const client = legacyBackfillFixture(); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + issues: [{ owner: 'acme', repo: 'widgets', id: '7', _deleted: true }], + }); + + const rows = issueIndexRows(client); + assert.deepEqual( + rows.map((r) => r.id), + ['22'], + 'tombstoned legacy issue 7 was re-added to the index by the backfill', + ); + }); + + // MUST-NOT-FIRE (a): with no tombstone at all, the legacy row is still backfilled. + it('still backfills a legacy issue row when no delete tombstone targets it', async () => { + const client = legacyBackfillFixture(); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + issues: [ + { + owner: 'acme', + repo: 'widgets', + number: 8, + title: 'Support issue', + state: 'open', + labels: [{ name: 'support' }], + updated_at: '2026-05-13T00:00:00Z', + }, + ], + }); + + const rows = issueIndexRows(client); + const legacy = rows.find((r) => r.id === '7'); + assert.ok(legacy, 'legacy issue 7 must survive when nothing deletes it'); + assert.deepEqual(legacy?.labels, ['factory', 'bug']); + }); + + // MUST-NOT-FIRE (b): the skip is scoped to the tombstoned id, not "any batch + // containing a tombstone". A tombstone for a different issue must not disable + // the backfill for row 7. + it('backfills untombstoned legacy rows even when the batch deletes a different issue', async () => { + const client = legacyBackfillFixture(); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + issues: [{ owner: 'acme', repo: 'widgets', id: '9', _deleted: true }], + }); + + const rows = issueIndexRows(client); + const legacy = rows.find((r) => r.id === '7'); + assert.ok(legacy, 'legacy issue 7 must survive a tombstone aimed at issue 9'); + assert.deepEqual( + legacy?.labels, + ['factory', 'bug'], + 'backfill must stay scoped to the tombstoned id, not disable itself wholesale', + ); + }); + + // A partially unreadable label array must fail open rather than backfill a + // silently truncated set as authoritative. + it('leaves a legacy row untouched when the by-id artifact has an unreadable label entry', async () => { + const indexPath = githubRepoIssuesIndexPath('acme', 'widgets'); + const client = createClient({ + initialFiles: { + [indexPath]: JSON.stringify([ + { id: '7', title: 'Factory issue', updated: '2026-05-12T00:00:00Z', number: 7, state: 'open' }, + ]), + [githubByIdAliasPath('acme', 'widgets', 'issues', 7)]: JSON.stringify({ + provider: 'github', + objectType: 'issue', + objectId: '7', + payload: { + owner: 'acme', + repo: 'widgets', + number: 7, + title: 'Factory issue', + state: 'open', + // The `factory` entry is unreadable; keeping the readable remainder + // would drop it and hide the issue from label filters for good. + labels: [{ nmae: 'factory' }, { name: 'bug' }], + updated_at: '2026-05-12T00:00:00Z', + }, + }), + }, + }); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + issues: [ + { + owner: 'acme', + repo: 'widgets', + number: 8, + title: 'Support issue', + state: 'open', + labels: [{ name: 'support' }], + updated_at: '2026-05-13T00:00:00Z', + }, + ], + }); + + const rows = JSON.parse(client.files.get(indexPath) ?? '[]') as Array>; + const legacy = rows.find((r) => r.id === '7'); + assert.ok(legacy, 'legacy row must be retained'); + assert.equal( + Object.hasOwn(legacy!, 'labels'), + false, + 'a truncated label set must not be backfilled as authoritative', + ); + }); + it('uses the newest lifecycle timestamp for PR by-edited aliases', async () => { const client = createClient(); diff --git a/packages/github/src/emit-auxiliary-files.ts b/packages/github/src/emit-auxiliary-files.ts index 3d16928e..eec7eef9 100644 --- a/packages/github/src/emit-auxiliary-files.ts +++ b/packages/github/src/emit-auxiliary-files.ts @@ -94,6 +94,7 @@ import { const GITHUB_PROVIDER_NAME = 'github' as const; const JSON_CONTENT_TYPE = EMIT_AUXILIARY_JSON_CONTENT_TYPE; const NUMBERED_DELETE_RECOVERY_REPO_SCAN_LIMIT = 25; +const ISSUE_INDEX_LABEL_BACKFILL_READ_CONCURRENCY = 25; // --------------------------------------------------------------------------- // Public input types @@ -328,6 +329,18 @@ export async function emitGitHubAuxiliaryFiles( // --- issues ------------------------------------------------------------- if (issues.length > 0) { + // Rows written before labels joined the public issue-index contract remain + // in the read/merge/write baseline until that exact issue changes. Hydrate + // those legacy rows from their stable materialized by-id artifacts first; + // current-batch upserts below then win with the freshest record data. + await backfillLegacyIssueIndexLabels( + client, + workspaceId, + issues, + priorReader, + (owner, repo) => getIssueReconciler(owner, repo), + ); + const fan = await runEmitBatch(client, workspaceId, issues, async (record) => { if (isDeleteRecord(record)) { return planNumberedDelete( @@ -430,6 +443,126 @@ async function writeRootIndex( } } +async function backfillLegacyIssueIndexLabels( + client: AuxiliaryEmitterClient, + workspaceId: string, + issues: readonly (GitHubIssueEmitRecord | ScopedDeleteTombstone)[], + priorReader: PriorAliasReader, + getReconciler: (owner: string, repo: string) => IndexFileReconciler, +): Promise { + if (!priorReader.isAvailable()) { + return; + } + + const reposByIndexPath = new Map(); + // Issue numbers a tombstone deletes in this same batch. `IndexFileReconciler` + // applies removes to the on-disk rows first and then merges every queued + // upsert back in, so an upsert always wins over a remove for the same id + // regardless of queueing order. Hydrating a tombstoned row here would + // therefore overwrite its own removal and resurrect a deleted issue — + // permanently, because the resurrected row now carries `labels` and is never + // treated as legacy again. + const deletedByIndexPath = new Map>(); + // Tombstones may omit owner/repo; planNumberedDelete recovers the repo later, + // so we cannot bind those to an index path yet. Skip them in every repo — an + // over-skip only defers one row's backfill to the next emit, while an + // under-skip resurrects a deleted issue. + const deletedInUnknownRepo = new Set(); + for (const issue of issues) { + const repoInfo = extractRepoInfo(issue); + if (isDeleteRecord(issue)) { + const number = readNumberLike(issue.id); + if (number !== null) { + if (repoInfo) { + const indexPath = githubRepoIssuesIndexPath(repoInfo.owner, repoInfo.repo); + let deleted = deletedByIndexPath.get(indexPath); + if (!deleted) { + deleted = new Set(); + deletedByIndexPath.set(indexPath, deleted); + } + deleted.add(number); + } else { + deletedInUnknownRepo.add(number); + } + } + } + if (!repoInfo) continue; + // A tombstone still opts its repo into the sweep: the batch's other legacy + // rows are unaffected by the delete and should still converge. + reposByIndexPath.set(githubRepoIssuesIndexPath(repoInfo.owner, repoInfo.repo), repoInfo); + } + + for (const [indexPath, repoInfo] of reposByIndexPath) { + const rows = await readJsonArray(client, workspaceId, indexPath); + const deletedNumbers = deletedByIndexPath.get(indexPath); + const legacyRows = rows.filter((row) => { + if (Array.isArray(row.labels)) return false; + const number = readNumberLike(row.number) ?? readNumberLike(row.id); + if (number !== null && (deletedNumbers?.has(number) || deletedInUnknownRepo.has(number))) { + return false; + } + return true; + }); + if (legacyRows.length === 0) { + continue; + } + + const reconciler = getReconciler(repoInfo.owner, repoInfo.repo); + for (let offset = 0; offset < legacyRows.length; offset += ISSUE_INDEX_LABEL_BACKFILL_READ_CONCURRENCY) { + const chunk = legacyRows.slice(offset, offset + ISSUE_INDEX_LABEL_BACKFILL_READ_CONCURRENCY); + const hydrated = await Promise.all( + chunk.map(async (row): Promise => { + const number = readNumberLike(row.number) ?? readNumberLike(row.id); + if (number === null) { + return null; + } + const labels = await priorReader.read( + githubByIdAliasPath(repoInfo.owner, repoInfo.repo, 'issues', number), + extractMaterializedIssueLabels, + ); + if (labels === null) { + return null; + } + return { + ...(row as Partial), + id: number, + title: typeof row.title === 'string' ? row.title : number, + updated: typeof row.updated === 'string' ? row.updated : '', + number: Number(number), + state: typeof row.state === 'string' ? row.state : '', + labels, + }; + }), + ); + reconciler.upsert(...hydrated.filter((row): row is GitHubRecordIndexRow => row !== null)); + } + } +} + +function extractMaterializedIssueLabels(parsed: Record): string[] | null { + const payload = pickPayload(parsed); + // Only an explicit array proves the authoritative artifact is label-complete. + // Missing/malformed artifacts keep the legacy row unchanged so consumers can + // continue their fail-open fallback rather than silently filtering it out. + if (!payload || !Array.isArray(payload.labels)) { + return null; + } + // The elements have to be readable too, not just the container. + // `readGitHubLabelNames` drops unreadable entries silently, and a partially + // dropped set is indistinguishable from a complete one to consumers that + // filter on it — so a single malformed entry could hide a `factory`-labelled + // issue for good, since the backfilled row is never treated as legacy again. + const everyLabelReadable = payload.labels.every( + (label) => + readNonEmptyString(label) !== undefined + || readNonEmptyString(isRecord(label) ? label.name : undefined) !== undefined, + ); + if (!everyLabelReadable) { + return null; + } + return readGitHubLabelNames(payload); +} + function stringifyError(error: unknown): string { if (error instanceof Error) return error.message; return String(error);