-
Notifications
You must be signed in to change notification settings - Fork 2
docs: codify issue template frontmatter policy (#880) #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /** | ||
| * Smoke test for the issue template label validator. | ||
| */ | ||
| const path = require("path"); | ||
| const { execFileSync } = require("child_process"); | ||
|
|
||
| describe("check-template-labels.js", () => { | ||
| it("validates the current issue template frontmatter and mappings", () => { | ||
| const scriptPath = path.join(__dirname, "../check-template-labels.js"); | ||
| const output = execFileSync(process.execPath, [scriptPath], { | ||
| cwd: path.join(__dirname, "../../../.."), | ||
| encoding: "utf8", | ||
| }); | ||
|
|
||
| expect(output).toContain( | ||
| "All template frontmatter, labels, and type mappings are valid.", | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,8 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * check-template-labels.js | ||
| * Validates that all labels referenced in issue/PR templates exist in labels.yml | ||
| * Validates issue-template frontmatter, canonical labels, and template-to-type mappings. | ||
| */ | ||
| // TODO: Align this helper with the latest automation spec updates. | ||
|
|
||
| import fs from "fs"; | ||
| import yaml from "js-yaml"; | ||
|
|
@@ -27,10 +26,114 @@ const ISSUE_TEMPLATE_DIR = resolveFromRoot( | |
| ".github/ISSUE_TEMPLATE", | ||
| ); | ||
|
|
||
| const TEMPLATE_TYPE_MAP = { | ||
| "01-task.md": { | ||
| primaryType: "type:task", | ||
| }, | ||
| "02-bug.md": { | ||
| primaryType: "type:bug", | ||
| }, | ||
| "03-feature.md": { | ||
| primaryType: "type:feature", | ||
| secondaryTypes: ["type:enhancement"], | ||
| }, | ||
| "04-design.md": { | ||
| primaryType: "type:design", | ||
| secondaryTypes: ["type:ui", "type:a11y"], | ||
| }, | ||
| "05-epic.md": { | ||
| primaryType: "type:epic", | ||
| }, | ||
| "06-story.md": { | ||
| primaryType: "type:story", | ||
| }, | ||
| "07-improvement.md": { | ||
| primaryType: "type:improve", | ||
| secondaryTypes: ["type:enhancement"], | ||
| }, | ||
| "08-user-experience-feedback.md": { | ||
| primaryType: "type:ux-feedback", | ||
| }, | ||
| "09-code-refactor.md": { | ||
| primaryType: "type:refactor", | ||
| secondaryTypes: ["type:maintenance", "type:chore"], | ||
| }, | ||
| "10-build-ci.md": { | ||
| primaryType: "type:build", | ||
| secondaryTypes: ["type:ci"], | ||
| }, | ||
| "11-automation.md": { | ||
| primaryType: "type:automation", | ||
| }, | ||
| "12-testing-coverage.md": { | ||
| primaryType: "type:test", | ||
| secondaryTypes: ["type:qa"], | ||
| }, | ||
| "13-performance.md": { | ||
| primaryType: "type:performance", | ||
| }, | ||
| "14-a11y.md": { | ||
| primaryType: "type:a11y", | ||
| }, | ||
| "15-security.md": { | ||
| primaryType: "type:security", | ||
| }, | ||
| "16-compatibility.md": { | ||
| primaryType: "type:compatibility", | ||
| }, | ||
| "17-integration-issue.md": { | ||
| primaryType: "type:integration", | ||
| secondaryTypes: ["type:dependency"], | ||
| }, | ||
| "18-release.md": { | ||
| primaryType: "type:release", | ||
| }, | ||
| "19-maintenance.md": { | ||
| primaryType: "type:maintenance", | ||
| secondaryTypes: ["type:chore"], | ||
| }, | ||
| "20-documentation.md": { | ||
| primaryType: "type:documentation", | ||
| }, | ||
| "21-research.md": { | ||
| primaryType: "type:research", | ||
| secondaryTypes: ["type:investigation"], | ||
| }, | ||
| "22-audit.md": { | ||
| primaryType: "type:audit", | ||
| }, | ||
| "23-code-review.md": { | ||
| primaryType: "type:review", | ||
| }, | ||
| "24-ai-ops.md": { | ||
| primaryType: "type:ai-ops", | ||
| }, | ||
| "25-content-modelling.md": { | ||
| primaryType: "type:content-modelling", | ||
| }, | ||
| "26-help.md": { | ||
| primaryType: "type:help", | ||
| secondaryTypes: ["type:question", "type:support"], | ||
| }, | ||
| }; | ||
|
|
||
| function loadYaml(file) { | ||
| return yaml.load(fs.readFileSync(file, "utf8")); | ||
| } | ||
|
|
||
| function extractFrontmatter(content) { | ||
| const match = content.match(/^---\n([\s\S]*?)\n---\n?/); | ||
| if (!match) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| return yaml.load(match[1]) || {}; | ||
| } catch (error) { | ||
| return { __error: error.message }; | ||
| } | ||
| } | ||
|
|
||
| function getCanonicalLabels() { | ||
| const labels = loadYaml(LABELS_FILE); | ||
| return new Set( | ||
|
|
@@ -62,7 +165,7 @@ function getIssueTypeLabels() { | |
| function getTemplateLabels() { | ||
| const files = fs | ||
| .readdirSync(ISSUE_TEMPLATE_DIR) | ||
| .filter((f) => f.endsWith(".md")); | ||
| .filter((f) => /^\d{2}-.+\.md$/u.test(f)); | ||
| const labelRegex = /labels?:\s*\[([^\]]+)\]|labels?:\s*([^\n]+)/gi; | ||
| const labels = new Set(); | ||
| for (const file of files) { | ||
|
|
@@ -86,19 +189,143 @@ function getTemplateLabels() { | |
| return labels; | ||
| } | ||
|
|
||
| function normaliseTemplateLabels(labelsValue) { | ||
| if (!labelsValue) { | ||
| return []; | ||
| } | ||
|
|
||
| if (Array.isArray(labelsValue)) { | ||
| return labelsValue.flatMap((label) => | ||
| typeof label === "string" ? [label.trim()] : [], | ||
| ); | ||
| } | ||
|
|
||
| if (typeof labelsValue === "string") { | ||
| return labelsValue | ||
| .split(",") | ||
| .map((label) => label.replace(/['"\][]]/gu, "").trim()) | ||
| .filter(Boolean); | ||
| } | ||
|
|
||
| return []; | ||
| } | ||
|
|
||
| function validateTemplateFrontmatter(file, frontmatter, canonicalLabels, issueTypeLabels) { | ||
| const requiredKeys = [ | ||
| "file_type", | ||
| "name", | ||
| "description", | ||
| "version", | ||
| "last_updated", | ||
| "category", | ||
| ]; | ||
|
|
||
| if (!frontmatter) { | ||
| throw new Error(`Missing YAML frontmatter in ${file}`); | ||
| } | ||
|
|
||
| if (frontmatter.__error) { | ||
| throw new Error(`Invalid YAML frontmatter in ${file}: ${frontmatter.__error}`); | ||
| } | ||
|
|
||
| for (const key of requiredKeys) { | ||
| if (!frontmatter[key]) { | ||
| throw new Error(`Missing required frontmatter key "${key}" in ${file}`); | ||
| } | ||
| } | ||
|
|
||
| if (frontmatter.file_type !== "issue-template") { | ||
| throw new Error( | ||
| `Unexpected file_type in ${file}: expected "issue-template", found "${frontmatter.file_type}"`, | ||
| ); | ||
| } | ||
|
|
||
| const declaredLabels = normaliseTemplateLabels(frontmatter.labels); | ||
| const declaredTypes = normaliseTemplateLabels(frontmatter.type).filter((label) => | ||
| label.startsWith("type:"), | ||
| ); | ||
|
|
||
| for (const label of declaredLabels) { | ||
| if (label && !canonicalLabels.has(label)) { | ||
| throw new Error(`Unknown label "${label}" referenced in ${file}`); | ||
| } | ||
| } | ||
|
|
||
| for (const typeLabel of declaredTypes) { | ||
| if (!issueTypeLabels.has(typeLabel)) { | ||
| throw new Error(`Unknown issue type "${typeLabel}" referenced in ${file}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function main() { | ||
| const canonical = getCanonicalLabels(); | ||
| const issueTypeLabels = getIssueTypeLabels(); | ||
| const templateLabels = getTemplateLabels(); | ||
| const templateFiles = fs | ||
| .readdirSync(ISSUE_TEMPLATE_DIR) | ||
| .filter((f) => /^\d{2}-.+\.md$/u.test(f)); | ||
| const all = new Set([...issueTypeLabels, ...templateLabels]); | ||
| const unknown = [...all].filter((l) => l && !canonical.has(l)); | ||
| if (unknown.length) { | ||
| console.error("Unknown labels found in templates or issue-types.yml:"); | ||
| for (const l of unknown) console.error(` - ${l}`); | ||
| process.exit(1); | ||
| } else { | ||
| console.log("All template and type labels are valid."); | ||
| } | ||
|
|
||
| const templateMapEntries = Object.entries(TEMPLATE_TYPE_MAP); | ||
| const missingMappedFiles = templateFiles.filter( | ||
| (file) => !TEMPLATE_TYPE_MAP[file], | ||
| ); | ||
| if (missingMappedFiles.length > 0) { | ||
| console.error("Missing template-to-type mapping entries:"); | ||
| for (const file of missingMappedFiles) console.error(` - ${file}`); | ||
| process.exit(1); | ||
| } | ||
|
Comment on lines
+277
to
+284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current validation only checks if all existing template files are defined in const templateFilesSet = new Set(templateFiles);
const missingMappedFiles = templateFiles.filter(
(file) => !TEMPLATE_TYPE_MAP[file],
);
const extraMappedFiles = templateMapEntries
.map(([file]) => file)
.filter((file) => !templateFilesSet.has(file));
if (missingMappedFiles.length > 0 || extraMappedFiles.length > 0) {
if (missingMappedFiles.length > 0) {
console.error("Missing template-to-type mapping entries:");
for (const file of missingMappedFiles) console.error(\` - \${file}\`);
}
if (extraMappedFiles.length > 0) {
console.error("Template-to-type mapping contains non-existent files:");
for (const file of extraMappedFiles) console.error(\` - \${file}\`);
}
process.exit(1);
} |
||
|
|
||
| const mappedTypes = new Set(); | ||
| for (const [file, mapping] of templateMapEntries) { | ||
| mappedTypes.add(mapping.primaryType); | ||
| for (const secondaryType of mapping.secondaryTypes || []) { | ||
| mappedTypes.add(secondaryType); | ||
| } | ||
|
|
||
| const content = fs.readFileSync(path.join(ISSUE_TEMPLATE_DIR, file), "utf8"); | ||
| const frontmatter = extractFrontmatter(content); | ||
| validateTemplateFrontmatter(file, frontmatter, canonical, issueTypeLabels); | ||
|
|
||
| const declaredTypes = [ | ||
| ...normaliseTemplateLabels(frontmatter?.type).filter((label) => | ||
| label.startsWith("type:"), | ||
| ), | ||
| ...normaliseTemplateLabels(frontmatter?.labels).filter((label) => | ||
| label.startsWith("type:"), | ||
| ), | ||
| ]; | ||
|
|
||
| if (declaredTypes.length > 0) { | ||
| const allowedTypes = new Set([ | ||
| mapping.primaryType, | ||
| ...(mapping.secondaryTypes || []), | ||
| ]); | ||
|
|
||
| const invalidTypes = declaredTypes.filter((typeLabel) => !allowedTypes.has(typeLabel)); | ||
| if (invalidTypes.length > 0) { | ||
| console.error(`Unexpected template-to-type mapping in ${file}:`); | ||
| for (const typeLabel of invalidTypes) console.error(` - ${typeLabel}`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const unknownMappedTypes = [...mappedTypes].filter((typeLabel) => !issueTypeLabels.has(typeLabel)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a new canonical issue type is added to Useful? React with 👍 / 👎. |
||
| if (unknownMappedTypes.length > 0) { | ||
| console.error("Template map references unknown issue types:"); | ||
| for (const typeLabel of unknownMappedTypes) console.error(` - ${typeLabel}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log("All template frontmatter, labels, and type mappings are valid."); | ||
| } | ||
|
|
||
| main(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regular expression used to extract frontmatter expects Unix-style line endings (
\n). If the repository is checked out on Windows withcore.autocrlfenabled, the files will have CRLF (\r\n) line endings, causing this match to fail and returnnull. This will lead to false validation failures. Updating the regex to support optional carriage returns (\r?) ensures cross-platform compatibility.