From 2bcd81d80d0b4664be6dff673d2b0c465646e843 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 16 Aug 2026 13:06:58 +0200 Subject: [PATCH 1/4] test(github): cover legacy issue index labels --- .../__tests__/emit-auxiliary-files.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/github/src/__tests__/emit-auxiliary-files.test.ts b/packages/github/src/__tests__/emit-auxiliary-files.test.ts index 84cf7d0a..21353297 100644 --- a/packages/github/src/__tests__/emit-auxiliary-files.test.ts +++ b/packages/github/src/__tests__/emit-auxiliary-files.test.ts @@ -467,6 +467,72 @@ 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'], + }, + ]); + }); + it('uses the newest lifecycle timestamp for PR by-edited aliases', async () => { const client = createClient(); From 5b5b3698e91302b84bf184e89dc00e91607a3357 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 16 Aug 2026 13:08:55 +0200 Subject: [PATCH 2/4] fix(github): backfill legacy issue index labels --- CHANGELOG.md | 1 + packages/github/src/emit-auxiliary-files.ts | 83 +++++++++++++++++++++ 2 files changed, 84 insertions(+) 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/emit-auxiliary-files.ts b/packages/github/src/emit-auxiliary-files.ts index 3d16928e..8fc9e51c 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,76 @@ 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(); + for (const issue of issues) { + const repoInfo = extractRepoInfo(issue); + if (!repoInfo) continue; + reposByIndexPath.set(githubRepoIssuesIndexPath(repoInfo.owner, repoInfo.repo), repoInfo); + } + + await Promise.all( + [...reposByIndexPath.entries()].map(async ([indexPath, repoInfo]) => { + const rows = await readJsonArray(client, workspaceId, indexPath); + const legacyRows = rows.filter((row) => !Array.isArray(row.labels)); + if (legacyRows.length === 0) { + return; + } + + 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: readNonEmptyString(row.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; + } + return readGitHubLabelNames(payload); +} + function stringifyError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); From 73d2bb57607def486aaecfd9426e02ed7b1ebbd7 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 16 Aug 2026 13:14:52 +0200 Subject: [PATCH 3/4] fix(github): bound issue label backfill reads --- packages/github/src/emit-auxiliary-files.ts | 74 ++++++++++----------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/packages/github/src/emit-auxiliary-files.ts b/packages/github/src/emit-auxiliary-files.ts index 8fc9e51c..2c43985a 100644 --- a/packages/github/src/emit-auxiliary-files.ts +++ b/packages/github/src/emit-auxiliary-files.ts @@ -461,45 +461,43 @@ async function backfillLegacyIssueIndexLabels( reposByIndexPath.set(githubRepoIssuesIndexPath(repoInfo.owner, repoInfo.repo), repoInfo); } - await Promise.all( - [...reposByIndexPath.entries()].map(async ([indexPath, repoInfo]) => { - const rows = await readJsonArray(client, workspaceId, indexPath); - const legacyRows = rows.filter((row) => !Array.isArray(row.labels)); - if (legacyRows.length === 0) { - return; - } + for (const [indexPath, repoInfo] of reposByIndexPath) { + const rows = await readJsonArray(client, workspaceId, indexPath); + const legacyRows = rows.filter((row) => !Array.isArray(row.labels)); + 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: readNonEmptyString(row.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)); - } - }), - ); + 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 { From b5c7d03dc122423d2b81987a9c9e556bbfaccdbe Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Sun, 16 Aug 2026 20:35:21 +0200 Subject: [PATCH 4/4] fix(github): never backfill a tombstoned legacy issue row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IndexFileReconciler.flush` filters the on-disk rows by the queued removes and then merges every queued upsert back in, so an upsert always wins over a remove for the same id regardless of queueing order. The legacy label backfill hydrated every label-less row in each touched repo, including rows whose issue a tombstone was deleting in the same batch — re-adding the deleted issue to `_index.json` permanently, since the resurrected row now carries `labels` and is never treated as legacy again. Exclude tombstoned numbers from the backfill sweep. Tombstones that omit owner/repo have their repo recovered later in planNumberedDelete, so those numbers are skipped in every repo: an over-skip only defers one row's backfill to the next emit, an under-skip resurrects a deleted issue. The repo of a tombstone still opts into the sweep — its other legacy rows are unaffected by the delete and should still converge. Also tighten `extractMaterializedIssueLabels`: it required `labels` to be an array but not its entries to be readable, while `readGitHubLabelNames` drops unreadable entries silently. 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. Fail open and keep the legacy row instead, matching the documented intent. Tests: a must-fire/must-not-fire trio pinning that a tombstoned row is not resurrected, that an untombstoned row is still backfilled, and that a tombstone aimed at another issue does not disable the backfill wholesale; plus a truncated-label-set case. Co-Authored-By: Claude Opus 5 Session-Id: 0baaa00e-1cd6-474b-99b3-0df6024ca89f --- .../__tests__/emit-auxiliary-files.test.ts | 154 ++++++++++++++++++ packages/github/src/emit-auxiliary-files.ts | 54 +++++- 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/packages/github/src/__tests__/emit-auxiliary-files.test.ts b/packages/github/src/__tests__/emit-auxiliary-files.test.ts index 21353297..d99ab253 100644 --- a/packages/github/src/__tests__/emit-auxiliary-files.test.ts +++ b/packages/github/src/__tests__/emit-auxiliary-files.test.ts @@ -533,6 +533,160 @@ describe('emitGitHubAuxiliaryFiles', () => { ]); }); + // --- 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 2c43985a..eec7eef9 100644 --- a/packages/github/src/emit-auxiliary-files.ts +++ b/packages/github/src/emit-auxiliary-files.ts @@ -455,15 +455,54 @@ async function backfillLegacyIssueIndexLabels( } 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 legacyRows = rows.filter((row) => !Array.isArray(row.labels)); + 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; } @@ -508,6 +547,19 @@ function extractMaterializedIssueLabels(parsed: Record): string 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); }