Skip to content
Draft
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
30 changes: 22 additions & 8 deletions integrations/validate-data-type-anchors.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).`
);
}
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion public/api-schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -9497,7 +9497,22 @@
"waiting_for_checks_slot",
"frozen"
],
"title": "Code"
"title": "Batch Status",
"x-enum-descriptions": {
"bisecting": "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.",
"failed": "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.",
"frozen": "The batch's checks passed, but merges are frozen. It merges once the freeze is lifted.",
"merged": "The batch was merged.",
"preparing": "Mergify is setting the batch up so it can be tested.",
"running": "The batch is set up and its CI checks are running.",
"waiting_for_batch": "Mergify is waiting for more pull requests to fill the batch, or for the maximum wait time to elapse, before starting it.",
"waiting_for_checks_slot": "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.",
"waiting_for_merge": "The batch's checks passed. It merges once the batches ahead of it have merged.",
"waiting_for_previous_batches": "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.",
"waiting_for_requeue": "A batch ahead in the queue failed, so this batch is requeued and retested against the updated queue.",
"waiting_schedule": "The batch's merge conditions are met, but a schedule condition is not. It continues once the schedule allows it."
},
"x-has-data-type": true
},
"batch_filled_slots": {
"anyOf": [
Expand Down
50 changes: 50 additions & 0 deletions src/components/Tables/BatchStatusCodes.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { properties?: Record<string, unknown> }> };
}
).components?.schemas?.BatchStatus?.properties?.code;

export default function BatchStatusCodes() {
const node = codeProp as
| { enum?: string[]; 'x-enum-descriptions'?: Record<string, string> }
| undefined;
const descriptions = node?.['x-enum-descriptions'] ?? {};

return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Status</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{(node?.enum ?? []).map((code) => (
<tr key={code}>
<td>
<code>{code}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: renderMarkdown(descriptions[code] ?? '') }} />
</tr>
Comment thread
kozlek marked this conversation as resolved.
))}
</tbody>
</table>
</div>
);
}
32 changes: 32 additions & 0 deletions src/components/Tables/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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('<a href="https://example.com">real link</a>');
expect(html).toContain('<code>code</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('<img src=x onerror="alert(1)">')).not.toContain('onerror');
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script');
});
});
20 changes: 19 additions & 1 deletion src/components/Tables/utils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import rehypeFormat from 'rehype-format';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';

/**
* Render a short markdown string (a schema description, a template variable
* blurb) to HTML for injection via `dangerouslySetInnerHTML`.
*
* Sanitized on the way out. The input is always first-party — descriptions
* synced from the engine's schemas — so this is defence in depth rather than a
* response to untrusted input, but the output goes straight into the DOM and
* several tables share this helper, so the guarantee belongs here and not in
* each caller. Without it, `[x](javascript:...)` in a description would render
* as a live `javascript:` link: `remark-rehype` does not filter URL protocols,
* and being first-party is a property of today's callers, not of this function.
*
* `rehype-raw` is kept ahead of the sanitizer so that if a caller ever enables
* `allowDangerousHtml`, the embedded HTML is parsed and then sanitized rather
* than passed through as an opaque raw node.
*/
export function renderMarkdown(markdown: string) {
const file = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeFormat)
.use(rehypeStringify)
.use(rehypeRaw)
.processSync(markdown);

return file.toString();
Expand Down
15 changes: 15 additions & 0 deletions src/content/docs/configuration/data-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ title: Configuration Data Types
description: The different data types you can find in Mergify configuration file
---

import BatchStatusCodes from '../../../components/Tables/BatchStatusCodes';
import OptionsTable from '../../../components/Tables/OptionsTable';
import QueueDequeueReasons from '../../../components/Tables/QueueDequeueReasons';
import TemplateVariablesTable from '../../../components/Tables/TemplateVariablesTable';
Expand Down Expand Up @@ -448,6 +449,20 @@ The following reasons can be reported:

<QueueDequeueReasons />

## 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:

<BatchStatusCodes />

## Report Mode

Report modes allow you to choose the type of report you want for your actions.
Expand Down
5 changes: 5 additions & 0 deletions src/content/docs/merge-queue/batches.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/content/docs/merge-queue/monitoring.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/util/dataType.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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([]);
});
});