Split workflow into validate (PR + act) and update (dev bot PR). Add structure checks per GitHub docs, API error validation, and act config.
146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
|
|
|
|
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
|
|
const END_MARKER = "# END GENERATED widget/integration owners";
|
|
|
|
const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [
|
|
{ label: "negation (!)", pattern: /^\s*!/ },
|
|
{ label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ },
|
|
];
|
|
|
|
const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]);
|
|
|
|
const parsePatterns = (line: string) => {
|
|
const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim();
|
|
return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#"));
|
|
};
|
|
|
|
const validateLine = (line: string, lineNumber: number) => {
|
|
const trimmed = line.trim();
|
|
if (trimmed.length === 0 || trimmed.startsWith("#")) return [];
|
|
|
|
const errors: string[] = [];
|
|
|
|
for (const check of UNSUPPORTED_PATTERN_CHECKS) {
|
|
if (check.pattern.test(line)) {
|
|
errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`);
|
|
}
|
|
}
|
|
|
|
const owners = parseOwners(line);
|
|
const patterns = parsePatterns(line);
|
|
|
|
if (patterns.length === 0 && owners.length === 0) return errors;
|
|
|
|
if (patterns.length > 0 && owners.length === 0) return errors;
|
|
|
|
if (owners.length > 0 && patterns.length === 0) {
|
|
errors.push(`line ${lineNumber}: owners without file patterns`);
|
|
}
|
|
|
|
for (const pattern of patterns) {
|
|
const allowedGlobal = pattern === "*";
|
|
const hasValidShape =
|
|
allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes(".");
|
|
if (!hasValidShape) {
|
|
errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`);
|
|
}
|
|
}
|
|
|
|
for (const owner of owners) {
|
|
if (!owner.startsWith("@")) {
|
|
errors.push(`line ${lineNumber}: invalid owner "${owner}"`);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
};
|
|
|
|
const validateStructure = (content: string) => {
|
|
const errors: string[] = [];
|
|
const lines = content.split("\n");
|
|
|
|
if (!content.includes("* @")) {
|
|
errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs");
|
|
}
|
|
|
|
const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim()));
|
|
const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER));
|
|
const endIndex = lines.findIndex((line) => line.includes(END_MARKER));
|
|
|
|
if (beginIndex === -1 || endIndex === -1) {
|
|
errors.push("missing generated CODEOWNERS markers");
|
|
return errors;
|
|
}
|
|
|
|
if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) {
|
|
errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers");
|
|
}
|
|
|
|
if (endIndex <= beginIndex) {
|
|
errors.push("END GENERATED marker must follow BEGIN GENERATED marker");
|
|
}
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const lineErrors = validateLine(lines[index] ?? "", index + 1);
|
|
errors.push(...lineErrors);
|
|
}
|
|
|
|
return errors;
|
|
};
|
|
|
|
const validateGithubApi = async () => {
|
|
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr";
|
|
|
|
if (!token) {
|
|
console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set).");
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { errors: unknown[] };
|
|
const apiErrors = payload.errors ?? [];
|
|
|
|
if (apiErrors.length > 0) {
|
|
console.error("GitHub reported CODEOWNERS syntax errors:");
|
|
console.error(JSON.stringify(apiErrors, null, 2));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("GitHub API: no CODEOWNERS syntax errors.");
|
|
};
|
|
|
|
const main = async () => {
|
|
const content = await readFile(CODEOWNERS_PATH, "utf8");
|
|
const errors = validateStructure(content);
|
|
|
|
if (errors.length > 0) {
|
|
console.error("CODEOWNERS validation failed:");
|
|
for (const error of errors) {
|
|
console.error(` ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("CODEOWNERS structure validation passed.");
|
|
await validateGithubApi();
|
|
};
|
|
|
|
await main();
|