Skip to content

Commit 906c6e7

Browse files
Merge pull request #648 from corbitsdev/cl-11537-add-sensitive-paths-and-risk-level-rules-with-defaults-a
feat(rules): add sensitive-paths and risk-level rules
2 parents 5013f74 + 3cc22e3 commit 906c6e7

25 files changed

Lines changed: 539 additions & 42 deletions

File tree

‎apps/hub/src/github/policy-file.postgres.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,13 @@ test("PostgreSQL: a file already holding all a file can of the saved rules is no
350350
const saved: CheckPack = {
351351
...emptyPack(FROM_FILE),
352352
checks: { "ci-green": { enabled: true } },
353-
custom: [{ id: "risk", name: "Risk", group: "pull-request", kind: "model", shape: "score", subject: "the risk", levels: ["high", "low"] }],
353+
custom: [{ id: "risk-score", name: "Risk score", group: "pull-request", kind: "model", shape: "score", subject: "the risk", levels: ["high", "low"] }],
354354
};
355355
await writePolicy(FROM_FILE, JSON.stringify(saved));
356356
onMain(FROM_FILE, YML, exportPolicyFile(saved, false).text);
357357
await policyFile.onPush(TENANT, FROM_FILE, "github");
358358
expect(await readPack(FROM_FILE)).toMatchObject({ status: "ok", source: { repo: { version: 1 } } });
359-
expect(await upgradeOf(FROM_FILE)).toMatchObject({ upgrade: { state: "differs", differences: [{ rule: "Risk", file: [] }], fixable: false } });
359+
expect(await upgradeOf(FROM_FILE)).toMatchObject({ upgrade: { state: "differs", differences: [{ rule: "Risk score", file: [] }], fixable: false } });
360360
expect(await exportOf(FROM_FILE)).toMatchObject({ http: 409, error: { code: "policy_file_full" } });
361361
}, 60_000);
362362

‎apps/hub/src/github/policy-file.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function addBody(lead: string): string {
4747
return [lead, "", "Edit it here before merging. Once merged, triage reads its rules from this file, and you change them by editing it."].join("\n");
4848
}
4949

50-
const STARTER_BODY = addBody(`Adds \`${POLICY_FILE_PATH}\` with a few common rules: a size limit, no merge conflicts and passing CI.`);
50+
const STARTER_BODY = addBody(`Adds \`${POLICY_FILE_PATH}\` with a few common rules: a size limit, no merge conflicts, passing CI, and a maintainer asked about sensitive or risky changes.`);
5151
const CHOSEN_BODY = addBody(`Adds \`${POLICY_FILE_PATH}\`.`);
5252

5353
/**

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,13 +359,13 @@ label off that head, so it never stands on a head not yet triaged.
359359
details (`/api/integrations/github-pull`) find referenced GitHub issues in
360360
the same cleaned title, body and commit messages; a pasted URL of an issue
361361
in the same repository counts too, as GitHub renders it `#<number>`.
362-
With the regression-risk rule on, it also lists each changed file's
362+
With the regression-risk or risk rule on, it also lists each changed file's
363363
default-branch commits from the last 30 days (`github_list_path_commits`,
364364
`bitbucket_datacenter_list_path_commits`, one page each, ten files at a
365365
time; tests, docs and lockfiles skipped; at most 300 files per pull
366366
request, GitHub's own diff limit) and keeps how many have a subject typed
367367
fix, bugfix, hotfix or revert (`fixes`); a history that fails, runs past
368-
one page or is past the 300 is kept as unread, which leaves only that rule
368+
one page or is past the 300 is kept as unread, which leaves only those rules
369369
unconfirmed.
370370
With the focused rule on, it also reads the stack (`get_stack`): the pull
371371
requests merged into the branch, and the open pull request whose branch it

‎docs/Corbits Triage User Manual/Corbits Triage User Manual.md‎

Lines changed: 36 additions & 7 deletions
Large diffs are not rendered by default.

‎packages/policy-check-action/src/cli.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ const EVERY_MACHINE_RULE = `rules:
169169
- { check: hidden-content, text: T., ${EVIDENCE} }
170170
- { check: regression-risk, text: T., ${EVIDENCE} }
171171
- { check: no-duplicate, text: T., ${EVIDENCE} }
172+
- { check: sensitive-paths, text: T., ${EVIDENCE} }
173+
- { check: risk, text: T., ${EVIDENCE} }
172174
- { id: conventions, check: code-patterns, text: T., presets: [convention], ${EVIDENCE} }
173175
`;
174176

‎packages/policy-check-action/src/local-git.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,9 @@ export function localGithub(change: LocalChange): (url: URL) => Promise<Response
186186

187187
const ON_HOST = "needs the pull request on its host";
188188

189-
/** Rules that read what only the host knows: its CI, mergeability, reviews, default-branch fix history, its other open pull requests and the pull request's own title and description. */
189+
/** Rules that read what only the host knows: its CI, mergeability, reviews, default-branch fix history, its other open pull requests, the author's standing and the pull request's own title and description. */
190190
export function hostOnly(pack: CheckPack): Skipped[] {
191191
const described = pack.custom.flatMap((row) => (row.kind === "text" && row.fields.some((field) => field !== "commits") ? [row.id] : []));
192192
const titled = (pack.checks["conventional-commits"]?.fields ?? CONVENTIONAL_FIELDS).includes("title") ? ["conventional-commits"] : [];
193-
return ["ci-green", "no-merge-conflicts", "linked-issue", "hidden-content", "regression-risk", "no-duplicate", NOT_A_DRAFT, NO_CHANGES_REQUESTED, ...titled, ...described].map((id) => ({ id, why: ON_HOST }));
193+
return ["ci-green", "no-merge-conflicts", "linked-issue", "hidden-content", "regression-risk", "no-duplicate", "risk", NOT_A_DRAFT, NO_CHANGES_REQUESTED, ...titled, ...described].map((id) => ({ id, why: ON_HOST }));
194194
}

‎packages/triage-contracts/src/check-pack.ts‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const CATALOG_IDS = [
3030
"regression-risk",
3131
"no-duplicate",
3232
"duplicates-open-pull-request",
33+
"sensitive-paths",
34+
"risk",
3335
] as const;
3436
export type CatalogId = (typeof CATALOG_IDS)[number];
3537

@@ -58,6 +60,8 @@ export const CHECK_CATALOG: Record<CatalogId, { name: string; group: CheckPackGr
5860
"regression-risk": { name: "Regression risk", group: "pull-request", kind: "machine" },
5961
"no-duplicate": { name: "No duplicate", group: "around", kind: "machine" },
6062
"duplicates-open-pull-request": { name: "Duplicates an open pull request", group: "around", kind: "quality" },
63+
"sensitive-paths": { name: "Sensitive paths", group: "pull-request", kind: "machine" },
64+
risk: { name: "Risk", group: "pull-request", kind: "machine" },
6165
};
6266

6367
export const NOT_A_DRAFT = "not-a-draft";
@@ -120,6 +124,45 @@ export const RECOMMENDED_PATHS = { forbiddenGlobs: ["vendor/**", "node_modules/*
120124
* with at least `hotFileFixes` fix or revert commits in the last `hotFileDays`, or a changed path matching a migration glob.
121125
*/
122126
export const DEFAULT_RISK = { hotFileFixes: 4, hotFileDays: 30, migrationGlobs: ["**/migrations/**", "**/*.sql"] } as const;
127+
export const RISK_MARKS = ["medium", "high"] as const;
128+
export type RiskMark = (typeof RISK_MARKS)[number];
129+
/** A named group of sensitive paths, and how sensitive a change to it is. */
130+
export type SensitiveGroup = { level: RiskMark; paths: string[] };
131+
/**
132+
* The sensitive-paths rule's groups when the policy names none: a schema change is hard to undo and auth and secrets
133+
* decide who gets in, so those are high; CI and deploy files decide what runs, and a lockfile pulls in code the diff does
134+
* not show, but every dependency bump changes one, so those are medium.
135+
*/
136+
export const DEFAULT_SENSITIVE: Readonly<Record<string, SensitiveGroup>> = {
137+
schema: { level: "high", paths: ["**/migrations/**", "**/*.sql"] },
138+
auth: { level: "high", paths: ["**/auth/**", "**/secrets/**"] },
139+
ci: { level: "medium", paths: [".github/**", "**/Dockerfile", "**/railway.json", "**/railway.toml"] },
140+
locks: { level: "medium", paths: ["**/bun.lock", "**/package-lock.json", "**/yarn.lock", "**/pnpm-lock.yaml"] },
141+
};
142+
/** The label each of the sensitive-paths and risk rules adds at a level, which Triage takes back once the level changes. */
143+
export const LEVEL_LABELS = { "sensitive-paths": "sensitive", risk: "risk" } as const;
144+
export type LevelLabelRule = keyof typeof LEVEL_LABELS;
145+
146+
export function levelLabel(id: LevelLabelRule, level: RiskMark): string {
147+
return `${LEVEL_LABELS[id]}: ${level}`;
148+
}
149+
150+
export function isLevelLabelRule(id: string): id is LevelLabelRule {
151+
return Object.hasOwn(LEVEL_LABELS, id);
152+
}
153+
154+
/** Where the sensitive-paths and risk rules fail, and from where they label below that, when the policy does not say. */
155+
export const DEFAULT_MARKS: { failAt: RiskMark; labelAt: RiskMark } = { failAt: "high", labelAt: "medium" };
156+
/** `sensitive` is a change to a medium sensitive group; one to a high group makes the pull request high risk on its own. */
157+
export const RISK_SIGNALS = ["sensitive", "size", "no-tests", "hot-files", "first-time"] as const;
158+
export type RiskSignal = (typeof RISK_SIGNALS)[number];
159+
export const RISK_LEVELS = ["low", "medium", "high"] as const;
160+
export type RiskLevel = (typeof RISK_LEVELS)[number];
161+
/** How many signals reach a level, or the signals any one of which reaches it; a list among them reaches it only with all of its signals. */
162+
export type RiskThreshold = number | Array<RiskSignal | RiskSignal[]>;
163+
export type RiskLevels = { medium: RiskThreshold; high: RiskThreshold; failAt: RiskMark; labelAt: RiskMark };
164+
/** The risk rule's defaults: any one signal is medium; a size limit crossed with no test changed is high. */
165+
export const DEFAULT_RISK_LEVELS: RiskLevels = { medium: 1, high: [["size", "no-tests"]], ...DEFAULT_MARKS };
123166
export const RECOMMENDED_ISSUE = { tracker: "either" } as const;
124167
export const CONVENTIONAL_TYPES = ["feat", "fix", "refactor", "perf", "docs", "test", "chore", "ci", "build", "style", "revert"] as const;
125168
export const CONVENTIONAL_FIELDS = ["title", "commits"] as const;
@@ -168,6 +211,16 @@ export type CatalogCheck = RuleNote & OnFail & {
168211
/** Regression risk: fixes on one changed file in recent history that make it risky, and the paths that are migrations; absent is `DEFAULT_RISK`'s. */
169212
hotFileFixes?: number;
170213
migrationGlobs?: string[];
214+
/** Sensitive paths: named groups of globs, each with its level; absent is `DEFAULT_SENSITIVE`. */
215+
sensitiveGroups?: Record<string, SensitiveGroup>;
216+
/**
217+
* Risk: what reaches medium and high; sensitive paths and risk: the level each fails at, and the level from which one
218+
* below that is labelled. Absent is `DEFAULT_RISK_LEVELS`'.
219+
*/
220+
medium?: RiskThreshold;
221+
high?: RiskThreshold;
222+
failAt?: RiskMark;
223+
labelAt?: RiskMark;
171224
};
172225

173226
/** Each size rule and the field its limit is stored in. */
@@ -184,10 +237,16 @@ export function isSizeRule(id: string): id is SizeRuleId {
184237
return Object.hasOwn(SIZE_RULES, id);
185238
}
186239

187-
/** What a policy file's rule does on failure when the file does not say: features and refactors cross size limits and touch risky code, so those only label. */
240+
const ASKING_RULES: ReadonlySet<string> = new Set(["sensitive-paths", "risk"]);
241+
242+
/**
243+
* What a policy file's rule does on failure when the file does not say: features and refactors cross size limits and
244+
* touch risky code, so those only label, and a sensitive or risky change is the author's to make but a maintainer's to accept.
245+
*/
188246
export function defaultOnFail(id: string): (typeof ON_FAILS)[number] {
189247
// A judged duplicate may only overlap, so a maintainer decides.
190248
if (id === "duplicates-open-pull-request") return "ask";
249+
if (ASKING_RULES.has(id)) return "ask";
191250
return isSizeRule(id) || id === "regression-risk" ? "label" : "fail";
192251
}
193252

@@ -481,6 +540,11 @@ const CatalogCheckSchema = type({
481540
"fields?": type.enumerated(...CONVENTIONAL_FIELDS).array(),
482541
"hotFileFixes?": "number",
483542
"migrationGlobs?": "string[]",
543+
"sensitiveGroups?": type({ "[string]": { level: type.enumerated(...RISK_MARKS), paths: "string[]" } }),
544+
"medium?": "number | (string | string[])[]",
545+
"high?": "number | (string | string[])[]",
546+
"failAt?": type.enumerated(...RISK_MARKS),
547+
"labelAt?": type.enumerated(...RISK_MARKS),
484548
});
485549

486550
export const CustomId = type(CUSTOM_ID).atMostLength(CUSTOM_ID_MAX);
@@ -610,6 +674,8 @@ export function recommendedPack(repo: string): CheckPack {
610674
"no-merge-conflicts": { enabled: true },
611675
"ci-green": { enabled: true },
612676
"no-forbidden-paths": { enabled: true, forbiddenGlobs: [...RECOMMENDED_PATHS.forbiddenGlobs] },
677+
"sensitive-paths": { enabled: true, onFail: "ask" },
678+
risk: { enabled: true, onFail: "ask" },
613679
},
614680
custom: [{ ...RECOMMENDED_TEXT, fields: [...RECOMMENDED_TEXT.fields], phrases: [...RECOMMENDED_TEXT.phrases] }],
615681
actions: { ...defaultActions(), pass: [{ do: "approve" }] },
@@ -621,6 +687,8 @@ const STARTER_REASONS = {
621687
"max-lines-changed": "Large pull requests are hard to review.",
622688
"no-merge-conflicts": "A pull request with conflicts can't be merged as it is.",
623689
"ci-green": "A change is not ready until CI passes.",
690+
"sensitive-paths": "Schema, auth, CI and lockfile changes reach beyond what the diff shows.",
691+
risk: "A risky change needs a maintainer's eye.",
624692
} as const satisfies Partial<Record<CatalogId, string>>;
625693

626694
/**
@@ -687,13 +755,52 @@ function parseCatalogCheck(id: CatalogId, raw: unknown): CatalogCheck | undefine
687755
const globs = parseGlobs(row.migrationGlobs, "Regression risk migration globs");
688756
if (globs) check.migrationGlobs = globs;
689757
}
758+
if (id === "sensitive-paths") {
759+
if (row.sensitiveGroups !== undefined) check.sensitiveGroups = sensitiveGroups(row.sensitiveGroups);
760+
Object.assign(check, marks(row, "Sensitive paths"));
761+
}
762+
if (id === "risk") Object.assign(check, riskLevels(row), marks(row, "Risk"));
690763
if (id === "conventional-commits") {
691764
if (row.types !== undefined) check.types = commitTypes(row.types);
692765
if (row.fields !== undefined) check.fields = conventionalFields(row.fields);
693766
}
694767
return check;
695768
}
696769

770+
function sensitiveGroups(value: unknown): Record<string, SensitiveGroup> {
771+
const where = "Sensitive paths groups";
772+
const named = Object.entries(record(value, where));
773+
if (named.length === 0 || named.length > LIST_MAX) throw new Error(`${where} must name 1 to ${LIST_MAX} groups.`);
774+
return Object.fromEntries(named.map(function group([name, raw]) {
775+
if (!GroupName.allows(name)) throw new Error(`${where} names must be lowercase words joined by hyphens, not ${refusalNames([name])}.`);
776+
const row = record(raw, `${where} ${name}`);
777+
exactKeys(row, ["level", "paths"], `${where} ${name}`);
778+
return [name, { level: oneOf(row.level, RISK_MARKS, `${where} ${name} level`), paths: globs(row.paths, `${where} ${name} paths`) }];
779+
}));
780+
}
781+
782+
function marks(row: Record<string, unknown>, where: string): Pick<CatalogCheck, "failAt" | "labelAt"> {
783+
return {
784+
...(row.failAt !== undefined && { failAt: oneOf(row.failAt, RISK_MARKS, `${where} failAt`) }),
785+
...(row.labelAt !== undefined && { labelAt: oneOf(row.labelAt, RISK_MARKS, `${where} labelAt`) }),
786+
};
787+
}
788+
789+
function riskThreshold(value: unknown, where: string): RiskThreshold {
790+
if (typeof value === "number") {
791+
if (!Number.isSafeInteger(value) || value < 1 || value > RISK_SIGNALS.length) throw new Error(`${where} must be a number of signals from 1 to ${RISK_SIGNALS.length}, or a list of signals.`);
792+
return value;
793+
}
794+
return list(value, where).map((entry) => (Array.isArray(entry) ? [...new Set(list(entry, where).map((signal) => oneOf(signal, RISK_SIGNALS, where)))] : oneOf(entry, RISK_SIGNALS, where)));
795+
}
796+
797+
function riskLevels(row: Record<string, unknown>): Pick<CatalogCheck, "medium" | "high"> {
798+
return {
799+
...(row.medium !== undefined && { medium: riskThreshold(row.medium, "Risk medium") }),
800+
...(row.high !== undefined && { high: riskThreshold(row.high, "Risk high") }),
801+
};
802+
}
803+
697804
function commitTypes(value: unknown): string[] {
698805
const types = texts(value, "Conventional commits types");
699806
const bad = types.find((name) => !CONVENTIONAL_TYPE.test(name));
@@ -785,6 +892,7 @@ function parseNote(row: Record<string, unknown>, where: string): RuleNote {
785892
function labelOf(id: string, row: OnFail): string {
786893
if (row.label !== undefined) return row.label;
787894
if (isSizeRule(id)) return "size: large";
895+
if (isLevelLabelRule(id)) return levelLabel(id, "high");
788896
return id === "docs" ? "docs: check" : `${id}: check`;
789897
}
790898

‎packages/triage-contracts/src/code-patterns.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import picomatch from "picomatch";
22

33
export const PATTERN_LANGUAGES = ["typescript", "javascript"] as const;
44
export type PatternLanguage = (typeof PATTERN_LANGUAGES)[number];
5+
/** The file extensions code-pattern rules read. */
6+
export const PATTERN_EXTENSIONS = ["ts", "mts", "cts", "tsx", "js", "mjs", "cjs", "jsx"] as const;
7+
export type PatternExtension = (typeof PATTERN_EXTENSIONS)[number];
58

69
/** An ast-grep rule: `rule`, `files` and `ignores` are as an ast-grep rule config holds them, globs as strings only. */
710
export interface PatternRule {

‎packages/triage-contracts/src/policy-export.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ function catalogParams(id: CatalogId, row: CatalogCheck): Record<string, unknown
5454
return row.forbiddenGlobs?.length ? { paths: row.forbiddenGlobs } : undefined;
5555
case "regression-risk":
5656
return { ...(row.hotFileFixes !== undefined && { hotFileFixes: row.hotFileFixes }), ...(row.migrationGlobs && { migrations: row.migrationGlobs }) };
57+
case "sensitive-paths":
58+
return { ...(row.sensitiveGroups && { groups: row.sensitiveGroups }), ...(row.failAt && { failAt: row.failAt }), ...(row.labelAt && { labelAt: row.labelAt }) };
59+
case "risk":
60+
return { ...(row.medium !== undefined && { medium: row.medium }), ...(row.high !== undefined && { high: row.high }), ...(row.failAt && { failAt: row.failAt }), ...(row.labelAt && { labelAt: row.labelAt }) };
5761
case "conventional-commits":
5862
return { ...(row.types && { types: row.types }), ...(row.fields && { fields: row.fields }) };
5963
default:
@@ -155,7 +159,7 @@ function catalogChecks(pack: CheckPack, id: CatalogId): string | undefined {
155159
if (id === "ci-green" && row?.enabled === false) return CI_SKIPPED;
156160
if (!row?.enabled) return undefined;
157161
const conventional = id === "conventional-commits" && { types: sorted(row.types ?? CONVENTIONAL_TYPES), fields: row.fields ?? CONVENTIONAL_FIELDS };
158-
return canonical({ max: isSizeRule(id) ? row[SIZE_RULES[id]] : undefined, tracker: row.tracker ?? "either", paths: sorted(row.forbiddenGlobs), hotFileFixes: row.hotFileFixes, migrations: sorted(row.migrationGlobs), onFail: onFailText(pack, id), ...conventional });
162+
return canonical({ max: isSizeRule(id) ? row[SIZE_RULES[id]] : undefined, tracker: row.tracker ?? "either", paths: sorted(row.forbiddenGlobs), hotFileFixes: row.hotFileFixes, migrations: sorted(row.migrationGlobs), groups: row.sensitiveGroups, medium: row.medium, high: row.high, failAt: row.failAt, labelAt: row.labelAt, onFail: onFailText(pack, id), ...conventional });
159163
}
160164

161165
function modelAsks(row: ModelCustomCheck): Record<string, unknown> {
@@ -239,6 +243,8 @@ function catalogLimits(pack: CheckPack, id: CatalogId): string[] {
239243
return row.forbiddenGlobs ?? [];
240244
case "conventional-commits":
241245
case "regression-risk":
246+
case "sensitive-paths":
247+
case "risk":
242248
return [catalogSentence(id, pack)];
243249
default:
244250
return ["on"];

0 commit comments

Comments
 (0)