chore: Improve Cubic performance and configuration clarity (#37193)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu
2026-08-27 16:42:28 +03:00
committed by GitHub
co-authored by Claude Opus 5
parent c678970930
commit 4f36a2d96b
29 changed files with 1660 additions and 328 deletions
+2
View File
@@ -432,6 +432,7 @@ Push to master/1.x
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
| 1st of month 04:00 | `util-refresh-cubic-schema.yml` | Refresh vendored cubic schema |
---
@@ -553,6 +554,7 @@ Scripts in `.github/scripts/`:
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
### Branch Replay Scripts
+3 -1
View File
@@ -17,16 +17,18 @@
"@actions/github": "9.0.0",
"@cyclonedx/cdxgen": "12.4.0",
"@octokit/core": "7.0.6",
"ajv": "8.20.0",
"conventional-changelog": "7.2.0",
"debug": "4.4.3",
"json-with-bigint": "3.5.11",
"glob": "13.0.6",
"json-with-bigint": "3.5.11",
"minimatch": "10.2.4",
"semver": "7.7.4",
"tempfile": "6.0.1",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/node": "^26.3.0",
"conventional-changelog-angular": "8.3.0"
}
}
+18
View File
@@ -17,6 +17,9 @@ importers:
'@octokit/core':
specifier: 7.0.6
version: 7.0.6
ajv:
specifier: 8.20.0
version: 8.20.0
conventional-changelog:
specifier: 7.2.0
version: 7.2.0(conventional-commits-filter@5.0.0)
@@ -42,6 +45,9 @@ importers:
specifier: ^2.8.3
version: 2.9.0
devDependencies:
'@types/node':
specifier: ^26.3.0
version: 26.3.0
conventional-changelog-angular:
specifier: 8.3.0
version: 8.3.0
@@ -323,6 +329,9 @@ packages:
'@types/http-cache-semantics@4.2.0':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
'@types/node@26.3.0':
resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1072,6 +1081,9 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici@6.28.0:
resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
engines: {node: '>=18.17'}
@@ -1523,6 +1535,10 @@ snapshots:
'@types/http-cache-semantics@4.2.0': {}
'@types/node@26.3.0':
dependencies:
undici-types: 8.3.0
'@types/normalize-package-data@2.4.4': {}
ajv-formats@3.0.1(ajv@8.20.0):
@@ -2324,6 +2340,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
undici-types@8.3.0: {}
undici@6.28.0: {}
undici@7.29.0: {}
@@ -0,0 +1,232 @@
/**
* Validates `cubic.yaml` against cubic's published schema and the limits it
* enforces silently.
*
* cubic drops custom rules past the agent cap and truncates any rule past the
* character ceiling without reporting either, and it never resolves a repo path
* mentioned in prose — only `file_paths` entries. A rule that trips any of these
* simply stops running, which is invisible until someone counts review comments.
*
* The schema is vendored rather than fetched so the check has no network
* dependency. `--refresh` pulls the current copy from cubic.dev and exits without
* validating — a refreshed schema that rejects the config should surface as a red
* check on the refresh PR, not as a failure that stops the PR being opened.
*
* Exit codes:
* 0 config is valid
* 1 config has at least one violation
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import Ajv from 'ajv/dist/2020.js';
import { parse } from 'yaml';
/** https://docs.cubic.dev/ai-review/custom-agents — only the first N agents take effect. */
export const MAX_CUBIC_AGENTS = 5;
/** Description plus linked file contents; characters past this are dropped from the prompt. */
export const MAX_RULE_CHARS = 10_000;
/** Fraction of the ceiling at which a rule is reported as close to silent truncation. */
export const WARN_RATIO = 0.8;
/** Every markdown file here must be linked by some agent, or it silently does nothing. */
export const RULES_DIR = '.agents/review-rules';
/** Vendored copy of the schema the `# yaml-language-server:` directive points at. */
export const SCHEMA_PATH = '.github/scripts/quality/cubic-config.schema.json';
export const SCHEMA_URL = 'https://www.cubic.dev/schema/cubic-repository-config.schema.json';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
/**
* Characters, not bytes — cubic's ceiling is a character count, and a byte count
* overstates it for any non-ASCII content (an em dash is 3 bytes, one character).
* `.length` counts UTF-16 code units, matching how the description is measured.
*
* @param {string} path - repo-relative
* @returns {number} character count, or -1 when the path does not resolve
*/
export function fileCharacters(path) {
try {
return readFileSync(join(REPO_ROOT, path), 'utf8').length;
} catch {
return -1;
}
}
/**
* Validate against cubic's own schema. Catches what the hand-written checks below
* cannot: a mistyped key inside `reviews` / `pr_descriptions` / `issues` (all
* `additionalProperties: false`), a bad enum value, a wrong type.
*
* @param {unknown} config
* @param {object} schema
* @returns {string[]}
*/
export function schemaErrors(config, schema) {
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
if (validate(config)) return [];
return (validate.errors ?? []).map((error) => {
const path = error.instancePath || '/';
const { allowedValues, additionalProperty } = error.params ?? {};
if (additionalProperty) {
return `${path} has an unknown key \`${additionalProperty}\`.`;
}
const allowed = allowedValues ? ` (allowed: ${allowedValues.join(', ')})` : '';
return `${path} ${error.message}${allowed}`;
});
}
/**
* Markdown rule files on disk, repo-relative, excluding the README.
*
* @returns {string[]}
*/
function ruleFiles() {
try {
return readdirSync(join(REPO_ROOT, RULES_DIR), { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md')
.map((entry) => relative(REPO_ROOT, join(entry.parentPath, entry.name)))
.sort();
} catch {
return [];
}
}
/**
* @param {any} config - parsed cubic.yaml
* @param {(path: string) => number} charsIn - characters in a linked file, -1 if missing
* @param {string[]} [onDisk] - rule files that must each be linked by some agent
* @returns {{ violations: string[], warnings: string[], ruleLengths: Record<string, number> }}
*/
export function checkConfig(config, charsIn, onDisk = []) {
const violations = [];
/** @type { string[] } */
const warnings = [];
/** @type { Record<string, number> } */
const ruleLengths = {}
const linked = new Set();
if (config?.version !== 1) {
violations.push(`\`version\` must be 1, found ${JSON.stringify(config?.version)}.`);
}
const rules = config?.reviews?.custom_rules ?? [];
if (!Array.isArray(rules)) {
violations.push('`reviews.custom_rules` must be a list.');
return { violations, warnings, ruleLengths };
}
if (rules.length > MAX_CUBIC_AGENTS) {
const dropped = rules.slice(MAX_CUBIC_AGENTS).map((rule) => rule?.name ?? '(unnamed)');
violations.push(
`${rules.length} custom rules defined but only the first ${MAX_CUBIC_AGENTS} take effect. ` +
`These never run: ${dropped.join(', ')}. Merge related rules instead of appending.`,
);
}
rules.forEach((rule, index) => {
const label = rule?.name ? `"${rule.name}"` : `rule #${index + 1}`;
if (!rule?.name) {
violations.push(`${label} has no \`name\`.`);
}
const description = rule?.description ?? '';
const filePaths = rule?.file_paths ?? [];
if (!description && filePaths.length === 0) {
violations.push(`${label} needs a \`description\`, \`file_paths\`, or both.`);
}
let total = description.length;
for (const path of filePaths) {
linked.add(path);
const chars = charsIn(path);
if (chars < 0) {
violations.push(`${label} links \`${path}\`, which does not exist.`);
continue;
}
total += chars;
}
if (total > MAX_RULE_CHARS) {
violations.push(
`${label} is ${total.toLocaleString()} characters; everything past ` +
`${MAX_RULE_CHARS.toLocaleString()} is dropped from the review prompt.`,
);
} else if (total > MAX_RULE_CHARS * WARN_RATIO) {
warnings.push(
`${label} is at ${Math.round((total / MAX_RULE_CHARS) * 100)}% of the ` +
`${MAX_RULE_CHARS.toLocaleString()}-character ceiling. Trim it before adding more.`,
);
}
ruleLengths[label] = total
});
for (const path of onDisk) {
if (!linked.has(path)) {
violations.push(`\`${path}\` is not linked by any agent, so it is never applied.`);
}
}
return { violations, warnings, ruleLengths };
}
async function refreshSchema() {
const response = await fetch(SCHEMA_URL);
if (!response.ok) {
console.error(`Could not fetch ${SCHEMA_URL}: HTTP ${response.status}`);
process.exit(1);
}
const schema = await response.json();
writeFileSync(join(REPO_ROOT, SCHEMA_PATH), `${JSON.stringify(schema, null, '\t')}\n`);
console.log(`Refreshed ${SCHEMA_PATH} from ${SCHEMA_URL}.`);
}
async function main() {
if (process.argv.includes('--refresh')) {
await refreshSchema();
return;
}
const config = parse(readFileSync(join(REPO_ROOT, 'cubic.yaml'), 'utf8'));
const schema = JSON.parse(readFileSync(join(REPO_ROOT, SCHEMA_PATH), 'utf8'));
const { violations, warnings, ruleLengths } = checkConfig(config, fileCharacters, ruleFiles());
violations.unshift(...schemaErrors(config, schema));
console.log("Rule sizes:")
for (const [label, ruleLength] of Object.entries(ruleLengths)) {
console.log(` ${label}: ${ruleLength} characters (${Math.floor(ruleLength / MAX_RULE_CHARS * 100)}%)`);
}
for (const warning of warnings) {
console.log(`::warning file=cubic.yaml::${warning}`);
}
if (violations.length === 0) {
console.log(
`cubic.yaml is valid (${config.reviews?.custom_rules?.length ?? 0}/${MAX_CUBIC_AGENTS} agents).`,
);
return;
}
for (const violation of violations) {
console.log(`::error file=cubic.yaml::${violation}`);
}
process.exit(1);
}
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import { after, before, describe, it } from 'node:test';
/**
* Run with:
* node --test .github/scripts/quality/check-cubic-config.test.mjs
*/
let checkConfig, fileCharacters, schemaErrors, MAX_CUBIC_AGENTS, MAX_RULE_CHARS, WARN_RATIO;
let schema;
before(async () => {
({ checkConfig, fileCharacters, schemaErrors, MAX_CUBIC_AGENTS, MAX_RULE_CHARS, WARN_RATIO } =
await import('./check-cubic-config.mjs'));
schema = JSON.parse(readFileSync(new URL('./cubic-config.schema.json', import.meta.url), 'utf8'));
});
/** Violations only, for the cases that assert nothing about warnings. */
const violationsOf = (...args) => checkConfig(...args).violations;
/** @param {Array<object>} rules */
const config = (rules) => ({ version: 1, reviews: { custom_rules: rules } });
/** @param {string} name */
const rule = (name) => ({ name, description: 'x' });
const noFiles = () => -1;
const emptyFiles = () => 0;
describe('limits', () => {
it('caps agents at 5', () => {
assert.equal(MAX_CUBIC_AGENTS, 5);
});
it('caps rule size at 10000', () => {
assert.equal(MAX_RULE_CHARS, 10_000);
});
it('warns at 80% of the ceiling', () => {
assert.equal(WARN_RATIO, 0.8);
});
});
describe('checkConfig', () => {
it('accepts a config at the agent cap', () => {
const rules = Array.from({ length: MAX_CUBIC_AGENTS }, (_, i) => rule(`r${i}`));
assert.deepEqual(violationsOf(config(rules), emptyFiles), []);
});
it('names every rule that will never run when the cap is exceeded', () => {
const rules = Array.from({ length: MAX_CUBIC_AGENTS + 2 }, (_, i) => rule(`r${i}`));
const [violation, ...rest] = violationsOf(config(rules), emptyFiles);
assert.deepEqual(rest, []);
assert.match(violation, /only the first 5 take effect/);
assert.match(violation, /r5, r6/);
});
it('flags a linked file that does not exist', () => {
const rules = [{ name: 'Frontend', description: '', file_paths: ['.agents/gone.md'] }];
const violations = violationsOf(config(rules), noFiles);
assert.equal(violations.length, 1);
assert.match(violations[0], /"Frontend" links `\.agents\/gone\.md`, which does not exist/);
});
it('counts linked file length toward the character ceiling', () => {
const rules = [{ name: 'Big', description: 'a'.repeat(9_000), file_paths: ['doc.md'] }];
assert.deepEqual(
violationsOf(config(rules), () => 500),
[],
);
const violations = violationsOf(config(rules), () => 2_000);
assert.equal(violations.length, 1);
assert.match(violations[0], /"Big" is 11,000 characters/);
});
it('measures a description in characters, not UTF-8 bytes', () => {
// Each em dash is 3 bytes but one character. By bytes this is 12,000 and
// would fail; by characters it is 4,000 and fits.
const rules = [{ name: 'Dashes', description: '—'.repeat(4_000) }];
assert.deepEqual(violationsOf(config(rules), emptyFiles), []);
});
it('flags a rule with neither a description nor linked files', () => {
const violations = violationsOf(config([{ name: 'Empty' }]), emptyFiles);
assert.equal(violations.length, 1);
assert.match(violations[0], /needs a `description`, `file_paths`, or both/);
});
it('flags an unnamed rule', () => {
const violations = violationsOf(config([{ description: 'x' }]), emptyFiles);
assert.equal(violations.length, 1);
assert.match(violations[0], /rule #1 has no `name`/);
});
it('flags a wrong schema version', () => {
const violations = violationsOf({ version: 2, reviews: { custom_rules: [] } }, emptyFiles);
assert.equal(violations.length, 1);
assert.match(violations[0], /`version` must be 1, found 2/);
});
it('accepts a config with no custom rules at all', () => {
assert.deepEqual(violationsOf({ version: 1 }, emptyFiles), []);
});
it('flags a rule file that no agent links', () => {
const rules = [{ name: 'Backend', description: '', file_paths: ['rules/linked.md'] }];
const onDisk = ['rules/linked.md', 'rules/orphan.md'];
const { violations } = checkConfig(config(rules), emptyFiles, onDisk);
assert.equal(violations.length, 1);
assert.match(violations[0], /`rules\/orphan\.md` is not linked by any agent/);
});
it('accepts rule files that are all linked', () => {
const rules = [
{ name: 'A', description: '', file_paths: ['rules/a.md'] },
{ name: 'B', description: '', file_paths: ['rules/b.md'] },
];
const { violations } = checkConfig(config(rules), emptyFiles, ['rules/a.md', 'rules/b.md']);
assert.deepEqual(violations, []);
});
});
describe('ceiling warnings', () => {
it('warns without failing once a rule passes 80%', () => {
const rules = [{ name: 'Security', description: 'a'.repeat(8_500) }];
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
assert.deepEqual(violations, []);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /"Security" is at 85% of the 10,000-character ceiling/);
});
it('stays quiet below the warning threshold', () => {
const rules = [{ name: 'Security', description: 'a'.repeat(7_000) }];
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
assert.deepEqual(violations, []);
assert.deepEqual(warnings, []);
});
it('reports a violation rather than a warning once over the ceiling', () => {
const rules = [{ name: 'Security', description: 'a'.repeat(10_001) }];
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
assert.equal(violations.length, 1);
assert.deepEqual(warnings, []);
});
});
describe('schemaErrors', () => {
/** @param {object} reviews */
const cfg = (reviews) => ({ version: 1, reviews });
it('accepts the settings we actually use', () => {
const errors = schemaErrors(
cfg({ enabled: true, sensitivity: 'medium', incremental_commits: true, check_drafts: true }),
schema,
);
assert.deepEqual(errors, []);
});
it('rejects a sensitivity outside the enum and lists the allowed values', () => {
const [error, ...rest] = schemaErrors(cfg({ sensitivity: 'strict' }), schema);
assert.deepEqual(rest, []);
assert.match(error, /\/reviews\/sensitivity must be equal to one of the allowed values/);
assert.match(error, /allowed: low, medium, high/);
});
it('names a mistyped key rather than saying "additional properties"', () => {
const [error, ...rest] = schemaErrors(cfg({ check_draft: true }), schema);
assert.deepEqual(rest, []);
assert.equal(error, '/reviews has an unknown key `check_draft`.');
});
it('rejects a wrong type', () => {
const [error] = schemaErrors(cfg({ incremental_commits: 'sometimes' }), schema);
assert.match(error, /\/reviews\/incremental_commits must be boolean/);
});
it('rejects a per-rule field cubic does not support', () => {
const config = cfg({ custom_rules: [{ name: 'A', description: 'x', severity: 'high' }] });
const [error] = schemaErrors(config, schema);
assert.equal(error, '/reviews/custom_rules/0 has an unknown key `severity`.');
});
it('requires a version', () => {
const errors = schemaErrors({ reviews: {} }, schema);
assert.ok(errors.some((e) => e.includes('version')));
});
});
describe('fileCharacters', () => {
const REPO_ROOT = new URL('../../..', import.meta.url).pathname;
let dir;
before(() => {
dir = mkdtempSync(join(tmpdir(), 'cubic-chars-'));
});
after(() => rmSync(dir, { recursive: true, force: true }));
/** @param {string} name @param {string} content */
const write = (name, content) => {
const abs = join(dir, name);
writeFileSync(abs, content, 'utf8');
return relative(REPO_ROOT, abs);
};
it('counts characters, not UTF-8 bytes', () => {
// 100 em dashes: 300 bytes, 100 characters. statSync().size would say 300.
const path = write('dashes.md', '—'.repeat(100));
assert.equal(fileCharacters(path), 100);
});
it('counts ASCII unchanged, where bytes and characters agree', () => {
const path = write('ascii.md', 'a'.repeat(100));
assert.equal(fileCharacters(path), 100);
});
it('returns -1 for a path that does not resolve', () => {
assert.equal(fileCharacters('.agents/review-rules/does-not-exist.md'), -1);
});
});
@@ -0,0 +1,348 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cubic.dev/schema/cubic-repository-config.schema.json",
"title": "Cubic Repository Configuration",
"description": "JSON schema for cubic.yaml (and the legacy .cubic.yaml) so editors can validate and autocomplete repository settings.",
"type": "object",
"additionalProperties": true,
"required": [
"version"
],
"properties": {
"version": {
"const": 1,
"description": "Schema version. Must always be 1."
},
"reviews": {
"type": "object",
"description": "AI review behavior and optional YAML-defined custom agents.",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Master toggle for AI reviews on this repository."
},
"sensitivity": {
"type": "string",
"enum": [
"low",
"medium",
"high"
],
"description": "Controls how strictly Cubic flags issues."
},
"incremental_commits": {
"type": "boolean",
"default": true,
"description": "If true, reviews new commits pushed to open PRs (only new issues are posted). If false, reviews only when the PR is first opened."
},
"check_drafts": {
"type": "boolean",
"default": false,
"description": "If true, reviews draft PRs immediately when opened. If false, skips them."
},
"architecture_diagrams": {
"type": "boolean",
"default": false,
"description": "If true, includes AI-generated architecture diagrams in review summaries."
},
"external_contributors_require_manual_review": {
"type": "boolean",
"default": false,
"description": "If true, cubic skips automatic reviews for public-repository PRs from external contributors until a trusted installation member manually triggers a review."
},
"show_ai_feedback_buttons": {
"type": "boolean",
"default": false,
"deprecated": true,
"description": "Deprecated and ignored. Retained so existing cubic.yaml files continue to validate."
},
"resolve_threads_when_addressed": {
"type": "boolean",
"default": true,
"description": "Automatically resolve GitHub review threads when cubic detects the issue has been addressed in a subsequent commit."
},
"merge_confidence_summary": {
"type": "boolean",
"default": false,
"description": "If true, includes an AI-generated merge confidence summary on full reviews."
},
"auto_approve_behavior": {
"type": "string",
"enum": [
"disabled",
"shadow",
"live"
],
"description": "Controls whether auto-approval is disabled, simulated in shadow mode, or submitted live to GitHub."
},
"auto_approve": {
"type": "string",
"enum": [
"disabled",
"always",
"low_risk_only",
"custom"
],
"description": "Auto-approve mode: disabled, always approve clean runs, use low-risk heuristics, or use a custom prompt."
},
"auto_approve_custom_prompt": {
"type": "string",
"description": "Custom criteria for auto-approval. Only used when auto_approve is custom."
},
"auto_approve_rules": {
"$ref": "#/$defs/autoApproveRules"
},
"ultrareview": {
"type": "string",
"enum": [
"disabled",
"manual",
"automatic"
],
"description": "Master switch for ultrareviews. disabled blocks all ultrareviews (manual and automatic), manual allows only manual triggers (the default), automatic also enables the automatic triggers."
},
"auto_ultrareview": {
"type": "string",
"enum": [
"disabled",
"high_risk_only",
"custom"
],
"description": "Auto-trigger ultrareview mode: disabled, let cubic decide per PR using built-in high-risk criteria, or use a custom prompt."
},
"auto_ultrareview_custom_prompt": {
"type": "string",
"description": "Custom criteria describing which PRs warrant an ultrareview. Only used when auto_ultrareview is custom."
},
"auto_ultrareview_file_patterns": {
"$ref": "#/$defs/globList",
"description": "File or directory globs that always trigger an ultrareview. If any changed file in a PR matches, cubic runs an ultrareview regardless of the auto_ultrareview mode."
},
"custom_instructions": {
"type": "string",
"description": "Free-form reviewer guidance. Leading/trailing whitespace is trimmed."
},
"ignore": {
"$ref": "#/$defs/ignore"
},
"custom_rules": {
"type": "array",
"description": "List of YAML-managed custom agents enforced before UI-defined agents.",
"items": {
"$ref": "#/$defs/customRule"
}
}
}
},
"pr_descriptions": {
"type": "object",
"description": "Configuration for AI-authored pull request descriptions.",
"additionalProperties": false,
"properties": {
"generate": {
"type": "boolean",
"description": "Enable AI-authored PR descriptions."
},
"instructions": {
"type": "string",
"description": "Extra guidance inserted into generated summaries."
},
"cubic_review_link": {
"type": "boolean",
"description": "Include a link to review the PR in cubic (eg www.cubic.dev/pr/owner/repo/123)."
},
"skip_if_author_description": {
"type": "boolean",
"description": "Skip AI description generation on new PRs when the author already wrote a substantive description. Empty or template-only descriptions still get an AI description."
}
}
},
"issues": {
"type": "object",
"description": "Issue and PR comment fix settings.",
"additionalProperties": false,
"properties": {
"fix_with_cubic_buttons": {
"type": "boolean",
"description": "Toggle Fix with cubic buttons inside GitHub issues."
},
"pr_comment_fixes": {
"type": "boolean",
"description": "Allow cubic to make code changes when requested from PR comment threads."
},
"fix_commits_to_pr": {
"type": "boolean",
"description": "When true, fix commits are pushed directly to the PR branch instead of opening a new PR."
},
"auto_fix_sign_commits": {
"type": "boolean",
"default": false,
"description": "When true, cubic signs the commits it pushes so GitHub marks them as verified. This applies only to the 'cubic' coding agent; the Cursor agent always signs its commits."
},
"coding_agent_provider": {
"type": "string",
"enum": [
"cubic",
"cursor_cloud_agent"
],
"description": "Which coding agent applies automatic fixes. 'cubic' uses cubic's built-in agent; 'cursor_cloud_agent' dispatches a Cursor cloud agent (requires Cursor configuration)."
}
}
}
},
"$defs": {
"globList": {
"type": "array",
"description": "List of glob patterns. Empty strings are ignored at runtime.",
"items": {
"type": "string",
"minLength": 1
}
},
"ignore": {
"type": "object",
"description": "Conditional ignore filters that skip AI reviews.",
"additionalProperties": false,
"properties": {
"files": {
"$ref": "#/$defs/globList",
"description": "File globs to skip entirely."
},
"head_branches": {
"$ref": "#/$defs/globList",
"description": "Source branches that should not trigger reviews."
},
"base_branches": {
"$ref": "#/$defs/globList",
"description": "Target branches that should not trigger reviews."
},
"pr_labels": {
"$ref": "#/$defs/globList",
"description": "Labels that disable reviews when present."
},
"pr_titles": {
"$ref": "#/$defs/globList",
"description": "Wildcard matches applied to pull request titles."
},
"max_changed_lines": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"description": "Store the automatic-review threshold for reviewable added plus deleted lines. Positive integers up to 2,147,483,647 are preserved, but cubic applies an effective 50,000-line ceiling to every review. Manual triggers can bypass a lower configured threshold, but not the effective ceiling."
}
}
},
"autoApproveRules": {
"type": "object",
"description": "Rules that control whether auto-approval evaluation is allowed.",
"additionalProperties": false,
"properties": {
"exclude": {
"$ref": "#/$defs/globList",
"description": "Changed files matching these globs are never eligible for auto-approval."
},
"only_files": {
"$ref": "#/$defs/globList",
"description": "Every changed path must match at least one of these globs for the pull request to be eligible for auto-approval. Renamed files must match on both their current and previous paths."
},
"exclude_external_contributors": {
"type": "boolean",
"default": false,
"description": "If true, public-repository PRs from external contributors are reviewed but never auto-approved."
},
"exclude_authors": {
"$ref": "#/$defs/globList",
"description": "PRs from a matching author are never auto-approved. Logins match case-insensitively and brackets are literal, so `renovate[bot]` and `*[bot]` both work."
},
"only_authors": {
"$ref": "#/$defs/globList",
"description": "Only PRs from a matching author can be auto-approved."
},
"exclude_head_branches": {
"$ref": "#/$defs/globList",
"description": "PRs whose source branch matches are never auto-approved."
},
"only_head_branches": {
"$ref": "#/$defs/globList",
"description": "Only PRs whose source branch matches can be auto-approved."
},
"exclude_base_branches": {
"$ref": "#/$defs/globList",
"description": "PRs targeting a matching base branch are never auto-approved."
},
"only_base_branches": {
"$ref": "#/$defs/globList",
"description": "Only PRs targeting a matching base branch can be auto-approved."
},
"exclude_labels": {
"$ref": "#/$defs/globList",
"description": "PRs carrying a matching label are never auto-approved. Labels match exactly, case-insensitively."
},
"only_labels": {
"$ref": "#/$defs/globList",
"description": "Only PRs carrying a matching label can be auto-approved. Labels match exactly, case-insensitively."
},
"exclude_titles": {
"$ref": "#/$defs/globList",
"description": "PRs whose title matches are never auto-approved."
},
"only_titles": {
"$ref": "#/$defs/globList",
"description": "Only PRs whose title matches can be auto-approved."
}
}
},
"customRule": {
"type": "object",
"description": "Definition of a YAML-managed custom agent.",
"additionalProperties": false,
"required": [
"name"
],
"anyOf": [
{
"required": [
"description"
]
},
{
"required": [
"file_paths"
]
}
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Rule title shown in the dashboard."
},
"description": {
"type": "string",
"minLength": 1,
"description": "Explain what the rule enforces."
},
"file_paths": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"description": "Ordered repo-relative instruction files. The first 10,000 characters of the description plus concatenated file text are used.",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"include": {
"$ref": "#/$defs/globList",
"description": "Optional include globs. Defaults to all files."
},
"exclude": {
"$ref": "#/$defs/globList",
"description": "Optional exclude globs that override includes."
}
}
}
}
}
+2
View File
@@ -117,6 +117,8 @@ jobs:
.github/scripts/**
scripts/licenses/**
scripts/mutation-health/**
cubic.yaml
.agents/review-rules/**
performance:
packages/testing/performance/**
packages/workflow/src/**
@@ -36,3 +36,7 @@ jobs:
- name: Run tests
id: run-tests
run: npm test --prefix=.github/scripts
- name: Check cubic.yaml
if: ${{ !cancelled() }}
run: node .github/scripts/quality/check-cubic-config.mjs
@@ -0,0 +1,85 @@
name: 'Util: Refresh cubic schema'
on:
schedule:
# 1st of the month at 04:00 UTC
- cron: '0 4 1 * *'
workflow_dispatch: # Allow manual trigger for testing
permissions:
contents: read
jobs:
refresh-schema:
name: Refresh vendored cubic schema
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'schedule' && github.repository == 'n8n-io/n8n')
runs-on: ubuntu-latest
timeout-minutes: 10
# Every write goes through the scoped app token below, so the job's own
# GITHUB_TOKEN only needs to read for checkout.
permissions:
contents: read
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
# Scope the token to what create-pull-request does: push the branch,
# open the PR (the label already exists, so no issues access needed).
permission-contents: write
permission-pull-requests: write
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# create-pull-request unsets any persisted credential and configures
# its own from `token`, so nothing here needs to survive the checkout.
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
# Refreshes only. Validation runs on the PR this opens, so a schema change
# that rejects the current cubic.yaml shows up as a red check to act on
# rather than a failure here that hides the change.
- name: Refresh vendored schema
run: node .github/scripts/quality/check-cubic-config.mjs --refresh
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
# Stage only the schema. setup-nodejs can leave unrelated files dirty,
# and this PR should never carry anything but the refreshed copy.
add-paths: .github/scripts/quality/cubic-config.schema.json
commit-message: 'chore: Refresh vendored cubic config schema (no-changelog)'
labels: 'automation:scheduled-update'
title: 'chore: Refresh vendored cubic config schema (no-changelog)'
body: |
cubic's published config schema has changed since the vendored copy was last updated.
The copy at `.github/scripts/quality/cubic-config.schema.json` is what
`pnpm check:cubic-config` validates `cubic.yaml` against, so it is vendored
to keep that check off the network.
**Review the diff for new options worth adopting** — new keys under `reviews`
are how cubic ships features, and the vendored copy is the only place they
become visible to us.
If the `Workflow scripts` check is red, the new schema rejects something in
`cubic.yaml`; fix the config in this PR.
_Generated by the monthly cubic schema refresh workflow._
branch: refresh-cubic-schema
base: master
delete-branch: true
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>