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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/components/Tables/PullRequestAttributes.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { getAttributeSource } from '../../util/attributeMetadata';
import configSchema from '../../util/sanitizedConfigSchema';
import { getValueType } from './ConfigOptions';

import { renderMarkdown } from './utils';

type Attributes = typeof configSchema.$defs.PullRequestAttributes.properties;

// The engine annotates each condition attribute with its authoritative operators
// and modifiers (`x-allowed-operators` / `x-modifiers`). The generated TypeScript
// types drop `x-` keys, so they are read from the imported JSON via this shape.
Expand All @@ -15,7 +18,10 @@ interface ConditionMeta {
}

interface Props {
staticAttributes: typeof configSchema.$defs.PullRequestAttributes.properties;
staticAttributes?: Attributes;
// When set, render only attributes whose metadata source matches (e.g. "github").
// When unset, render only attributes that have no source (config-writable ones).
source?: string;
}

function renderOperators(operators: string[]) {
Expand Down Expand Up @@ -60,8 +66,17 @@ function renderModifiers(modifiers: ConditionMeta['x-modifiers']) {
return parts.flatMap((part, index) => (index === 0 ? [part] : [' ', part]));
}

export default function PullRequestAttributes({ staticAttributes }: Props) {
const attributes = staticAttributes ?? configSchema.$defs.PullRequestAttributes.properties;
export default function PullRequestAttributes({ staticAttributes, source }: Props) {
// The search-highlight path passes a pre-filtered subset; render it as-is.
// Otherwise partition the canonical list by metadata source.
const attributes =
staticAttributes ??
(Object.fromEntries(
Object.entries(configSchema.$defs.PullRequestAttributes.properties).filter(([, value]) => {
const attributeSource = getAttributeSource(value);
return source ? attributeSource === source : attributeSource === undefined;
})
) as Attributes);

const entries = Object.entries(attributes).sort(([keyA], [keyB]) => (keyA > keyB ? 1 : -1));

Expand Down
15 changes: 15 additions & 0 deletions src/content/docs/configuration/conditions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,23 @@ Boolean attributes are used on their own or negated with `-`:

## Attributes List

Conditions are evaluated against the following pull request attributes, split
into Mergify attributes and a read-only set loaded from GitHub.

### Mergify Attributes

<PullRequestAttributesTable />

### GitHub Rulesets and Branch Protection Attributes

Mergify loads these attributes from the GitHub rulesets and branch protection
rules active on the pull request's base branch. They are read-only: Mergify
populates their values from GitHub, so you cannot set them in your
configuration. Use them in conditions to gate on the same review and protection
requirements that GitHub enforces.

<PullRequestAttributesTable source="github" />

## Operators List

<table>
Expand Down
24 changes: 24 additions & 0 deletions src/util/attributeMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import { getAttributeSource } from './attributeMetadata';

describe('getAttributeSource', () => {
test('returns the source when present', () => {
expect(getAttributeSource({ 'x-mergify-attribute-metadata': { source: 'github' } })).toBe(
'github'
);
});

test('returns undefined when the attribute has no metadata', () => {
expect(getAttributeSource({ type: 'boolean' })).toBeUndefined();
});

test('returns undefined for a missing or malformed source', () => {
expect(getAttributeSource({ 'x-mergify-attribute-metadata': {} })).toBeUndefined();
expect(getAttributeSource({ 'x-mergify-attribute-metadata': { source: 42 } })).toBeUndefined();
});

test('returns undefined for non-object input', () => {
expect(getAttributeSource(null)).toBeUndefined();
expect(getAttributeSource('github')).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions src/util/attributeMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Pull-request attribute definitions publish provenance under this custom
// JSON-schema key.
const ATTRIBUTE_METADATA_KEY = 'x-mergify-attribute-metadata';

/**
* Return the `source` recorded on a pull-request attribute definition, or
* `undefined` when the attribute carries no metadata. A `github` source marks
* attributes that Mergify loads from GitHub rulesets and branch protection:
* they are read-only and cannot be set in the configuration.
*/
export function getAttributeSource(definition: unknown): string | undefined {
if (!definition || typeof definition !== 'object') {
return undefined;
}

const metadata = (definition as Record<string, unknown>)[ATTRIBUTE_METADATA_KEY];
if (metadata && typeof metadata === 'object') {
const source = (metadata as Record<string, unknown>).source;
return typeof source === 'string' ? source : undefined;
}

return undefined;
}
14 changes: 14 additions & 0 deletions src/util/schemaToMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ describe('expandMdxComponents', () => {
expect(result).not.toContain('PullRequestAttributesTable');
});

test('default PullRequestAttributesTable excludes GitHub-sourced attributes', () => {
const result = expandMdxComponents('<PullRequestAttributesTable />');
expect(result).toContain('`author`');
expect(result).not.toContain('`github-review-approved`');
});

test('PullRequestAttributesTable source="github" lists only GitHub-sourced attributes', () => {
const result = expandMdxComponents('<PullRequestAttributesTable source="github" />');
expect(result).toContain('`github-review-approved`');
expect(result).toContain('`github-code-owner-review-satisfied`');
expect(result).not.toContain('`author`');
expect(result).not.toContain('PullRequestAttributesTable');
});

test('removes import statements', () => {
const input = [
'import OptionsTable from "../../../components/Tables/OptionsTable";',
Expand Down
17 changes: 12 additions & 5 deletions src/util/schemaToMarkdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import jsonpointer from 'jsonpointer';

import { getAttributeSource } from './attributeMetadata';
import configSchema from './sanitizedConfigSchema';

type Schema = typeof configSchema;
Expand Down Expand Up @@ -99,9 +100,14 @@ function generateOptionsTable(defName: string): string {
return [headerLine, separatorLine, ...rowLines].join('\n');
}

function generatePullRequestAttributesTable(): string {
function generatePullRequestAttributesTable(source?: string): string {
const attributes = (configSchema as any).$defs.PullRequestAttributes.properties;
const entries = Object.entries<any>(attributes).sort(([a], [b]) => a.localeCompare(b));
const entries = Object.entries<any>(attributes)
.filter(([, value]) => {
const attributeSource = getAttributeSource(value);
return source ? attributeSource === source : attributeSource === undefined;
})
.sort(([a], [b]) => a.localeCompare(b));

const rows: string[][] = [];
for (const [key, value] of entries) {
Expand Down Expand Up @@ -178,9 +184,10 @@ export function expandMdxComponents(source: string): string {
generateOptionsTable(def)
);

// Replace <PullRequestAttributesTable />
source = source.replace(/<PullRequestAttributesTable\s*\/?>/g, () =>
generatePullRequestAttributesTable()
// Replace <PullRequestAttributesTable /> (optionally filtered by source)
source = source.replace(
/<PullRequestAttributesTable(?:\s+source=["'](\w+)["'])?\s*\/?>/g,
(_match, attributeSource) => generatePullRequestAttributesTable(attributeSource)
);

// Collapse runs of 3+ blank lines to 2
Expand Down