From fad7e3e9d5e1128e3868fd09c06e82a041a2c1f5 Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Wed, 29 Jul 2026 18:44:06 +0200 Subject: [PATCH 1/4] feat(docs): document the batch status data type A batch's `status.code` appeared on the API reference as a bare list of values with no explanation, and nowhere else in the docs. Readers seeing a status in an API response, in the CLI, or on a dashboard chip had no way to find out what it meant. Add a Batch Status section to the data-types page, rendering a table generated from the OpenAPI spec so the codes and their descriptions come from the engine rather than being hand-maintained, and cross-reference it from the monitoring and batches pages. Extend the anchor build gate to scan the API spec alongside the configuration schema. This is the first data type marked in the API spec, and schema syncs land as direct pushes to main, so the deploy build is the only gate they pass through. Also sanitize `renderMarkdown`. Its output is injected with `dangerouslySetInnerHTML` by every schema-driven table, and it did not filter URL protocols, so a `javascript:` link in any synced description would have rendered as a live link. Raw HTML was already dropped, since `remark-rehype` runs without `allowDangerousHtml`. The inputs are all first-party, so this is defence in depth, but the guarantee belongs in the shared helper rather than in each of its six callers. Verified that article text and every href on the data-types, file-format and batches pages are unchanged. Both bundled schemas are what the sync bot will deliver once the engine side merges. They carry the per-value documentation as `x-mergify-enum`, a positional array aligned with `enum` holding a description, an optional display title and a deprecation flag. `x-enum-descriptions` is gone: that name is an established openapi-generator convention for a positional array of strings, and publishing a map under it would mislead the SDK generators customers run against our spec. Part of MRGFY-8330 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ic5cd08016af10408a80c0d9f72b86dd61abc0a84 --- integrations/validate-data-type-anchors.ts | 30 +- package.json | 1 + pnpm-lock.yaml | 20 + public/api-schemas.json | 945 ++++-------------- public/mergify-configuration-schema.json | 176 +++- src/components/Tables/BatchStatusCodes.tsx | 50 + src/components/Tables/utils.test.ts | 32 + src/components/Tables/utils.ts | 20 +- src/content/docs/configuration/data-types.mdx | 15 + src/content/docs/merge-queue/batches.mdx | 5 + src/content/docs/merge-queue/monitoring.mdx | 4 + src/util/dataType.test.ts | 11 + 12 files changed, 523 insertions(+), 786 deletions(-) create mode 100644 src/components/Tables/BatchStatusCodes.tsx create mode 100644 src/components/Tables/utils.test.ts diff --git a/integrations/validate-data-type-anchors.ts b/integrations/validate-data-type-anchors.ts index 0de789aa11..e61dd0d6ad 100644 --- a/integrations/validate-data-type-anchors.ts +++ b/integrations/validate-data-type-anchors.ts @@ -1,9 +1,19 @@ import type { AstroIntegration } from 'astro'; +import rawApiSchema from '../public/api-schemas.json'; import rawConfigSchema from '../public/mergify-configuration-schema.json'; import { missingDataTypeAnchors } from '../src/util/dataTypeAnchors'; +// Both synced schemas can carry the engine's `x-has-data-type` marker: the +// configuration schema for types you write in `.mergify.yml`, the OpenAPI spec +// for types the API only reports (a batch status, say). Both arrive by the same +// bot sync, so both need the same gate. +const SCHEMAS: { file: string; schema: unknown }[] = [ + { file: 'public/mergify-configuration-schema.json', schema: rawConfigSchema }, + { file: 'public/api-schemas.json', schema: rawApiSchema }, +]; + /** - * Fail the build when the config schema flags a documented data type whose + * Fail the build when a synced schema flags a documented data type whose * derived anchor (slugified `title`) has no matching heading on the * data-types page. This is the enforcement point that actually guards the * drift path: schema syncs land as direct bot pushes to main (no PR, so no @@ -16,14 +26,18 @@ export function validateDataTypeAnchors(): AstroIntegration { name: 'validate-data-type-anchors', hooks: { 'astro:build:start': () => { - const missing = missingDataTypeAnchors(rawConfigSchema); - if (missing.length > 0) { + const problems = SCHEMAS.flatMap(({ file, schema }) => { + const missing = missingDataTypeAnchors(schema); + return missing.length > 0 ? [`${file}: ${missing.join(', ')}`] : []; + }); + + if (problems.length > 0) { throw new Error( - `Documented data type(s) in public/mergify-configuration-schema.json have no ` + - `matching heading anchor on src/content/docs/configuration/data-types.mdx: ` + - `${missing.join(', ')}. A marked node's slugified title must equal the anchor ` + - `of its section heading (add the missing section, or fix the title next to the ` + - `engine's DocsDataType annotation).` + `Documented data type(s) in a synced schema have no matching heading anchor on ` + + `src/content/docs/configuration/data-types.mdx — ${problems.join('; ')}. ` + + `A marked node's slugified title must equal the anchor of its section heading ` + + `(add the missing section, or fix the title next to the engine's DocsDataType ` + + `annotation).` ); } }, diff --git a/package.json b/package.json index ca6b4d8e00..896d497e7b 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "rehype-autolink-headings": "^7.1.0", "rehype-format": "^5.0.1", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-lint": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8559d3087..ff55814043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 rehype-slug: specifier: ^6.0.0 version: 6.0.0 @@ -3256,6 +3259,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-select@6.0.4: resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} @@ -4428,6 +4434,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-slug@6.0.0: resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} @@ -9112,6 +9121,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.1 + unist-util-position: 5.0.0 + hast-util-select@6.0.4: dependencies: '@types/hast': 3.0.5 @@ -10645,6 +10660,11 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + rehype-slug@6.0.0: dependencies: '@types/hast': 3.0.5 diff --git a/public/api-schemas.json b/public/api-schemas.json index a28b89baa7..39ab7ef174 100644 --- a/public/api-schemas.json +++ b/public/api-schemas.json @@ -1329,53 +1329,7 @@ { "type": "array", "items": { - "enum": [ - "action.assign", - "action.backport", - "action.close", - "action.comment", - "action.copy", - "action.delete_head_branch", - "action.dequeue", - "action.dismiss_reviews", - "action.edit", - "action.github_actions", - "action.label", - "action.merge", - "action.post_check", - "action.queue.batch_bisection_end", - "action.queue.batch_bisection_start", - "action.queue.change", - "action.queue.checks.change", - "action.queue.checks_end", - "action.queue.checks_not_started", - "action.queue.checks_start", - "action.queue.conflict_deferred", - "action.queue.enter", - "action.queue.leave", - "action.queue.merged", - "action.rebase", - "action.refresh", - "action.request_reviews", - "action.review", - "action.squash", - "action.update", - "ci_insights.auto_quarantine.disabled", - "ci_insights.auto_quarantine.enabled", - "ci_insights.auto_quarantine.modified", - "ci_insights.job_retried", - "command.dequeue", - "command.queue", - "queue.pause.create", - "queue.pause.delete", - "queue.pause.update", - "scheduled_freeze.create", - "scheduled_freeze.delete", - "scheduled_freeze.update", - "test.dequarantined", - "test.quarantined" - ], - "type": "string" + "$ref": "#/components/schemas/EventType" } }, { @@ -1396,53 +1350,7 @@ { "type": "array", "items": { - "enum": [ - "action.assign", - "action.backport", - "action.close", - "action.comment", - "action.copy", - "action.delete_head_branch", - "action.dequeue", - "action.dismiss_reviews", - "action.edit", - "action.github_actions", - "action.label", - "action.merge", - "action.post_check", - "action.queue.batch_bisection_end", - "action.queue.batch_bisection_start", - "action.queue.change", - "action.queue.checks.change", - "action.queue.checks_end", - "action.queue.checks_not_started", - "action.queue.checks_start", - "action.queue.conflict_deferred", - "action.queue.enter", - "action.queue.leave", - "action.queue.merged", - "action.rebase", - "action.refresh", - "action.request_reviews", - "action.review", - "action.squash", - "action.update", - "ci_insights.auto_quarantine.disabled", - "ci_insights.auto_quarantine.enabled", - "ci_insights.auto_quarantine.modified", - "ci_insights.job_retried", - "command.dequeue", - "command.queue", - "queue.pause.create", - "queue.pause.delete", - "queue.pause.update", - "scheduled_freeze.create", - "scheduled_freeze.delete", - "scheduled_freeze.update", - "test.dequarantined", - "test.quarantined" - ], - "type": "string" + "$ref": "#/components/schemas/EventType" } }, { @@ -1463,13 +1371,7 @@ { "type": "array", "items": { - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "type": "string" + "$ref": "#/components/schemas/EventOutcome" } }, { @@ -1490,13 +1392,7 @@ { "type": "array", "items": { - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "type": "string" + "$ref": "#/components/schemas/EventOutcome" } }, { @@ -1828,53 +1724,7 @@ { "type": "array", "items": { - "enum": [ - "action.assign", - "action.backport", - "action.close", - "action.comment", - "action.copy", - "action.delete_head_branch", - "action.dequeue", - "action.dismiss_reviews", - "action.edit", - "action.github_actions", - "action.label", - "action.merge", - "action.post_check", - "action.queue.batch_bisection_end", - "action.queue.batch_bisection_start", - "action.queue.change", - "action.queue.checks.change", - "action.queue.checks_end", - "action.queue.checks_not_started", - "action.queue.checks_start", - "action.queue.conflict_deferred", - "action.queue.enter", - "action.queue.leave", - "action.queue.merged", - "action.rebase", - "action.refresh", - "action.request_reviews", - "action.review", - "action.squash", - "action.update", - "ci_insights.auto_quarantine.disabled", - "ci_insights.auto_quarantine.enabled", - "ci_insights.auto_quarantine.modified", - "ci_insights.job_retried", - "command.dequeue", - "command.queue", - "queue.pause.create", - "queue.pause.delete", - "queue.pause.update", - "scheduled_freeze.create", - "scheduled_freeze.delete", - "scheduled_freeze.update", - "test.dequarantined", - "test.quarantined" - ], - "type": "string" + "$ref": "#/components/schemas/EventType" } }, { @@ -1895,53 +1745,7 @@ { "type": "array", "items": { - "enum": [ - "action.assign", - "action.backport", - "action.close", - "action.comment", - "action.copy", - "action.delete_head_branch", - "action.dequeue", - "action.dismiss_reviews", - "action.edit", - "action.github_actions", - "action.label", - "action.merge", - "action.post_check", - "action.queue.batch_bisection_end", - "action.queue.batch_bisection_start", - "action.queue.change", - "action.queue.checks.change", - "action.queue.checks_end", - "action.queue.checks_not_started", - "action.queue.checks_start", - "action.queue.conflict_deferred", - "action.queue.enter", - "action.queue.leave", - "action.queue.merged", - "action.rebase", - "action.refresh", - "action.request_reviews", - "action.review", - "action.squash", - "action.update", - "ci_insights.auto_quarantine.disabled", - "ci_insights.auto_quarantine.enabled", - "ci_insights.auto_quarantine.modified", - "ci_insights.job_retried", - "command.dequeue", - "command.queue", - "queue.pause.create", - "queue.pause.delete", - "queue.pause.update", - "scheduled_freeze.create", - "scheduled_freeze.delete", - "scheduled_freeze.update", - "test.dequarantined", - "test.quarantined" - ], - "type": "string" + "$ref": "#/components/schemas/EventType" } }, { @@ -1962,13 +1766,7 @@ { "type": "array", "items": { - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "type": "string" + "$ref": "#/components/schemas/EventOutcome" } }, { @@ -1989,13 +1787,7 @@ { "type": "array", "items": { - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "type": "string" + "$ref": "#/components/schemas/EventOutcome" } }, { @@ -5567,12 +5359,7 @@ { "type": "array", "items": { - "enum": [ - "healthy", - "flaky", - "broken" - ], - "type": "string" + "$ref": "#/components/schemas/HealthStatus" } }, { @@ -5593,12 +5380,7 @@ { "type": "array", "items": { - "enum": [ - "healthy", - "flaky", - "broken" - ], - "type": "string" + "$ref": "#/components/schemas/HealthStatus" } }, { @@ -5619,12 +5401,7 @@ { "type": "array", "items": { - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" + "$ref": "#/components/schemas/HealthConfidence" } }, { @@ -5645,12 +5422,7 @@ { "type": "array", "items": { - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" + "$ref": "#/components/schemas/HealthConfidence" } }, { @@ -6587,11 +6359,7 @@ "schema": { "anyOf": [ { - "enum": [ - "manual", - "auto" - ], - "type": "string" + "$ref": "#/components/schemas/QuarantineSource" }, { "type": "null" @@ -7536,12 +7304,7 @@ { "type": "array", "items": { - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" + "$ref": "#/components/schemas/HealthConfidence" } }, { @@ -7562,12 +7325,7 @@ { "type": "array", "items": { - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" + "$ref": "#/components/schemas/HealthConfidence" } }, { @@ -9497,7 +9255,58 @@ "waiting_for_checks_slot", "frozen" ], - "title": "Code" + "title": "Batch Status", + "x-has-data-type": true, + "x-mergify-enum": [ + { + "description": "The batch is set up and its CI checks are running.", + "title": "CI Running" + }, + { + "description": "The batch failed, so Mergify is splitting it into smaller sub-batches and testing those to identify the pull request responsible and merge the others.", + "title": "Bisecting Batch" + }, + { + "description": "Mergify is setting the batch up so it can be tested.", + "title": "Preparing" + }, + { + "description": "The batch failed, either because Mergify could not prepare it for testing or because its checks did not pass. It is removed from the queue and its pull requests have to be fixed.", + "title": "Failed" + }, + { + "description": "The batch was merged.", + "title": "Merged" + }, + { + "description": "The batch's checks passed. It merges once the batches ahead of it have merged.", + "title": "Waiting to Merge" + }, + { + "description": "A batch ahead in the queue failed, so this batch is held while Mergify identifies the cause. It has either not started its own checks yet, or already ran and failed and is parked until the cause is known.", + "title": "Waiting on Previous Batch" + }, + { + "description": "A batch ahead in the queue failed, so this batch is requeued and retested against the updated queue.", + "title": "Requeuing" + }, + { + "description": "The batch's merge conditions are met, but a schedule condition is not. It continues once the schedule allows it.", + "title": "Waiting for Schedule" + }, + { + "description": "Mergify is waiting for more pull requests to fill the batch, or for the maximum wait time to elapse, before starting it.", + "title": "Filling Batch" + }, + { + "description": "The batch is ready to be tested, but every slot for running checks in parallel is taken. It starts as soon as one frees up.", + "title": "Waiting for CI Slot" + }, + { + "description": "The batch's checks passed, but merges are frozen. It merges once the freeze is lifted.", + "title": "Frozen" + } + ] }, "batch_filled_slots": { "anyOf": [ @@ -9785,20 +9594,7 @@ "description": "The timestamp when the checks have ended for this pull request" }, "state": { - "type": "string", - "enum": [ - "failure", - "error", - "cancelled", - "action_required", - "timed_out", - "pending", - "neutral", - "skipped", - "stale", - "success" - ], - "title": "State", + "$ref": "#/components/schemas/QueueCheckState", "description": "The global state of the checks" } }, @@ -10450,14 +10246,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -10548,14 +10337,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -10622,14 +10404,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -10723,14 +10498,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -10824,14 +10592,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -10929,14 +10690,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11092,14 +10846,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11179,14 +10926,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11311,14 +11051,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11385,14 +11118,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11472,14 +11198,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11569,14 +11288,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11656,14 +11368,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "metadata": { "$ref": "#/components/schemas/EventNoMetadata" @@ -11730,14 +11435,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11820,14 +11518,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -11907,14 +11598,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12012,14 +11696,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12110,14 +11787,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12160,6 +11830,15 @@ "type": "object", "title": "EventNoMetadata" }, + "EventOutcome": { + "type": "string", + "enum": [ + "success", + "failure", + "pending", + "neutral" + ] + }, "EventPostCheck": { "properties": { "id": { @@ -12202,14 +11881,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12317,14 +11989,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12538,14 +12203,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12715,14 +12373,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12819,14 +12470,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -12911,14 +12555,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -13219,14 +12856,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -13340,14 +12970,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -13579,14 +13202,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -13711,14 +13327,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -13827,14 +13436,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14103,14 +13705,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14232,14 +13827,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14328,14 +13916,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14419,14 +14000,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14515,14 +14089,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "metadata": { "$ref": "#/components/schemas/EventNoMetadata" @@ -14589,14 +14156,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "metadata": { "$ref": "#/components/schemas/EventNoMetadata" @@ -14663,14 +14223,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14761,14 +14314,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14877,14 +14423,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -14978,14 +14517,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -15084,14 +14616,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -15189,14 +14714,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "metadata": { "$ref": "#/components/schemas/EventNoMetadata" @@ -15263,14 +14781,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -15312,24 +14823,13 @@ "title": "Test Name" }, "source": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Source" + "$ref": "#/components/schemas/QuarantineSource" }, "original_quarantine": { "$ref": "#/components/schemas/TestQuarantineSnapshot" }, "health_status_at_event": { - "type": "string", - "enum": [ - "healthy", - "flaky", - "broken" - ], - "title": "Health Status At Event" + "$ref": "#/components/schemas/HealthStatus" }, "dequarantined_at": { "type": "string", @@ -15406,14 +14906,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "type": { "type": "string", @@ -15455,12 +14948,7 @@ "title": "Test Name" }, "source": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Source" + "$ref": "#/components/schemas/QuarantineSource" }, "reason": { "type": "string", @@ -15483,13 +14971,7 @@ "title": "Quarantined At" }, "health_status_at_event": { - "type": "string", - "enum": [ - "healthy", - "flaky", - "broken" - ], - "title": "Health Status At Event" + "$ref": "#/components/schemas/HealthStatus" }, "quarantined_by": { "anyOf": [ @@ -15520,6 +15002,55 @@ ], "title": "EventTestQuarantinedMetadata" }, + "EventType": { + "type": "string", + "enum": [ + "action.assign", + "action.backport", + "action.close", + "action.comment", + "action.copy", + "action.delete_head_branch", + "action.dequeue", + "action.dismiss_reviews", + "action.edit", + "action.github_actions", + "action.label", + "action.merge", + "action.post_check", + "action.queue.batch_bisection_end", + "action.queue.batch_bisection_start", + "action.queue.change", + "action.queue.checks.change", + "action.queue.checks_end", + "action.queue.checks_not_started", + "action.queue.checks_start", + "action.queue.conflict_deferred", + "action.queue.enter", + "action.queue.leave", + "action.queue.merged", + "action.rebase", + "action.refresh", + "action.request_reviews", + "action.review", + "action.squash", + "action.update", + "ci_insights.auto_quarantine.disabled", + "ci_insights.auto_quarantine.enabled", + "ci_insights.auto_quarantine.modified", + "ci_insights.job_retried", + "command.dequeue", + "command.queue", + "queue.pause.create", + "queue.pause.delete", + "queue.pause.update", + "scheduled_freeze.create", + "scheduled_freeze.delete", + "scheduled_freeze.update", + "test.dequarantined", + "test.quarantined" + ] + }, "EventUpdate": { "properties": { "id": { @@ -15562,14 +15093,7 @@ "title": "Base Ref" }, "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "pending", - "neutral" - ], - "title": "Outcome" + "$ref": "#/components/schemas/EventOutcome" }, "metadata": { "$ref": "#/components/schemas/EventNoMetadata" @@ -16329,6 +15853,22 @@ ], "title": "GitHubAccount" }, + "HealthConfidence": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "HealthStatus": { + "type": "string", + "enum": [ + "healthy", + "flaky", + "broken" + ] + }, "HealthTransition": { "properties": { "at": { @@ -16338,13 +15878,7 @@ "description": "Bucket boundary at which the test's health changed to `health_status`." }, "health_status": { - "type": "string", - "enum": [ - "healthy", - "flaky", - "broken" - ], - "title": "Health Status", + "$ref": "#/components/schemas/HealthStatus", "description": "Health the test held from `at` until the next transition." }, "exception_type": { @@ -17777,6 +17311,13 @@ ], "title": "PullRequestQueued" }, + "QuarantineSource": { + "type": "string", + "enum": [ + "manual", + "auto" + ] + }, "QuarantinedTest": { "properties": { "id": { @@ -17820,12 +17361,7 @@ "description": "UTC timestamp when the test was added to quarantine." }, "source": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Source", + "$ref": "#/components/schemas/QuarantineSource", "description": "How the test got quarantined: `manual` (user action) or `auto` (auto-quarantined).", "default": "manual" }, @@ -17957,20 +17493,7 @@ "description": "External integrator's direct URL (e.g. Jenkins build URL)" }, "state": { - "type": "string", - "enum": [ - "failure", - "error", - "cancelled", - "action_required", - "timed_out", - "pending", - "neutral", - "skipped", - "stale", - "success" - ], - "title": "State", + "$ref": "#/components/schemas/QueueCheckState", "description": "Check state" }, "avatar_url": { @@ -17996,6 +17519,21 @@ ], "title": "QueueCheck" }, + "QueueCheckState": { + "type": "string", + "enum": [ + "failure", + "error", + "cancelled", + "action_required", + "timed_out", + "pending", + "neutral", + "skipped", + "stale", + "success" + ] + }, "QueueChecksOutcome": { "properties": { "BASE_BRANCH_CHANGED": { @@ -19491,12 +19029,7 @@ "TestQuarantineSnapshot": { "properties": { "source": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Source" + "$ref": "#/components/schemas/QuarantineSource" }, "reason": { "type": "string", @@ -19569,23 +19102,11 @@ "TestSearchResultMetrics": { "properties": { "health_status": { - "type": "string", - "enum": [ - "healthy", - "flaky", - "broken" - ], - "title": "Health Status", + "$ref": "#/components/schemas/HealthStatus", "description": "Current health classification of the test: healthy, flaky, or broken." }, "health_confidence": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ], - "title": "Health Confidence", + "$ref": "#/components/schemas/HealthConfidence", "description": "Confidence in the health classification, derived from how many times the test has been rerun." }, "failed_executions_impact": { @@ -20021,20 +19542,7 @@ "title": "Details Url" }, "state": { - "type": "string", - "enum": [ - "failure", - "error", - "cancelled", - "action_required", - "timed_out", - "pending", - "neutral", - "skipped", - "stale", - "success" - ], - "title": "State" + "$ref": "#/components/schemas/QueueCheckState" }, "avatar_url": { "anyOf": [ @@ -20337,20 +19845,7 @@ "description": "The queue rule conditions evaluation report" }, "state": { - "type": "string", - "enum": [ - "failure", - "error", - "cancelled", - "action_required", - "timed_out", - "pending", - "neutral", - "skipped", - "stale", - "success" - ], - "title": "State", + "$ref": "#/components/schemas/QueueCheckState", "description": "The global state of the checks" } }, diff --git a/public/mergify-configuration-schema.json b/public/mergify-configuration-schema.json index d167d765bb..e1fbcabcd0 100644 --- a/public/mergify-configuration-schema.json +++ b/public/mergify-configuration-schema.json @@ -2552,19 +2552,11 @@ }, "queue-dequeue-reason": { "anyOf": [ - { - "const": "NONE", - "type": "string" - }, { "enum": [ + "NONE", "PR_MERGED", - "PR_MERGED_INTERMEDIATE_RESULTS_SKIPPED" - ], - "type": "string" - }, - { - "enum": [ + "PR_MERGED_INTERMEDIATE_RESULTS_SKIPPED", "PR_DEQUEUED", "PR_DEQUEUED_FROM_PARTITION", "PR_AHEAD_DEQUEUED", @@ -2616,49 +2608,129 @@ "!=", "~=" ], - "x-enum-descriptions": { - "BASE_BRANCH_CHANGED": "The pull request's base branch changed.", - "BASE_BRANCH_MISSING": "The pull request's base branch no longer exists.", - "BASE_REF_ALIGNMENT_TIMEOUT": "The pull request could not be retargeted onto the queue base branch, for example a stacked pull request GitHub never retargeted.", - "BATCH_AHEAD_FAILED": "A batch ahead in the queue failed, so this pull request's speculative checks are restarted. It stays in the queue.", - "BATCH_MAX_FAILURE_RESOLUTION_ATTEMPTS": "The maximum number of `batch_max_failure_resolution_attempts` was reached while isolating a batch failure.", - "BATCH_PULL_REQUEST_CLOSED": "The merge queue batch pull request was closed.", - "BATCH_SCOPES_CHANGED": "A pull request's scopes changed after it was embarked, so its batch was recomposed. It stays in the queue.", - "BRANCH_UPDATE_FAILED": "Updating the pull request's head branch failed.", - "CHECKS_FAILED": "The queue conditions cannot be satisfied because required checks failed.", - "CHECKS_RETRIED": "The pull request's failed checks are being retried. It stays in the queue.", - "CHECKS_TIMEOUT": "The configured `checks_timeout` was reached before the queue conditions were satisfied.", - "CONFIGURATION_CHANGED": "The Mergify configuration changed while the pull request was queued.", - "CONFLICT_WITH_BASE_BRANCH": "The pull request conflicts with its base branch.", - "CONFLICT_WITH_PULL_AHEAD": "The pull request conflicts with a pull request ahead of it in the queue.", - "DRAFT_PULL_REQUEST_CHANGED": "The merge queue draft pull request received commits that Mergify did not create.", - "DRAFT_PULL_REQUEST_CREATION_BRANCH_NOT_INDEXED": "Mergify could not create the merge queue draft pull request because GitHub had not yet indexed the new branch.", - "DRAFT_PULL_REQUEST_CREATION_FAILED": "Mergify could not create the merge queue draft pull request.", - "DROPPED_BY_BISECTION_ELIMINATION": "Batch bisection isolated the failure to other pull requests and dropped this one without testing it individually.", - "GITHUB_STACKED_PULL_REQUEST_MERGE_UNSUPPORTED": "The pull request is a GitHub stacked pull request, which GitHub does not support merging through its API.", - "INCOMPATIBILITY_WITH_BRANCH_PROTECTIONS": "The pull request cannot be checked because of an incompatibility between the queue and the repository's branch protections.", - "INTERMEDIATE_RESULTS_SKIPPED": "The pull request's intermediate speculative results were skipped. It stays in the queue.", - "MERGE_QUEUE_RESET": "The merge queue was reset.", - "NONE": "The pull request is not, and never was, in a merge queue.", - "PR_AHEAD_DEQUEUED": "A pull request ahead in the queue was removed, so this pull request's speculative checks are restarted. It stays in the queue.", - "PR_CHECKS_STOPPED_BECAUSE_MERGE_QUEUE_PAUSE": "The pull request's checks were interrupted because the merge queue is paused on the repository. It stays in the queue.", - "PR_DEQUEUED": "The pull request was removed from the queue, for example because its queue conditions no longer match.", - "PR_DEQUEUED_FROM_PARTITION": "The pull request stopped matching a partition's rules and was removed from that partition.", - "PR_MANUALLY_DEQUEUED": "The pull request was manually removed from the queue.", - "PR_MANUALLY_MERGED": "The pull request was merged manually, outside of the merge queue.", - "PR_MERGED": "The pull request was merged by the merge queue.", - "PR_MERGED_INTERMEDIATE_RESULTS_SKIPPED": "The pull request was merged based on the successful result of a later pull request in the same speculative batch, skipping its own intermediate checks.", - "PR_UNEXPECTEDLY_FAILED_TO_MERGE": "An unexpected error happened while merging the pull request.", - "PR_WITH_HIGHER_PRIORITY_QUEUED": "A higher-priority pull request was queued ahead of this one. It stays in the queue.", - "PULL_REQUEST_UPDATED": "The pull request was manually updated.", - "QUEUE_RULE_MISSING": "The queue rule referenced by the pull request no longer exists in the configuration.", - "SCHEDULED_FREEZE_STATUS_CHANGED": "A scheduled queue freeze changed the freeze status of the batch's pull requests, so the batch was split. It stays in the queue.", - "SCHEDULE_BLOCKED_AHEAD_YIELDED": "A pull request ahead that was waiting for its merge schedule yielded its position so ready pull requests behind it can merge first. It stays in the queue.", - "SPECULATIVE_CHECK_NUMBER_REDUCED": "The number of speculative checks was reduced. The pull request stays in the queue.", - "STACK_PREDECESSOR_DEQUEUED": "A predecessor pull request in the same stack was dequeued, so this one was removed as well.", - "UNPROCESSABLE_PULL_REQUEST": "Mergify cannot process the pull request, for example because it has too many check runs, comments, or files." - }, "x-has-data-type": true, + "x-mergify-enum": [ + { + "description": "The pull request is not, and never was, in a merge queue." + }, + { + "description": "The pull request was merged by the merge queue." + }, + { + "description": "The pull request was merged based on the successful result of a later pull request in the same speculative batch, skipping its own intermediate checks." + }, + { + "description": "The pull request was removed from the queue, for example because its queue conditions no longer match." + }, + { + "description": "The pull request stopped matching a partition's rules and was removed from that partition." + }, + { + "description": "A pull request ahead in the queue was removed, so this pull request's speculative checks are restarted. It stays in the queue." + }, + { + "description": "A batch ahead in the queue failed, so this pull request's speculative checks are restarted. It stays in the queue." + }, + { + "description": "A higher-priority pull request was queued ahead of this one. It stays in the queue." + }, + { + "description": "A scheduled queue freeze changed the freeze status of the batch's pull requests, so the batch was split. It stays in the queue." + }, + { + "description": "The number of speculative checks was reduced. The pull request stays in the queue." + }, + { + "description": "The configured `checks_timeout` was reached before the queue conditions were satisfied." + }, + { + "description": "The queue conditions cannot be satisfied because required checks failed." + }, + { + "description": "Batch bisection isolated the failure to other pull requests and dropped this one without testing it individually." + }, + { + "description": "The queue rule referenced by the pull request no longer exists in the configuration." + }, + { + "description": "The pull request's base branch no longer exists." + }, + { + "description": "The pull request's base branch changed." + }, + { + "description": "An unexpected error happened while merging the pull request." + }, + { + "description": "The maximum number of `batch_max_failure_resolution_attempts` was reached while isolating a batch failure." + }, + { + "description": "The pull request's checks were interrupted because the merge queue is paused on the repository. It stays in the queue." + }, + { + "description": "The pull request conflicts with its base branch." + }, + { + "description": "The pull request conflicts with a pull request ahead of it in the queue." + }, + { + "description": "Updating the pull request's head branch failed." + }, + { + "description": "The merge queue draft pull request received commits that Mergify did not create." + }, + { + "description": "The merge queue batch pull request was closed." + }, + { + "description": "The pull request was manually updated." + }, + { + "description": "The merge queue was reset." + }, + { + "description": "The pull request cannot be checked because of an incompatibility between the queue and the repository's branch protections." + }, + { + "description": "The pull request was merged manually, outside of the merge queue." + }, + { + "description": "Mergify could not create the merge queue draft pull request." + }, + { + "description": "Mergify could not create the merge queue draft pull request because GitHub had not yet indexed the new branch." + }, + { + "description": "The Mergify configuration changed while the pull request was queued." + }, + { + "description": "Mergify cannot process the pull request, for example because it has too many check runs, comments, or files." + }, + { + "description": "The pull request was manually removed from the queue." + }, + { + "description": "A predecessor pull request in the same stack was dequeued, so this one was removed as well." + }, + { + "description": "The pull request's intermediate speculative results were skipped. It stays in the queue." + }, + { + "description": "The pull request's failed checks are being retried. It stays in the queue." + }, + { + "description": "A pull request ahead that was waiting for its merge schedule yielded its position so ready pull requests behind it can merge first. It stays in the queue." + }, + { + "description": "The pull request could not be retargeted onto the queue base branch, for example a stacked pull request GitHub never retargeted." + }, + { + "description": "The pull request is a GitHub stacked pull request, which GitHub does not support merging through its API." + }, + { + "description": "A pull request's scopes changed after it was embarked, so its batch was recomposed. It stays in the queue." + } + ], "x-modifiers": [ { "type": "negate" diff --git a/src/components/Tables/BatchStatusCodes.tsx b/src/components/Tables/BatchStatusCodes.tsx new file mode 100644 index 0000000000..e9b1657f01 --- /dev/null +++ b/src/components/Tables/BatchStatusCodes.tsx @@ -0,0 +1,50 @@ +import apiSchema from '../../../public/api-schemas.json'; + +import { renderMarkdown } from './utils'; + +// A batch's `status.code` in the merge queue API. The engine is the single +// source of truth: the codes come from the property's enum, and a one-line +// description for each is published alongside it under `x-enum-descriptions` +// (keyed by the raw code), so this table can't drift from what the API +// returns. Same convention as the queue dequeue reason table, sourced from the +// OpenAPI spec rather than the configuration schema because a batch status is +// something the API reports, never something you write in `.mergify.yml`. +// +// The lookup is optional-chained through a loose cast so a future schema +// reshape (renamed model or property) degrades to an empty table rather than +// throwing at module load and crashing the Astro build. +const codeProp: unknown = ( + apiSchema as { + components?: { schemas?: Record }> }; + } +).components?.schemas?.BatchStatus?.properties?.code; + +export default function BatchStatusCodes() { + const node = codeProp as + | { enum?: string[]; 'x-enum-descriptions'?: Record } + | undefined; + const descriptions = node?.['x-enum-descriptions'] ?? {}; + + return ( +
+ + + + + + + + + {(node?.enum ?? []).map((code) => ( + + + + ))} + +
StatusDescription
+ {code} + +
+
+ ); +} diff --git a/src/components/Tables/utils.test.ts b/src/components/Tables/utils.test.ts new file mode 100644 index 0000000000..45868ac543 --- /dev/null +++ b/src/components/Tables/utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { renderMarkdown } from './utils'; + +// `renderMarkdown` output is injected with `dangerouslySetInnerHTML` by every +// schema-driven table, so what it lets through is a security property, not a +// formatting detail. These lock it. +describe('renderMarkdown', () => { + it('renders the markdown the schema descriptions actually use', () => { + const html = renderMarkdown('A [real link](https://example.com) and `code`.'); + expect(html).toContain('real link'); + expect(html).toContain('code'); + }); + + it('keeps relative links, anchors and mailto', () => { + expect(renderMarkdown('[a](/merge-queue/batches)')).toContain('href="/merge-queue/batches"'); + expect(renderMarkdown('[a](#batch-status)')).toContain('href="#batch-status"'); + expect(renderMarkdown('[a](mailto:x@example.com)')).toContain('href="mailto:x@example.com"'); + }); + + it('strips javascript: and data: URLs rather than emitting a live link', () => { + expect(renderMarkdown('[click](javascript:alert(1))')).not.toContain('javascript:'); + expect(renderMarkdown('[click](JaVaScRiPt:alert(1))')).not.toContain('alert(1)'); + expect(renderMarkdown('![x](data:text/html;base64,PHNjcmlwdD4=)')).not.toContain( + 'data:text/html' + ); + }); + + it('drops raw HTML, including event handlers and script tags', () => { + expect(renderMarkdown('')).not.toContain('onerror'); + expect(renderMarkdown('')).not.toContain(' +## Batch Status + +This describes what a [batch](/merge-queue/batches) is currently doing in the +merge queue. It is reported as the `status.code` field of each batch returned by +the [merge queue status API](/api/merge-queue), and the dashboard shows it on +each batch. + +A status describes the batch as a whole, not an individual pull request: a batch +that is running its checks reports `running` for every pull request it carries. + +The following statuses can be reported: + + + ## Report Mode Report modes allow you to choose the type of report you want for your actions. diff --git a/src/content/docs/merge-queue/batches.mdx b/src/content/docs/merge-queue/batches.mdx index 75df5e21cf..cfa087d6bb 100644 --- a/src/content/docs/merge-queue/batches.mdx +++ b/src/content/docs/merge-queue/batches.mdx @@ -379,6 +379,11 @@ Note that this system is completely automatic and there is no need to intervene. The number of maximum splits can be controlled by [`batch_max_failure_resolution_attempts`](/configuration/file-format#queue-rules). +While this runs, the batches involved report a +[batch status](/configuration/data-types#batch-status) such as `bisecting`, +`waiting_for_previous_batches`, or `waiting_for_requeue`, so you can follow the +resolution from [the dashboard or the CLI](/merge-queue/monitoring). + :::tip Each split carries metadata about the batches it came from. You can use it to [re-run only the tests that failed in the parent diff --git a/src/content/docs/merge-queue/monitoring.mdx b/src/content/docs/merge-queue/monitoring.mdx index 67e3600084..05a3f02312 100644 --- a/src/content/docs/merge-queue/monitoring.mdx +++ b/src/content/docs/merge-queue/monitoring.mdx @@ -126,6 +126,10 @@ This displays: - **Waiting PRs**: queued pull requests with priority, queue time, and estimated merge time +Each batch reports a [batch status](/configuration/data-types#batch-status) +describing what it is doing, such as running its checks or waiting for a +schedule. + To filter results to a specific branch: ```bash diff --git a/src/util/dataType.test.ts b/src/util/dataType.test.ts index c68bf2172d..6fc157206e 100644 --- a/src/util/dataType.test.ts +++ b/src/util/dataType.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import apiSchema from '../../public/api-schemas.json'; import configSchema from '../../public/mergify-configuration-schema.json'; import { collectDataTypeTitles, getDataTypeHref, isDataType } from './dataType'; import { dataTypesHeadingAnchors, missingDataTypeAnchors } from './dataTypeAnchors'; @@ -57,6 +58,8 @@ describe('data-types page anchors', () => { 'priority', 'report-mode', 'schedule', + // marked in the OpenAPI spec rather than the configuration schema + 'batch-status', // anchors hardcoded in ConfigOptions.tsx link maps 'commit', 'commit-author', @@ -83,4 +86,12 @@ describe('data-types page anchors', () => { it('covers every documented data type flagged in the config schema', () => { expect(missingDataTypeAnchors(configSchema)).toEqual([]); }); + + // The same convention, for types the engine marks in the OpenAPI spec + // instead — a data type the API reports but you never write in + // `.mergify.yml`. Both schemas arrive by the same bot sync, so both are + // gated in integrations/validate-data-type-anchors.ts. + it('covers every documented data type flagged in the API schema', () => { + expect(missingDataTypeAnchors(apiSchema)).toEqual([]); + }); }); From 3cf3096eefe86ebe430e7775832e3470c497095a Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Mon, 3 Aug 2026 17:55:30 +0200 Subject: [PATCH 2/4] refactor(docs): read documented enum values through one shared reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine is changing how it publishes per-value documentation for a schema's choice set, moving from an `x-enum-descriptions` map to a positional `x-mergify-enum` array that also carries a display title and a deprecation flag. Schema syncs land as direct pushes to main, so the docs repo cannot assume both sides move together. Introduce `readEnumChoices`, which tolerates every shape a synced schema can be in during that migration: values as a flat `enum`, a `const`, or the `anyOf` of branches a composed `Literal` produces, with the node — or any single branch of it — either inline or behind a `$ref`. Hoisting repeated enums into shared components is what removes the duplicated blocks from the published schema, and a reader that followed only the property node would silently render an empty table once that happens. Three things the shape change makes possible that the map could not, and which are handled explicitly because each fails silently: - Metadata can sit on the referring node as a `$ref` sibling rather than on the target, which is how pydantic publishes an annotation for a hoisted type. Both are consulted, nearest first. - A positional array can fall out of alignment with `enum` in a way a keyed map never could. A length mismatch is treated as unusable rather than shifting every description onto the wrong value. - The two shapes can coexist on one node mid-migration, so they are merged rather than treated as alternatives. Both schema-driven tables now go through it, so the engine can migrate one enum at a time without a lockstep docs change. Ref resolution uses the `jsonpointer` the sibling readers already use: hand-decoding each segment raises `URIError` on a pointer containing a stray `%`, which would break the never-throws contract this reader depends on to degrade instead of failing the build. Tests pin each silent-degradation mode, and two of them read the real synced schemas rather than fixtures, so a sync that changes or reverts a shape fails here instead of publishing a table of blank cells. Part of MRGFY-8330 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ie539de06bf3391eb4390333810cda6e8f131b1e6 --- src/components/Tables/BatchStatusCodes.tsx | 31 ++- src/components/Tables/QueueDequeueReasons.tsx | 51 ++--- src/util/enumChoices.test.ts | 199 ++++++++++++++++++ src/util/enumChoices.ts | 199 ++++++++++++++++++ 4 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 src/util/enumChoices.test.ts create mode 100644 src/util/enumChoices.ts diff --git a/src/components/Tables/BatchStatusCodes.tsx b/src/components/Tables/BatchStatusCodes.tsx index e9b1657f01..72b4b861d8 100644 --- a/src/components/Tables/BatchStatusCodes.tsx +++ b/src/components/Tables/BatchStatusCodes.tsx @@ -1,18 +1,18 @@ import apiSchema from '../../../public/api-schemas.json'; +import { readEnumChoices } from '../../util/enumChoices'; import { renderMarkdown } from './utils'; // A batch's `status.code` in the merge queue API. The engine is the single -// source of truth: the codes come from the property's enum, and a one-line -// description for each is published alongside it under `x-enum-descriptions` -// (keyed by the raw code), so this table can't drift from what the API -// returns. Same convention as the queue dequeue reason table, sourced from the -// OpenAPI spec rather than the configuration schema because a batch status is -// something the API reports, never something you write in `.mergify.yml`. +// source of truth: the codes and their one-line descriptions are published +// alongside the schema, so this table can't drift from what the API returns. +// Sourced from the OpenAPI spec rather than the configuration schema because a +// batch status is something the API reports, never something you write in +// `.mergify.yml`. // -// The lookup is optional-chained through a loose cast so a future schema -// reshape (renamed model or property) degrades to an empty table rather than -// throwing at module load and crashing the Astro build. +// `readEnumChoices` resolves a `$ref` and understands every documentation +// shape the engine currently publishes, so this keeps rendering whether the +// property is inline or hoisted into a shared component. const codeProp: unknown = ( apiSchema as { components?: { schemas?: Record }> }; @@ -20,10 +20,7 @@ const codeProp: unknown = ( ).components?.schemas?.BatchStatus?.properties?.code; export default function BatchStatusCodes() { - const node = codeProp as - | { enum?: string[]; 'x-enum-descriptions'?: Record } - | undefined; - const descriptions = node?.['x-enum-descriptions'] ?? {}; + const choices = readEnumChoices(apiSchema, codeProp); return (
@@ -35,12 +32,12 @@ export default function BatchStatusCodes() { - {(node?.enum ?? []).map((code) => ( - + {choices.map((choice) => ( + - {code} + {choice.value} - + ))} diff --git a/src/components/Tables/QueueDequeueReasons.tsx b/src/components/Tables/QueueDequeueReasons.tsx index 0a6045f3fa..ca663b475b 100644 --- a/src/components/Tables/QueueDequeueReasons.tsx +++ b/src/components/Tables/QueueDequeueReasons.tsx @@ -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 @@ -22,28 +22,6 @@ 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 { @@ -51,10 +29,7 @@ function toKebab(code: string): string { } export default function QueueDequeueReasons() { - const descriptions = - (reasonProp as { 'x-enum-descriptions'?: Record } | undefined)?.[ - 'x-enum-descriptions' - ] ?? {}; + const choices = readEnumChoices(configSchema, reasonProp); return (
@@ -66,12 +41,12 @@ export default function QueueDequeueReasons() { - {enumValues(reasonProp).map((code) => ( - + {choices.map((choice) => ( + - {toKebab(code)} + {toKebab(choice.value)} - + ))} diff --git a/src/util/enumChoices.test.ts b/src/util/enumChoices.test.ts new file mode 100644 index 0000000000..6df7862747 --- /dev/null +++ b/src/util/enumChoices.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import apiSchema from '../../public/api-schemas.json'; +import configSchema from '../../public/mergify-configuration-schema.json'; +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('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'] }); + }); +}); + +// The cases above are synthetic. These bind the reader to the two schemas the +// site actually renders, so a sync that changes shape — or reverts one — fails +// here instead of silently publishing a table of blank cells. +function at(root: unknown, ...path: string[]): unknown { + let current = root; + for (const key of path) { + current = (current as Record | undefined)?.[key]; + } + return current; +} + +describe('the real synced schemas', () => { + it('documents every batch status code in the API schema', () => { + const code = at(apiSchema, 'components', 'schemas', 'BatchStatus', 'properties', 'code'); + const choices = readEnumChoices(apiSchema, code); + expect(choices.length).toBeGreaterThan(0); + expect(choices.filter((c) => c.description.trim() === '')).toEqual([]); + }); + + it('documents every dequeue reason in the configuration schema', () => { + const reason = at( + configSchema, + '$defs', + 'PullRequestAttributes', + 'properties', + 'queue-dequeue-reason' + ); + const choices = readEnumChoices(configSchema, reason); + expect(choices.length).toBeGreaterThan(0); + expect(choices.filter((c) => c.description.trim() === '')).toEqual([]); + }); +}); diff --git a/src/util/enumChoices.ts b/src/util/enumChoices.ts new file mode 100644 index 0000000000..43deee12c0 --- /dev/null +++ b/src/util/enumChoices.ts @@ -0,0 +1,199 @@ +import jsonpointer from 'jsonpointer'; + +// Reading the documented values of a schema "choice set" (an enum and its +// per-value documentation). +// +// The engine is migrating how it publishes that documentation, so this reader +// deliberately understands every shape a synced schema can currently be in. +// Schema syncs land as direct pushes to main, so the docs repo cannot assume +// the engine side has migrated yet — and both shapes may coexist across the +// configuration schema and the OpenAPI spec for a while. +// +// Metadata shapes, which are *merged* rather than treated as alternatives so a +// half-migrated node keeps rendering every description it publishes: +// - `x-mergify-enum`: a positional array of `{title, description, deprecated}` +// aligned with `enum`. The target shape. +// - `x-enum-descriptions`: a map of raw value -> description sentence. The +// previous shape; carries no per-value title or deprecation. +// +// The values come from `enum`, from a `const`, or from an `anyOf`/`oneOf` of +// such branches — the shape a composed `Literal` (`Literal["A"] | OtherT`) +// produces. Branches are `$ref`-resolved too: hoisting a repeated enum into a +// shared component leaves `{anyOf: [{$ref: ...}, {type: "null"}]}`, and a +// reader that only resolved the top node would silently render nothing. + +export interface EnumChoice { + value: string; + /** Display label, when the schema publishes one. */ + title?: string; + /** May be empty: a value can be published before it is documented. */ + description: string; + deprecated: boolean; +} + +interface SchemaNode { + $ref?: unknown; + enum?: unknown; + const?: unknown; + anyOf?: unknown; + oneOf?: unknown; + 'x-mergify-enum'?: unknown; + 'x-enum-descriptions'?: unknown; +} + +const MAX_REF_HOPS = 10; + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Follow a `$ref` chain from `node` within `root`, returning the node reached. + * Stops on a dangling, malformed or cyclic ref and returns what it has, so a + * schema reshape degrades to an empty table rather than throwing during the + * Astro build. + * + * Uses the same `jsonpointer` the other schema readers use (ConfigOptions, + * schemaToMarkdown) rather than splitting and decoding by hand: a pointer + * containing a stray `%` makes `decodeURIComponent` raise, which would break + * the never-throws contract this function advertises. + */ +export function resolveRef(root: unknown, node: unknown): unknown { + let current = node; + for (let hop = 0; hop < MAX_REF_HOPS; hop++) { + if (!isObject(current)) { + return current; + } + const ref = (current as SchemaNode).$ref; + if (typeof ref !== 'string' || !ref.startsWith('#/')) { + return current; + } + let target: unknown; + try { + target = jsonpointer.get(root as object, ref.slice(1)); + } catch { + return current; + } + if (target === undefined || target === null) { + return current; + } + current = target; + } + return current; +} + +/** The raw values a resolved node accepts directly (no branch recursion). */ +function ownValues(node: SchemaNode): string[] { + if (Array.isArray(node.enum)) { + return node.enum.map(String); + } + if (typeof node.const === 'string') { + return [node.const]; + } + return []; +} + +/** + * Per-value metadata published on `node` or on any node its `$ref` chain + * passes through. + * + * Pydantic publishes an annotation inline for an inlined type but as a + * *sibling of `$ref`* for a type hoisted into `$defs`, so both the raw node + * and the resolved target have to be consulted — the same walk + * `ConfigOptions.getDataTypeLink` performs for `x-has-data-type`. Nearest wins: + * a sibling on the referring node overrides the shared component. + */ +function collectMetadata(root: unknown, node: unknown): SchemaNode { + const merged: SchemaNode = {}; + let current = node; + for (let hop = 0; hop < MAX_REF_HOPS && isObject(current); hop++) { + const schema = current as SchemaNode; + if (merged['x-mergify-enum'] === undefined && Array.isArray(schema['x-mergify-enum'])) { + merged['x-mergify-enum'] = schema['x-mergify-enum']; + } + if (merged['x-enum-descriptions'] === undefined && isObject(schema['x-enum-descriptions'])) { + merged['x-enum-descriptions'] = schema['x-enum-descriptions']; + } + const ref = schema.$ref; + if (typeof ref !== 'string' || !ref.startsWith('#/')) { + break; + } + let next: unknown; + try { + next = jsonpointer.get(root as object, ref.slice(1)); + } catch { + break; + } + if (next === undefined || next === null) { + break; + } + current = next; + } + return merged; +} + +/** + * The documented choices of `node` (which may be a `$ref` into `root`). + * Returns [] for anything that is not a choice set. + * + * Branch unions are flattened by concatenation, and each branch supplies the + * metadata for its own values — so a hoisted enum keeps its descriptions + * whether the `$ref` sits at the top of the node or inside one of its + * branches. + */ +export function readEnumChoices(root: unknown, node: unknown): EnumChoice[] { + return read(root, node, {}); +} + +function read(root: unknown, node: unknown, inherited: SchemaNode): EnumChoice[] { + const resolved = resolveRef(root, node); + if (!isObject(resolved)) { + return []; + } + + // Metadata on this node wins over anything inherited from an enclosing + // union. The engine publishes the annotations at the top of an optional + // node while the values sit in its non-null branch, so a branch with no + // metadata of its own must still see the parent's. + const own = collectMetadata(root, node); + const meta: SchemaNode = { + 'x-mergify-enum': own['x-mergify-enum'] ?? inherited['x-mergify-enum'], + 'x-enum-descriptions': own['x-enum-descriptions'] ?? inherited['x-enum-descriptions'], + }; + + const values = ownValues(resolved as SchemaNode); + if (values.length === 0) { + const branches = (resolved as SchemaNode).anyOf ?? (resolved as SchemaNode).oneOf; + if (Array.isArray(branches)) { + return branches.flatMap((branch) => read(root, branch, meta)); + } + return []; + } + + const entries = meta['x-mergify-enum']; + // `x-mergify-enum` is positional, so a length mismatch means every entry + // after the first divergence describes the wrong value. Publishing 40 subtly + // wrong sentences is worse than publishing none, and the misalignment is + // otherwise undetectable — the map shape this replaced could not drift. + const aligned = Array.isArray(entries) && entries.length === values.length ? entries : undefined; + + const legacy = meta['x-enum-descriptions']; + const descriptions = isObject(legacy) ? legacy : {}; + + return values.map((value, index) => { + const entry = aligned?.[index]; + const positional = isObject(entry) ? entry : {}; + const fallback = descriptions[value]; + return { + value, + title: typeof positional.title === 'string' ? positional.title : undefined, + description: + typeof positional.description === 'string' && positional.description !== '' + ? positional.description + : typeof fallback === 'string' + ? fallback + : '', + deprecated: positional.deprecated === true, + }; + }); +} From 80eb05a52163b98c02dc347cd3394287b19d2778 Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Tue, 4 Aug 2026 09:57:43 +0200 Subject: [PATCH 3/4] fix(docs): show accepted values for API parameters typed by a shared enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicating repeated enums in the OpenAPI spec hoists them into shared components, so a query parameter's schema becomes a `$ref` instead of an inline `enum`. `getTypeLabel` returned the component name for any `$ref`, which is right for an object — the schema tree below expands it — but wrong for an enum: parameters are rendered from that label alone, with no tree underneath and no `enum` among their constraints. Fifteen public parameters were affected. `/api/activity-log`'s `event_type` listed all 44 accepted values and would have shown `EventType[]`; `source` on the quarantines endpoint would have shown `QuarantineSource` instead of `"manual" | "auto"`. The components themselves are not rendered anywhere on the site, so the values would have been published nowhere at all. Resolve the reference when it targets an enum and keep the name otherwise. Part of MRGFY-8330 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ic546133c44ed82ddc0f57c2269b64f6725247e27 --- src/components/ApiReference/openapi.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/ApiReference/openapi.ts b/src/components/ApiReference/openapi.ts index 900f7aef23..b403026e4e 100644 --- a/src/components/ApiReference/openapi.ts +++ b/src/components/ApiReference/openapi.ts @@ -250,7 +250,20 @@ function escapeHtml(text: string): string { export function getTypeLabel(schema: SchemaObject | undefined, root: OpenAPISpec): string { if (!schema) return 'any'; - if (schema.$ref) return getRefName(schema) ?? 'object'; + if (schema.$ref) { + // A referenced enum still has to show its values here. Parameters are + // rendered from this label alone (Endpoint.astro) with no schema tree + // underneath and no `enum` in their constraints, so falling back to the + // component name would leave a query parameter documented as + // `EventType[]` with its accepted values published nowhere on the site. + // Object references keep their name — expanding those is what the schema + // tree is for. + const resolved = resolveRef(schema, root); + if (Array.isArray(resolved?.enum)) { + return resolved.enum.map((v) => JSON.stringify(v)).join(' | '); + } + return getRefName(schema) ?? 'object'; + } if (schema.anyOf) { const nonNull = schema.anyOf.filter((s) => s.type !== 'null'); From b7653a22ede53c17581f8a4fdced1ce7a8fc75a5 Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Tue, 4 Aug 2026 09:57:43 +0200 Subject: [PATCH 4/4] test(docs): make the renderMarkdown security tests actually load-bearing Three of the four assertions passed whether or not `rehype-sanitize` was in the pipeline: `remark-rehype` runs without `allowDangerousHtml`, so raw HTML is discarded before either `rehype-raw` or the sanitizer sees it, and both raw-HTML cases were `not.toContain` against an empty string. A refactor dropping the sanitizer would have kept them green while re-opening the hole they claim to guard. Separate the two guarantees so each is pinned by tests that fail when it breaks: URL-protocol filtering is the sanitizer's own contribution, while raw HTML never reaching the output comes from the markdown pipeline and only becomes the sanitizer's job if a caller ever enables `allowDangerousHtml`. Part of MRGFY-8330 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I63b59daf0427a2d2bfad0fc008ff14c3730b9553 --- src/components/Tables/utils.test.ts | 49 ++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/components/Tables/utils.test.ts b/src/components/Tables/utils.test.ts index 45868ac543..27059bf8c6 100644 --- a/src/components/Tables/utils.test.ts +++ b/src/components/Tables/utils.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'; import { renderMarkdown } from './utils'; // `renderMarkdown` output is injected with `dangerouslySetInnerHTML` by every -// schema-driven table, so what it lets through is a security property, not a -// formatting detail. These lock it. +// schema-driven table, so what it lets through is a security property. +// +// Two different layers provide that, and it is worth keeping them apart: raw +// HTML never survives because `remark-rehype` runs without +// `allowDangerousHtml`, which is true with or without the sanitizer. Only the +// URL-protocol filtering below actually exercises `rehype-sanitize` — those +// are the assertions that fail if it is removed. describe('renderMarkdown', () => { it('renders the markdown the schema descriptions actually use', () => { const html = renderMarkdown('A [real link](https://example.com) and `code`.'); @@ -17,16 +22,38 @@ describe('renderMarkdown', () => { expect(renderMarkdown('[a](mailto:x@example.com)')).toContain('href="mailto:x@example.com"'); }); - it('strips javascript: and data: URLs rather than emitting a live link', () => { - expect(renderMarkdown('[click](javascript:alert(1))')).not.toContain('javascript:'); - expect(renderMarkdown('[click](JaVaScRiPt:alert(1))')).not.toContain('alert(1)'); - expect(renderMarkdown('![x](data:text/html;base64,PHNjcmlwdD4=)')).not.toContain( - 'data:text/html' - ); + // These are the sanitizer's own guarantee: `remark-rehype` emits an for + // any link target, whatever its protocol, so without `rehype-sanitize` each + // of these renders as a live link. + describe('URL protocol filtering (rehype-sanitize)', () => { + it('strips a javascript: link rather than emitting a live one', () => { + const html = renderMarkdown('[click](javascript:alert(1))'); + expect(html).toContain('click'); + expect(html).not.toContain('javascript:'); + }); + + it('strips a case-obfuscated javascript: link', () => { + expect(renderMarkdown('[click](JaVaScRiPt:alert(1))')).not.toContain('alert(1)'); + }); + + it('strips a data: URL on an image', () => { + expect(renderMarkdown('![x](data:text/html;base64,PHNjcmlwdD4=)')).not.toContain( + 'data:text/html' + ); + }); }); - it('drops raw HTML, including event handlers and script tags', () => { - expect(renderMarkdown('')).not.toContain('onerror'); - expect(renderMarkdown('')).not.toContain(' { + it('drops an event handler', () => { + expect(renderMarkdown('')).not.toContain('onerror'); + }); + + it('drops a script tag', () => { + expect(renderMarkdown('')).not.toContain('