Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
220 changes: 220 additions & 0 deletions packages/github/src/__tests__/emit-auxiliary-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
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<Record<string, unknown>> {
const indexPath = githubRepoIssuesIndexPath('acme', 'widgets');
return JSON.parse(client.files.get(indexPath) ?? '[]') as Array<Record<string, unknown>>;
}

// 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<Record<string, unknown>>;
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();

Expand Down
133 changes: 133 additions & 0 deletions packages/github/src/emit-auxiliary-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<GitHubRecordIndexRow>,
): Promise<void> {
if (!priorReader.isAvailable()) {
return;
}

const reposByIndexPath = new Map<string, { owner: string; repo: string }>();
// 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<string, Set<string>>();
// 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<string>();
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<string>();
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) {
Comment thread
khaliqgant marked this conversation as resolved.
const chunk = legacyRows.slice(offset, offset + ISSUE_INDEX_LABEL_BACKFILL_READ_CONCURRENCY);
const hydrated = await Promise.all(
chunk.map(async (row): Promise<GitHubRecordIndexRow | null> => {
const number = readNumberLike(row.number) ?? readNumberLike(row.id);
if (number === null) {
return null;
}
const labels = await priorReader.read<string[]>(
githubByIdAliasPath(repoInfo.owner, repoInfo.repo, 'issues', number),
extractMaterializedIssueLabels,
);
if (labels === null) {
return null;
}
return {
...(row as Partial<GitHubRecordIndexRow>),
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));
Comment thread
khaliqgant marked this conversation as resolved.
Comment thread
khaliqgant marked this conversation as resolved.
}
Comment thread
khaliqgant marked this conversation as resolved.
}
}

function extractMaterializedIssueLabels(parsed: Record<string, unknown>): 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);
Comment thread
khaliqgant marked this conversation as resolved.
}

function stringifyError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
Expand Down
Loading