Skip to content
Closed
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
51 changes: 13 additions & 38 deletions src/components/Tables/QueueDequeueReasons.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { readEnumChoices } from '../../util/enumChoices';
import configSchema from '../../util/sanitizedConfigSchema';

import { renderMarkdown } from './utils';

// The `queue-dequeue-reason` condition attribute accepts one of the merge queue
// dequeue codes. The engine is the single source of truth: the codes come from
// the attribute's enum, and a one-line description for each is published
// alongside it under `x-enum-descriptions` (keyed by the raw code). Driving the
// code list from the enum keeps the table current even before a schema sync
// delivers the descriptions.
// dequeue codes. The engine is the single source of truth: the codes and their
// one-line descriptions are published on the attribute itself, so this table
// stays current without being hand-maintained.
//
// `x-enum-descriptions` is not in the synced schema until the engine ships it,
// so it is absent from the JSON-derived types and read via a cast (it becomes a
// normal typed key once the sync bot lands it).
// `readEnumChoices` handles every shape the engine can publish here — a flat
// `enum`, the `anyOf` of `const`/`enum` branches a composed `Literal` produces
// today, or a `$ref` to a shared component — and reads the documentation from
// either `x-mergify-enum` or the older `x-enum-descriptions` map.
//
// The lookup is optional-chained through a loose cast so a future schema reshape
// (renamed attribute or restructured `$defs`) degrades to an empty table rather
Expand All @@ -22,39 +22,14 @@ const reasonProp: unknown = (
}
).$defs?.PullRequestAttributes?.properties?.['queue-dequeue-reason'];

type EnumNode = { anyOf?: EnumNode[]; enum?: string[]; const?: string };

function branchValues(node: EnumNode): string[] {
if (node.const !== undefined) {
return [node.const];
}
return node.enum ?? [];
}

// Collect the raw enum values the attribute accepts, tolerating the shapes the
// engine might emit: an `anyOf` of `{const}` / `{enum}` branches (today), or a
// flat `{enum}` / `{const}`. Returns [] for anything else (or a missing node),
// so a future schema-shape change degrades to an empty table rather than
// crashing the Astro build.
function enumValues(prop: unknown): string[] {
if (!prop || typeof prop !== 'object') {
return [];
}
const node = prop as EnumNode;
return node.anyOf ? node.anyOf.flatMap(branchValues) : branchValues(node);
}

// The engine stores codes as `UPPER_SNAKE`; conditions are written in kebab-case
// (the parser normalizes `upper().replace('-', '_')`), so that is what to show.
function toKebab(code: string): string {
return code.toLowerCase().replace(/_/g, '-');
}

export default function QueueDequeueReasons() {
const descriptions =
(reasonProp as { 'x-enum-descriptions'?: Record<string, string> } | undefined)?.[
'x-enum-descriptions'
] ?? {};
const choices = readEnumChoices(configSchema, reasonProp);

return (
<div className="table-wrap">
Expand All @@ -66,12 +41,12 @@ export default function QueueDequeueReasons() {
</tr>
</thead>
<tbody>
{enumValues(reasonProp).map((code) => (
<tr key={code}>
{choices.map((choice) => (
<tr key={choice.value}>
<td>
<code>{toKebab(code)}</code>
<code>{toKebab(choice.value)}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: renderMarkdown(descriptions[code] ?? '') }} />
<td dangerouslySetInnerHTML={{ __html: renderMarkdown(choice.description) }} />
</tr>
))}
</tbody>
Expand Down
173 changes: 173 additions & 0 deletions src/util/enumChoices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { readEnumChoices, resolveRef } from './enumChoices';

// This reader spans an engine-side migration, so each shape it has to survive
// is pinned here. Every failure mode below is silent by nature — a shape it
// mishandles renders a header with an empty or subtly wrong table rather than
// failing the build — which is why they are tested rather than left to review.
describe('readEnumChoices', () => {
it('reads the target shape: x-mergify-enum aligned with enum', () => {
expect(
readEnumChoices(
{},
{
enum: ['running', 'failed'],
'x-mergify-enum': [
{ title: 'CI Running', description: 'Checks are running.' },
{ title: 'Failed', description: 'Checks failed.', deprecated: true },
],
}
)
).toEqual([
{
value: 'running',
title: 'CI Running',
description: 'Checks are running.',
deprecated: false,
},
{ value: 'failed', title: 'Failed', description: 'Checks failed.', deprecated: true },
]);
});

it('falls back to the x-enum-descriptions map while the engine migrates', () => {
expect(
readEnumChoices(
{},
{ enum: ['a', 'b'], 'x-enum-descriptions': { a: 'First.', b: 'Second.' } }
)
).toEqual([
{ value: 'a', title: undefined, description: 'First.', deprecated: false },
{ value: 'b', title: undefined, description: 'Second.', deprecated: false },
]);
});

it('merges the two shapes so a half-migrated node keeps every description', () => {
// Both shapes can coexist on one node mid-migration; treating them as
// alternatives would blank values the schema still documents.
const choices = readEnumChoices(
{},
{
enum: ['a', 'b', 'c'],
'x-mergify-enum': [{ description: 'A new' }, {}, {}],
'x-enum-descriptions': { a: 'A old', b: 'B old', c: 'C old' },
}
);
expect(choices.map((c) => c.description)).toEqual(['A new', 'B old', 'C old']);
});

it('flattens a composed Literal published as anyOf of const/enum branches', () => {
expect(
readEnumChoices(
{},
{
anyOf: [{ const: 'NONE' }, { enum: ['MERGED', 'DEQUEUED'] }],
'x-enum-descriptions': { NONE: 'Not queued.' },
}
).map((c) => c.value)
).toEqual(['NONE', 'MERGED', 'DEQUEUED']);
});

it('resolves a $ref to a hoisted component', () => {
const root = {
components: {
schemas: {
Outcome: {
enum: ['success'],
'x-mergify-enum': [{ title: 'Success', description: 'It passed.' }],
},
},
},
};
expect(readEnumChoices(root, { $ref: '#/components/schemas/Outcome' })).toEqual([
{ value: 'success', title: 'Success', description: 'It passed.', deprecated: false },
]);
});

it('resolves a $ref sitting inside an anyOf branch', () => {
// What an optional hoisted enum looks like: {anyOf: [{$ref}, {type: null}]}.
// Resolving only the top node would yield nothing at all.
const root = {
$defs: {
Reason: {
enum: ['A', 'B'],
'x-mergify-enum': [{ description: 'a' }, { description: 'b' }],
},
},
};
const choices = readEnumChoices(root, {
anyOf: [{ $ref: '#/$defs/Reason' }, { type: 'null' }],
});
expect(choices.map((c) => c.value)).toEqual(['A', 'B']);
expect(choices.map((c) => c.description)).toEqual(['a', 'b']);
});

it('reads metadata published as a $ref sibling, not only on the target', () => {
// Pydantic publishes an annotation inline for an inlined type but as a
// sibling of `$ref` once the type is hoisted into `$defs`.
const root = { $defs: { Reason: { enum: ['A', 'B'] } } };
const choices = readEnumChoices(root, {
$ref: '#/$defs/Reason',
'x-mergify-enum': [{ description: 'first' }, { description: 'second' }],
});
expect(choices.map((c) => c.description)).toEqual(['first', 'second']);
});

it('ignores a misaligned x-mergify-enum rather than shifting every description', () => {
// Positional metadata whose length disagrees with `enum` describes the
// wrong values from the first divergence onward. Publishing nothing beats
// publishing confidently wrong sentences.
const choices = readEnumChoices(
{},
{
enum: ['b', 'c'],
'x-mergify-enum': [
{ description: 'desc for a' },
{ description: 'desc for b' },
{ description: 'desc for c' },
],
}
);
expect(choices.map((c) => c.description)).toEqual(['', '']);
});

it('leaves values undocumented rather than dropping them', () => {
const choices = readEnumChoices(
{},
{ enum: ['a', 'b'], 'x-mergify-enum': [{ title: 'A' }, {}] }
);
expect(choices.map((c) => c.value)).toEqual(['a', 'b']);
expect(choices.map((c) => c.description)).toEqual(['', '']);
});

it('reads a single-value choice set published as a non-string const', () => {
// A one-value literal publishes `{const: 1}` where a two-value one
// publishes `{enum: [1, 2]}`; accepting only string consts would render
// the second and silently drop the first.
expect(readEnumChoices({}, { const: 1, 'x-mergify-enum': [{ description: 'one' }] })).toEqual([
{ value: '1', title: undefined, description: 'one', deprecated: false },
]);
});

it('degrades to an empty list instead of throwing on unusable input', () => {
expect(readEnumChoices({}, undefined)).toEqual([]);
expect(readEnumChoices({}, { type: 'string' })).toEqual([]);
expect(readEnumChoices({}, { $ref: '#/nope/missing' })).toEqual([]);
});
});

describe('resolveRef', () => {
it('stops on a dangling ref rather than looping or throwing', () => {
expect(resolveRef({}, { $ref: '#/a/b' })).toEqual({ $ref: '#/a/b' });
});

it('does not throw on a pointer containing a stray percent sign', () => {
// Hand-rolled `decodeURIComponent` on each segment raises URIError here,
// which would break the never-throws contract during SSR.
const root = { components: { schemas: { 'A%B': { enum: ['x'] } } } };
expect(() => resolveRef(root, { $ref: '#/components/schemas/A%B' })).not.toThrow();
});

it('returns non-ref nodes untouched', () => {
expect(resolveRef({}, { enum: ['x'] })).toEqual({ enum: ['x'] });
});
});
Loading