Files
zed/script/danger/dangerfile.ts
T
Finn Evers 0a26ad72e9 Require release notes to be present (#64238)
Release notes are the sole information users have about what could
either have improved or have been broken for them. Omitting release
notes is one thing, but not writing any or having empty ones helps
nobody - users get no context on what could cause issues they were
seeing nor do they know about potential improvements, triagers cannot
easily find the PR that could have introduced the bug either.

Thus, this PR now makes it so that danger actually requires one to write
release notes. Empty release notes or the placeholder will no longer be
accepted.

Release Notes:

- N/A
2026-09-15 13:38:45 +00:00

208 lines
7.5 KiB
TypeScript

import { danger, message, fail, schedule } from "danger";
import { releaseNotesSection, releaseNotesEntries } from "../lib/release-notes";
const { prHygiene } = require("danger-plugin-pr-hygiene");
prHygiene({
prefixPattern: /^([a-z\d\(\)_\s]+):(.*)/g,
rules: {
// Don't enable this rule just yet, as it can have false positives.
useImperativeMood: "off",
noConventionalCommits: {
bannedTypes: ["feat", "fix", "style", "refactor", "perf", "test", "chore", "build", "revert"],
},
},
});
const body = danger.github.pr.body;
const releaseNotes = releaseNotesSection(body);
const entries = releaseNotesEntries(releaseNotes);
const hasReleaseNotes = releaseNotes !== "";
if (!hasReleaseNotes) {
fail(
[
"This PR is missing release notes.",
"",
'Please add a "Release Notes" section that describes the change:',
"",
"```",
"Release Notes:",
"",
"- Added/Fixed/Improved ...",
"```",
"",
'If your change is not user-facing, you can use "N/A" for the entry:',
"```",
"Release Notes:",
"",
"- N/A",
"```",
"",
"If your change touches a `gpui`-related crate, you must also add an entry for the GPUI release notes:",
"```",
"Release Notes:",
"",
"- Added/Fixed/Improved ...",
"- [GPUI] Added/Fixed/Improved ...",
"```",
"",
'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.',
].join("\n"),
);
} else if (
entries.some((entry) => {
const description = entry.replace(/^-\s*(?:\[GPUI\]\s*)?/i, "").trim();
return description === "" || /^(?:N\/A\s+or\s+)?Added\/Fixed\/Improved\s*(?:\.{3}|…)$/i.test(description);
})
) {
fail(
[
"This PR has no proper release notes.",
"",
'Please replace them with "N/A" or a description of the changes.',
].join("\n"),
);
}
const GPUI_RELEASE_NOTES_PATTERN = /^- \[GPUI\]/im;
const gpuiCrates = danger.git.fileMatch("crates/gpui*/**");
if (gpuiCrates.edited || gpuiCrates.deleted) {
if (!entries.some((entry) => GPUI_RELEASE_NOTES_PATTERN.test(entry))) {
const { edited, deleted } = gpuiCrates.getKeyedPaths();
const touchedGpuiCratesStr = [...edited, ...deleted]
.map((file) => "`" + file.split("/")[1] + "`")
.filter((crate, index, self) => self.indexOf(crate) === index)
.join(", ");
fail(
[
`This PR modifies GPUI crates (${touchedGpuiCratesStr}), which requires a GPUI release notes entry.`,
"",
'Please add at least one entry prefixed with `[GPUI]` to the "Release Notes" section:',
"```",
"Release Notes:",
"",
"- Added/Fixed/Improved ...",
"- [GPUI] Added/Fixed/Improved ...",
"```",
"",
'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.',
].join("\n"),
);
}
}
const ISSUE_LINK_PATTERN =
/(?:- )?(?<!(?:Close[sd]?|Fixe[sd]|Resolve[sd]|Implement[sed]|Follow-up of|Part of):?\s+)https:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/gi;
const bodyWithoutReleaseNotes = hasReleaseNotes ? body.slice(0, body.length - releaseNotes.length) : body;
const includesIssueUrl = ISSUE_LINK_PATTERN.test(bodyWithoutReleaseNotes);
if (includesIssueUrl) {
const matches = bodyWithoutReleaseNotes.match(ISSUE_LINK_PATTERN) ?? [];
const issues = matches
.map((match) => match.replace(/^#/, "").replace(/https:\/\/github\.com\/zed-industries\/zed\/issues\//, ""))
.filter((issue, index, self) => self.indexOf(issue) === index);
const issuesToReport = issues.map((issue) => `#${issue}`).join(", ");
message(
[
`This PR includes links to the following GitHub Issues: ${issuesToReport}`,
"If this PR aims to close an issue, please include a `Closes #ISSUE` line at the top of the PR body.",
].join("\n"),
);
}
const SELF_REVIEW_MARKER = "Remove this line to confirm you've reviewed this PR before submitting.";
const readmeModified =
danger.git.modified_files.includes("README.md") || danger.git.created_files.includes("README.md");
if (readmeModified) {
schedule(async () => {
const readmeDiff = await danger.git.diffForFile("README.md");
if (readmeDiff && readmeDiff.added.includes(SELF_REVIEW_MARKER)) {
fail(
"Please self-review your PR before submitting — README.md contains a line that asks to be removed which you should have spotted.",
);
}
});
}
if (danger.git.deleted_files.includes(".rules")) {
fail(
[
"This PR deletes `.rules`, which contains the mandatory agent self-review rule (the `> [!IMPORTANT]` README marker rule).",
"If the deletion is intentional, move that rule to another rules file in the same PR.",
].join("\n"),
);
} else if (danger.git.modified_files.includes(".rules")) {
schedule(async () => {
const rulesDiff = await danger.git.diffForFile(".rules");
if (rulesDiff && rulesDiff.removed.includes(SELF_REVIEW_MARKER) && !rulesDiff.added.includes(SELF_REVIEW_MARKER)) {
fail(
[
"This PR removes the mandatory agent self-review rule from `.rules` (the `> [!IMPORTANT]` README marker rule).",
"It has been dropped by accident before and had to be restored in https://github.com/zed-industries/zed/pull/63384.",
"If changing it is intentional, keep an equivalent rule containing the same marker text in `.rules`.",
].join("\n"),
);
}
});
}
const SCHEMA_CHANGE_ATTESTATION =
"The corresponding database schema migration has been created in the Cloud repo and applied to the production database.";
const MIGRATION_SCHEMA_FILES = [
"crates/collab/migrations/20251208000000_test_schema.sql",
"crates/collab/migrations.sqlite/20221109000000_test_schema.sql",
];
const modifiedSchemaFiles = danger.git.modified_files.filter((file) =>
MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)),
);
if (modifiedSchemaFiles.length > 0) {
if (body.includes(SCHEMA_CHANGE_ATTESTATION)) {
message(
[
"This PR modifies database schema files.",
"",
`The author has attested that ${SCHEMA_CHANGE_ATTESTATION.substring(0, 1).toLowerCase() + SCHEMA_CHANGE_ATTESTATION.substring(1)}`,
].join("\n"),
);
} else {
const modifiedSchemaFilesStr = modifiedSchemaFiles.map((path) => "`" + path + "`").join(", ");
fail(
[
`This PR modifies database schema files (${modifiedSchemaFilesStr}), which requires creating a schema migration in the Cloud repository.`,
"Once the schema migration has been created and applied, please add the following attestation to your PR description: ",
`"${SCHEMA_CHANGE_ATTESTATION}"`,
].join("\n\n"),
);
}
}
const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary.";
const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"];
const modifiedFixtures = danger.git.modified_files.filter((file) =>
FIXTURES_PATHS.some((fixturePath) => file.includes(fixturePath)),
);
if (modifiedFixtures.length > 0) {
if (!body.includes(FIXTURE_CHANGE_ATTESTATION)) {
const modifiedFixturesStr = modifiedFixtures.map((path) => "`" + path + "`").join(", ");
fail(
[
`This PR modifies eval or test fixtures (${modifiedFixturesStr}), which are typically expected to remain unchanged.`,
"If these changes are intentional and required, please add the following attestation to your PR description: ",
`"${FIXTURE_CHANGE_ATTESTATION}"`,
].join("\n\n"),
);
}
}