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
1 change: 1 addition & 0 deletions apps/web/src/lib/triage-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const FACTS: Record<string, (value: number | boolean | string) => string> = {
hasConflicts: (value) => (value ? "has conflicts" : "no conflicts"),
touchesForbiddenPaths: (value) => (value ? "touches forbidden paths" : "no forbidden paths"),
linkedIssue: (value) => (value ? "issue linked" : "no issue linked"),
conventional: (value) => (value ? "conventional commits" : "not conventional commits"),
files: (value) => counted(value, "file"),
lines: (value) => counted(value, "line"),
approvals: (value) => counted(value, "approval"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ A rule asks one question about a pull request and answers pass, fail or unconfir
- **Flag merge conflicts with the base branch.**
- **CI must pass before merge.** The repository's automated checks must be green. While they are still running, the rule is unconfirmed rather than passed.
- **Never accept changes to** certain paths, such as vendored code.
- **Require a conventional commit title and messages.** The title and the first line of each commit must start with a type, such as `feat: add search` or `fix(api)!: drop v1`. The types are feat, fix, refactor, perf, docs, test, chore, ci, build, style and revert unless the file lists its own, and the file can check only the title or only the commits. Case does not matter. Merges and the `Revert "…"` commits GitHub's revert button makes are skipped, and only the first 100 commits are read. When it fails, Triage names each title or commit that doesn't follow the format.
- **Fail when …**, a text rule of your own, such as "Fail when any commit message contains "Co-Authored-By:"". It names where to look (the title, the description, any commit message, or several of them), whether the rule fails when the text **contains** or **does not contain** its phrases, and the phrases. Case does not matter. Only the first 100 commits are read. The rule's evidence quotes the line that matched. Text rules count toward the eight rules of your own.
- **Judgement**, questions the decision model answers from the diff and the documentation:
- **Check the change is focused on one thing.** On a stacked branch, one another pull request was merged into or one based on another open pull request's branch, this rule is skipped, and the comment says why, such as "stacked branch, includes #1521".
Expand Down
9 changes: 6 additions & 3 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ The same pull request and policy give the same result. A rule is unconfirmed
only while the host has not computed what it needs, such as mergeability or CI.

Built in: Max files changed, Max lines changed, Linked issue (GitHub, Linear or
either), No merge conflicts, CI green, No forbidden paths. Two always run:
either), No merge conflicts, CI green, No forbidden paths, Conventional commits
(the title and each of the first 100 commits' first line start
`type(scope)!: `, in any case; merges and reverts skipped). Two always run:
Not a draft, and No changes requested (a requested change fails and waits on
the author, then on the reviewer once the author pushes or replies). The
recommended forbidden paths are `vendor/**`, `node_modules/**` and
Expand Down Expand Up @@ -242,8 +244,9 @@ rule's id, `model` for a yes/no question about the whole pull request, or
`text` for a phrase match) and why it exists: a one-line `reason` and its
`evidence` (`#44`, `owner/name#44`, links or `maintainer-stated`). All four
are required. A model or text rule names its own `id`. A built-in rule takes
its setting as `max` (size limits), `tracker` (linked issue) or `paths`
(forbidden paths); `enabled: false` keeps it written down but off.
its setting as `max` (size limits), `tracker` (linked issue), `paths`
(forbidden paths) or `types` and `fields` (conventional commits);
`enabled: false` keeps it written down but off.

```yaml
rules:
Expand Down
28 changes: 28 additions & 0 deletions packages/triage-contracts/src/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const CATALOG_IDS = [
"no-merge-conflicts",
"ci-green",
"no-forbidden-paths",
"conventional-commits",
] as const;
export type CatalogId = (typeof CATALOG_IDS)[number];

Expand All @@ -38,6 +39,7 @@ export const CHECK_CATALOG: Record<CatalogId, { name: string; group: CheckPackGr
"no-merge-conflicts": { name: "No merge conflicts", group: "around", kind: "machine" },
"ci-green": { name: "CI green", group: "code-vs-ci", kind: "machine" },
"no-forbidden-paths": { name: "No forbidden paths", group: "code-vs-ci", kind: "machine" },
"conventional-commits": { name: "Conventional commits", group: "pull-request", kind: "machine" },
};

export const NOT_A_DRAFT = "not-a-draft";
Expand Down Expand Up @@ -96,6 +98,11 @@ export const RECOMMENDED_SIZE = { maxFiles: 30, maxLines: 3000 } as const;
// An author who edits `.github/**` can change which checks run and turn CI green.
export const RECOMMENDED_PATHS = { forbiddenGlobs: ["vendor/**", "node_modules/**", ".github/**"] } as const;
export const RECOMMENDED_ISSUE = { tracker: "either" } as const;
export const CONVENTIONAL_TYPES = ["feat", "fix", "refactor", "perf", "docs", "test", "chore", "ci", "build", "style", "revert"] as const;
export const CONVENTIONAL_FIELDS = ["title", "commits"] as const;
export type ConventionalField = (typeof CONVENTIONAL_FIELDS)[number];
export const CONVENTIONAL_TYPE = /^[\w-]+$/;

export const RECOMMENDED_TEXT: Readonly<TextCustomCheck> = {
id: "no-attribution-trailers",
name: "No attribution trailers",
Expand All @@ -121,6 +128,9 @@ export type CatalogCheck = RuleNote & {
maxLines?: number;
tracker?: IssueTracker;
forbiddenGlobs?: string[];
/** Conventional commits: the types allowed, and whether the title, the commits or both are read; absent is every default type, and both. */
types?: string[];
fields?: ConventionalField[];
};

export const MODEL_SHAPES = ["is-true", "score", "choose"] as const;
Expand Down Expand Up @@ -366,6 +376,8 @@ const CatalogCheckSchema = type({
"maxLines?": "number",
"tracker?": type.enumerated(...ISSUE_TRACKERS),
"forbiddenGlobs?": "string[]",
"types?": "string[]",
"fields?": type.enumerated(...CONVENTIONAL_FIELDS).array(),
});

export const CustomId = type(CUSTOM_ID).atMostLength(CUSTOM_ID_MAX);
Expand Down Expand Up @@ -547,9 +559,25 @@ function parseCatalogCheck(id: CatalogId, raw: unknown): CatalogCheck | undefine
const globs = parseGlobs(row.forbiddenGlobs, "No forbidden paths globs");
if (globs) check.forbiddenGlobs = globs;
}
if (id === "conventional-commits") {
if (row.types !== undefined) check.types = commitTypes(row.types);
if (row.fields !== undefined) check.fields = conventionalFields(row.fields);
}
return check;
}

function commitTypes(value: unknown): string[] {
const types = texts(value, "Conventional commits types");
const bad = types.find((name) => !CONVENTIONAL_TYPE.test(name));
if (bad !== undefined) throw new Error(`Conventional commits types must be single words, not ${bad}.`);
return types;
}

function conventionalFields(value: unknown): ConventionalField[] {
const fields = new Set(list(value, "Conventional commits fields").map((field) => oneOf(field, CONVENTIONAL_FIELDS, "Conventional commits fields")));
return CONVENTIONAL_FIELDS.filter((field) => fields.has(field));
}

/** An old size row that set only one limit never measured the other, so that half loads switched off. */
function sizeLimitMissing(id: string, raw: unknown): boolean {
const row = asRecord(raw);
Expand Down
10 changes: 9 additions & 1 deletion packages/triage-contracts/src/policy-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { dump, JSON_SCHEMA } from "js-yaml";
import {
AUTHOR_GROUPS,
CATALOG_IDS,
CONVENTIONAL_FIELDS,
CONVENTIONAL_TYPES,
POLICY_EVENTS,
VERDICT_LABELS,
VERDICTS,
Expand Down Expand Up @@ -44,6 +46,8 @@ function catalogParams(id: CatalogId, row: CatalogCheck): Record<string, unknown
return row.tracker === undefined ? {} : { tracker: row.tracker };
case "no-forbidden-paths":
return row.forbiddenGlobs?.length ? { paths: row.forbiddenGlobs } : undefined;
case "conventional-commits":
return { ...(row.types && { types: row.types }), ...(row.fields && { fields: row.fields }) };
default:
return {};
}
Expand Down Expand Up @@ -137,7 +141,8 @@ function catalogChecks(pack: CheckPack, id: CatalogId): string | undefined {
const row = pack.checks[id];
if (id === "ci-green" && row?.enabled === false) return CI_SKIPPED;
if (!row?.enabled) return undefined;
return canonical({ maxFiles: row.maxFiles, maxLines: row.maxLines, tracker: row.tracker ?? "either", paths: sorted(row.forbiddenGlobs) });
const conventional = id === "conventional-commits" && { types: sorted(row.types ?? CONVENTIONAL_TYPES), fields: row.fields ?? CONVENTIONAL_FIELDS };
return canonical({ maxFiles: row.maxFiles, maxLines: row.maxLines, tracker: row.tracker ?? "either", paths: sorted(row.forbiddenGlobs), ...conventional });
}

function modelAsks(row: ModelCustomCheck): Record<string, unknown> {
Expand Down Expand Up @@ -180,6 +185,7 @@ const CATALOG_RULES: Partial<Record<CatalogId, string>> = {
"max-lines-changed": "Most lines changed",
"linked-issue": "Require a linked issue from",
"no-forbidden-paths": "Never accept changes to",
"conventional-commits": "Conventional commits",
};

// CI green has three states: required, absent (merges still wait for CI) and written off (merges skip it).
Expand All @@ -199,6 +205,8 @@ function catalogValues(pack: CheckPack, id: CatalogId): string[] {
return [ISSUE_TRACKER_NAMES[row.tracker && row.tracker !== "off" ? row.tracker : "either"]];
case "no-forbidden-paths":
return row.forbiddenGlobs ?? [];
case "conventional-commits":
return [catalogSentence(id, pack)];
default:
return ["on"];
}
Expand Down
5 changes: 4 additions & 1 deletion packages/triage-contracts/src/policy-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// the stored policy's reader stays the one authority on what a policy may hold.
import { type } from "arktype";
import { asRecord } from "@corbits/common";
import { CATALOG_IDS, CustomId, Evidence, ISSUE_TRACKERS, Label, POLICY_EVENTS, stepListSchema, Text, TEXT_FIELDS, TEXT_MATCHES, VERDICTS, withAuthorKinds, type CatalogId } from "./check-pack.js";
import { CATALOG_IDS, CONVENTIONAL_FIELDS, CONVENTIONAL_TYPE, CustomId, Evidence, ISSUE_TRACKERS, Label, POLICY_EVENTS, stepListSchema, Text, TEXT_FIELDS, TEXT_MATCHES, VERDICTS, withAuthorKinds, type CatalogId } from "./check-pack.js";

const Lines = Text.array().atLeastLength(1).atMostLength(50);
const Limit = type("number.integer").atLeast(1);
Expand All @@ -17,6 +17,7 @@ const SIMPLE_CHECKS = ["focused", "docs", "no-merge-conflicts", "ci-green"] as c
const RuleSchema = type({ ...RULE_BASE, check: "'max-files-changed' | 'max-lines-changed'", max: Limit })
.or({ ...RULE_BASE, check: "'linked-issue'", "tracker?": type.enumerated(...ISSUE_TRACKERS.filter((tracker) => tracker !== "off")) })
.or({ ...RULE_BASE, check: "'no-forbidden-paths'", paths: Lines })
.or({ ...RULE_BASE, check: "'conventional-commits'", "types?": type(CONVENTIONAL_TYPE).describe("a single word").array().atLeastLength(1).atMostLength(50), "fields?": type.enumerated(...CONVENTIONAL_FIELDS).array().atLeastLength(1) })
.or({ ...RULE_BASE, check: type.enumerated(...SIMPLE_CHECKS) })
.or({ ...OWN_RULE, check: "'model'" })
.or({ ...OWN_RULE, check: "'text'", fields: type.enumerated(...TEXT_FIELDS).array().atLeastLength(1), failWhen: type.enumerated(...TEXT_MATCHES), phrases: Lines });
Expand Down Expand Up @@ -66,6 +67,8 @@ function catalogRow(rule: Extract<PolicyRule, { check: CatalogId }>): Record<str
return rule.tracker === undefined ? row : { ...row, tracker: rule.tracker };
case "no-forbidden-paths":
return { ...row, forbiddenGlobs: rule.paths };
case "conventional-commits":
return { ...row, ...(rule.types && { types: rule.types }), ...(rule.fields && { fields: rule.fields }) };
default:
return row;
}
Expand Down
11 changes: 10 additions & 1 deletion packages/triage-contracts/src/rule-prose.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Plain-text words for a policy's rules and Dos; only the browser extension uses them today.
import { RECOMMENDED_ISSUE, type ActionKind, type CatalogCheck, type CatalogId, type CheckPack, type CustomCheck, type IssueTracker, type ModelShape, type TextCheck, type TextField, type TextMatch } from "./check-pack.js";
import { CONVENTIONAL_FIELDS, RECOMMENDED_ISSUE, type ActionKind, type CatalogCheck, type CatalogId, type CheckPack, type CustomCheck, type IssueTracker, type ModelShape, type TextCheck, type TextField, type TextMatch } from "./check-pack.js";
import type { ResolvedTarget } from "./do.js";

export const ISSUE_TRACKER_NAMES: Record<IssueTracker, string> = { github: "GitHub", linear: "Linear", either: "GitHub or Linear", off: "Not required" };
Expand All @@ -8,13 +8,22 @@ function count(value: number | undefined): string {
return value?.toLocaleString() ?? "any number of";
}

const CONVENTIONAL_SUBJECTS = { title: "a conventional commit title", commits: "conventional commit messages", both: "a conventional commit title and messages" } as const;

function conventionalSentence(row: CatalogCheck | undefined): string {
const fields = row?.fields ?? CONVENTIONAL_FIELDS;
const subject = CONVENTIONAL_SUBJECTS[fields.length === 1 ? fields[0]! : "both"];
return `Require ${subject}${row?.types ? ` of type ${row.types.join(", ")}` : ""}`;
}

const CATALOG_SENTENCES: Record<CatalogId, (row: CatalogCheck | undefined) => string> = {
"max-files-changed": (row) => `Flag pull requests over ${count(row?.maxFiles)} files changed`,
"max-lines-changed": (row) => `Flag pull requests over ${count(row?.maxLines)} lines changed`,
"linked-issue": (row) => `Require a linked issue from ${ISSUE_TRACKER_NAMES[row?.tracker && row.tracker !== "off" ? row.tracker : RECOMMENDED_ISSUE.tracker]}`,
"no-merge-conflicts": () => "Flag merge conflicts with the base branch",
"ci-green": () => "CI must pass before merge",
"no-forbidden-paths": (row) => `Never accept changes to ${(row?.forbiddenGlobs ?? []).join(", ") || "forbidden paths"}`,
"conventional-commits": conventionalSentence,
focused: () => "Check the change is focused on one thing",
docs: () => "Check docs are updated when behavior changes",
};
Expand Down
28 changes: 27 additions & 1 deletion packages/triage-workflows/src/logic/checks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_REPO_POLICY, emptyPack, readCheckPack, recommendedPack, repoPolicy, type RepoPolicy } from "@corbits/triage-contracts";
import { DEFAULT_REPO_POLICY, emptyPack, readCheckPack, readPolicyFile, recommendedPack, repoPolicy, type RepoPolicy } from "@corbits/triage-contracts";
import { deriveState, REVIEW_ANSWERED_REASON, type PrFacts } from "./checks.ts";
import { NO_FACTS } from "./item.ts";

Expand Down Expand Up @@ -209,3 +209,29 @@ describe("text rules", () => {
expect(result.needsJudgment).toBe(false);
});
});

describe("conventional commits", () => {
const open: PrFacts = { ...noisy, draft: false, mergeable: true, checks: "success" };
const RULE = "rules:\n - check: conventional-commits\n text: Conventional.\n reason: History.\n evidence: [maintainer-stated]\n";

function conventional(title: string, commitMessages: string[], settings = "") {
const pack = readCheckPack(readPolicyFile(`${RULE}${settings}`, "acme/widgets"));
return deriveState({ ...open, title, commitMessages }, undefined, pack).checks.find((check) => check.check === "conventional-commits");
}

test("a type in any case, a scope and a breaking mark pass, and merges and reverts are skipped", () => {
expect(conventional("feat(api)!: drop v1", ["Fix: login\n\nBody", "Merge pull request #4 from acme/fix", "Merge pull request #12 in PROJ/widgets from fix to main", "Merge branch 'main' into fix", 'Revert "feat: login"', "chore(deps): bump"])).toMatchObject({ result: "pass", reason: "follows conventional commits" });
});

test("an unknown type, a missing colon or space, and a type the policy leaves out each fail, named", () => {
expect(conventional("feature: login", ["fix login", "fix:login", "fix(): login"])).toMatchObject({
result: "fail",
reason: "`feature: login` isn't a conventional commit; `fix login` isn't a conventional commit; `fix:login` isn't a conventional commit; and 1 more",
});
expect(conventional("feat: login", [], " types: [fix]\n")).toMatchObject({ result: "fail", reason: "`feat: login` isn't a conventional commit" });
});

test("title-only reads no commits", () => {
expect(conventional("fix: login", ["Add tests"], " fields: [title]\n")).toMatchObject({ result: "pass" });
});
});
38 changes: 38 additions & 0 deletions packages/triage-workflows/src/logic/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
catalogCheckEnabled,
CLOSED_FINDING,
classificationSources,
CONVENTIONAL_FIELDS,
CONVENTIONAL_TYPES,
customCheckEnabled,
isTextCheck,
parseCheckPack,
Expand All @@ -12,6 +14,7 @@ import {
type CatalogId,
type CheckOutput,
type CheckPack,
type ConventionalField,
type IssueTracker,
type TextCustomCheck,
type TriageEvent,
Expand Down Expand Up @@ -225,12 +228,41 @@ function textResult(rule: TextCustomCheck, facts: PrFacts): { failed: boolean; r
return { failed: missing.length > 0, reason: missing.length ? `${what} does not contain ${phrases}` : `contains ${phrases}`, evidence: missing.map((item) => item.where).slice(0, TEXT_EVIDENCE_MAX) };
}

// `type(scope)!: subject`; the type is checked against the policy's list once it parses, ignoring case as the spec asks.
const CONVENTIONAL_SUBJECT = /^([^\s():!]+)(?:\([^()]+\))?!?: \S/;
// Subjects git and the hosts write themselves: merges, and the revert button's `Revert "…"`.
const GENERATED_SUBJECT = /^(?:Merge (?:pull request|branch|remote-tracking branch|commit|tag) |Revert ")/;
const OFFENDERS_SHOWN = 3;

type ConventionalRule = { types: readonly string[]; fields: readonly ConventionalField[] };

function isConventional(subject: string, types: readonly string[]): boolean {
const type = CONVENTIONAL_SUBJECT.exec(subject)?.[1]?.toLowerCase();
return type !== undefined && types.some((allowed) => allowed.toLowerCase() === type);
}

function offenderReason(subject: string): string {
return `\`${excerpt(subject).replaceAll("`", "'")}\` isn't a conventional commit`;
}

/** Each title or commit subject that is not `type(scope)!: subject` with an allowed type; merges and reverts are left out. */
function conventionalResult(facts: PrFacts, { types, fields }: ConventionalRule): { failed: boolean; reason: string } {
const commits = facts.commitMessages.map((message) => message.split("\n", 1)[0]!);
const subjects = [...(fields.includes("title") ? [facts.title] : []), ...(fields.includes("commits") ? commits : [])].map((subject) => subject.trim());
const offenders = [...new Set(subjects.filter((subject) => !GENERATED_SUBJECT.test(subject) && !isConventional(subject, types)))];
if (offenders.length === 0) return { failed: false, reason: "follows conventional commits" };
const shown = offenders.slice(0, OFFENDERS_SHOWN).map(offenderReason);
const more = offenders.length - shown.length;
return { failed: true, reason: [...shown, ...(more > 0 ? [`and ${more} more`] : [])].join("; ") };
}

interface CheckRules {
enabled(id: CatalogId): boolean;
maxFiles?: number;
maxLines?: number;
forbiddenGlobs: string[];
tracker: IssueTracker;
conventional?: ConventionalRule;
}

function resultOf(failed: boolean | null): CheckResult["result"] {
Expand Down Expand Up @@ -320,6 +352,11 @@ function evaluate(facts: PrFacts, rules: CheckRules, pack?: CheckPack): Determin
values: { linkedIssue: ref !== null },
});
}
if (on("conventional-commits") && rules.conventional) {
const { failed, reason } = conventionalResult(facts, rules.conventional);
if (failed) add("conventional-commits", "needs-author-update", reason);
result("conventional-commits", failed, reason, [], { kind: "values", values: { conventional: !failed } });
}
for (const rule of pack?.custom.filter(isTextCheck).filter(customCheckEnabled) ?? []) {
const { failed, reason, evidence } = textResult(rule, facts);
if (failed) add(rule.id, "needs-author-update", reason);
Expand All @@ -337,6 +374,7 @@ function deriveFromPack(facts: PrFacts, pack: CheckPack): DeterministicResult {
maxLines: pack.checks["max-lines-changed"]?.maxLines,
forbiddenGlobs: pack.checks["no-forbidden-paths"]?.forbiddenGlobs ?? [],
tracker: pack.checks["linked-issue"]?.tracker ?? "either",
conventional: { types: pack.checks["conventional-commits"]?.types ?? CONVENTIONAL_TYPES, fields: pack.checks["conventional-commits"]?.fields ?? CONVENTIONAL_FIELDS },
}, pack);
}

Expand Down
Loading
Loading