Skip to content

Commit 7e43692

Browse files
committed
Eliminating redundancies
1 parent b88976b commit 7e43692

8 files changed

Lines changed: 228 additions & 264 deletions

File tree

.github/workflows/validate-configs.yml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,5 @@ jobs:
2121
with:
2222
node-version: "20"
2323

24-
- name: Validate label sync configuration
25-
run: node scripts/sync-labels.mjs --validate-only
26-
27-
- name: Validate remove-labels configuration
28-
run: node scripts/remove-labels.mjs --validate-only
24+
- name: Validate configuration
25+
run: node scripts/validate-configs.mjs

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Trigger:
185185
What it does:
186186

187187
1. Checks out the repo
188-
2. Runs `node scripts/sync-labels.mjs --validate-only`
188+
2. Runs `node scripts/validate-configs.mjs`
189189

190190
Validation includes:
191191

scripts/export-properties.mjs

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,19 @@
11
import path from "node:path";
22
import fs from "node:fs/promises";
3-
import { assert, readJsonc } from "./lib/config-utils.mjs";
3+
import { readJsonc } from "./lib/config-utils.mjs";
4+
import { validateProperties } from "./lib/config-validation.mjs";
45

56
const workspaceRoot = process.cwd();
67
const propertiesPath = path.join(workspaceRoot, "config", "properties.jsonc");
78

8-
function validateProperties(properties) {
9-
assert(properties && typeof properties === "object" && !Array.isArray(properties), "config/properties.jsonc must contain an object.");
10-
assert(
11-
typeof properties.organization === "string" && properties.organization.trim(),
12-
"properties.organization must be a non-empty string.",
13-
);
14-
assert(
15-
typeof properties.labelSyncTokenSecretName === "string" && /^[A-Z_][A-Z0-9_]*$/.test(properties.labelSyncTokenSecretName),
16-
"properties.labelSyncTokenSecretName must look like a GitHub secret name.",
17-
);
18-
19-
if (properties.sourceRepository !== undefined) {
20-
assert(
21-
typeof properties.sourceRepository === "string" && /^[^/\s]+\/[^/\s]+$/.test(properties.sourceRepository.trim()),
22-
"properties.sourceRepository must match owner/repo when provided.",
23-
);
24-
}
25-
26-
if (properties.deleteMissingByDefault !== undefined) {
27-
assert(typeof properties.deleteMissingByDefault === "boolean", "properties.deleteMissingByDefault must be a boolean.");
28-
}
29-
30-
return {
31-
organization: properties.organization.trim(),
32-
labelSyncTokenSecretName: properties.labelSyncTokenSecretName.trim(),
33-
sourceRepository: (properties.sourceRepository ?? process.env.GITHUB_REPOSITORY ?? "").trim(),
34-
deleteMissingByDefault: properties.deleteMissingByDefault ?? false,
35-
};
36-
}
37-
389
async function main() {
39-
const properties = validateProperties(await readJsonc(propertiesPath));
10+
const properties = validateProperties(await readJsonc(propertiesPath), {
11+
requireOrganization: true,
12+
requireLabelSyncTokenSecretName: true,
13+
includeSourceRepository: true,
14+
defaultSourceRepository: process.env.GITHUB_REPOSITORY ?? "",
15+
includeDeleteMissingByDefault: true,
16+
});
4017
const outputPath = process.env.GITHUB_OUTPUT;
4118

4219
if (!outputPath) {

scripts/lib/config-validation.mjs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
assert,
3+
normalizeColor,
4+
normalizeDescription,
5+
normalizeName,
6+
normalizeRepositoryRef,
7+
} from "./config-utils.mjs";
8+
9+
function isFullRepositoryName(value) {
10+
return /^[^/\s]+\/[^/\s]+$/.test(value);
11+
}
12+
13+
function isRepositoryName(value) {
14+
return /^[^/\s]+$/.test(value);
15+
}
16+
17+
export function validateProperties(properties, options = {}) {
18+
const {
19+
requireOrganization = false,
20+
requireLabelSyncTokenSecretName = false,
21+
includeSourceRepository = false,
22+
defaultSourceRepository = "",
23+
includeDeleteMissingByDefault = false,
24+
} = options;
25+
26+
assert(properties && typeof properties === "object" && !Array.isArray(properties), "config/properties.jsonc must contain an object.");
27+
28+
const validated = {};
29+
30+
if (requireOrganization) {
31+
assert(
32+
typeof properties.organization === "string" && properties.organization.trim(),
33+
"properties.organization must be a non-empty string.",
34+
);
35+
validated.organization = properties.organization.trim();
36+
}
37+
38+
if (requireLabelSyncTokenSecretName) {
39+
assert(
40+
typeof properties.labelSyncTokenSecretName === "string" && /^[A-Z_][A-Z0-9_]*$/.test(properties.labelSyncTokenSecretName),
41+
"properties.labelSyncTokenSecretName must look like a GitHub secret name.",
42+
);
43+
validated.labelSyncTokenSecretName = properties.labelSyncTokenSecretName.trim();
44+
}
45+
46+
if (properties.sourceRepository !== undefined) {
47+
assert(
48+
typeof properties.sourceRepository === "string" && /^[^/\s]+\/[^/\s]+$/.test(properties.sourceRepository.trim()),
49+
"properties.sourceRepository must match owner/repo when provided.",
50+
);
51+
}
52+
53+
if (includeSourceRepository) {
54+
validated.sourceRepository = (properties.sourceRepository ?? defaultSourceRepository).trim();
55+
}
56+
57+
if (properties.deleteMissingByDefault !== undefined) {
58+
assert(typeof properties.deleteMissingByDefault === "boolean", "properties.deleteMissingByDefault must be a boolean.");
59+
}
60+
61+
if (includeDeleteMissingByDefault) {
62+
validated.deleteMissingByDefault = properties.deleteMissingByDefault ?? false;
63+
}
64+
65+
return validated;
66+
}
67+
68+
export function validateLabels(labels) {
69+
assert(Array.isArray(labels), "config/labels.jsonc must contain an array.");
70+
71+
const seen = new Set();
72+
73+
return labels.map((label, index) => {
74+
assert(label && typeof label === "object" && !Array.isArray(label), `Label at index ${index} must be an object.`);
75+
assert(typeof label.name === "string" && label.name.trim(), `Label at index ${index} is missing a valid name.`);
76+
assert(typeof label.color === "string" && /^[0-9a-fA-F]{6}$/.test(normalizeColor(label.color)), `Label "${label.name}" must have a 6-character hex color.`);
77+
78+
const key = normalizeName(label.name);
79+
assert(!seen.has(key), `Duplicate label name detected: "${label.name}".`);
80+
seen.add(key);
81+
82+
if (label.description !== undefined) {
83+
assert(typeof label.description === "string", `Label "${label.name}" has a non-string description.`);
84+
}
85+
86+
return {
87+
name: label.name.trim(),
88+
color: normalizeColor(label.color),
89+
description: normalizeDescription(label.description),
90+
};
91+
});
92+
}
93+
94+
export function validateDeleteLabels(deleteLabels) {
95+
assert(Array.isArray(deleteLabels), "config/auto-pruned-labels.jsonc must contain an array.");
96+
97+
const seen = new Set();
98+
99+
return deleteLabels.map((entry, index) => {
100+
assert(typeof entry === "string" && entry.trim(), `Delete label at index ${index} must be a non-empty string.`);
101+
102+
const name = entry.trim();
103+
const key = normalizeName(name);
104+
assert(!seen.has(key), `Duplicate delete label detected: "${name}".`);
105+
seen.add(key);
106+
return name;
107+
});
108+
}
109+
110+
export function assertNoLabelOverlap(desiredLabels, deleteLabels) {
111+
const desiredKeys = new Set(desiredLabels.map((label) => normalizeName(label.name)));
112+
const overlaps = deleteLabels.filter((label) => desiredKeys.has(normalizeName(label)));
113+
114+
assert(
115+
overlaps.length === 0,
116+
`Labels cannot exist in both config/labels.jsonc and config/auto-pruned-labels.jsonc: ${overlaps.join(", ")}.`,
117+
);
118+
}
119+
120+
function validateRepositoryEntries(entries, configKey) {
121+
assert(Array.isArray(entries), `config/repository-filter.jsonc field "${configKey}" must contain an array.`);
122+
123+
const seen = new Set();
124+
125+
return new Set(entries.map((entry, index) => {
126+
assert(typeof entry === "string" && entry.trim(), `"${configKey}" entry at index ${index} must be a non-empty string.`);
127+
128+
const name = entry.trim();
129+
assert(
130+
isRepositoryName(name) || isFullRepositoryName(name),
131+
`"${configKey}" entry "${name}" must be either "repo-name" or "owner/repo-name".`,
132+
);
133+
134+
const key = normalizeRepositoryRef(name);
135+
assert(!seen.has(key), `Duplicate "${configKey}" entry detected: "${name}".`);
136+
seen.add(key);
137+
return key;
138+
}));
139+
}
140+
141+
export function validateRepositoryFilter(repositoryFilter) {
142+
assert(
143+
repositoryFilter && typeof repositoryFilter === "object" && !Array.isArray(repositoryFilter),
144+
"config/repository-filter.jsonc must contain an object.",
145+
);
146+
147+
if (repositoryFilter.useWhitelist !== undefined) {
148+
assert(typeof repositoryFilter.useWhitelist === "boolean", 'config/repository-filter.jsonc field "useWhitelist" must be a boolean.');
149+
}
150+
151+
return {
152+
useWhitelist: repositoryFilter.useWhitelist ?? false,
153+
whitelist: validateRepositoryEntries(repositoryFilter.whitelist ?? [], "whitelist"),
154+
blacklist: validateRepositoryEntries(repositoryFilter.blacklist ?? [], "blacklist"),
155+
};
156+
}

scripts/remove-labels.mjs

Lines changed: 5 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "node:path";
22
import { assert, normalizeName, normalizeRepositoryRef, readJsonc } from "./lib/config-utils.mjs";
3+
import { validateProperties, validateRepositoryFilter } from "./lib/config-validation.mjs";
34

45
const workspaceRoot = process.cwd();
56
const propertiesPath = path.join(workspaceRoot, "config", "properties.jsonc");
@@ -20,68 +21,6 @@ function parseBoolean(value) {
2021
return value.toLowerCase() === "true";
2122
}
2223

23-
function isFullRepositoryName(value) {
24-
return /^[^/\s]+\/[^/\s]+$/.test(value);
25-
}
26-
27-
function isRepositoryName(value) {
28-
return /^[^/\s]+$/.test(value);
29-
}
30-
31-
function validateProperties(properties) {
32-
assert(properties && typeof properties === "object" && !Array.isArray(properties), "config/properties.jsonc must contain an object.");
33-
assert(
34-
typeof properties.organization === "string" && properties.organization.trim(),
35-
"properties.organization must be a non-empty string.",
36-
);
37-
assert(
38-
typeof properties.labelSyncTokenSecretName === "string" && /^[A-Z_][A-Z0-9_]*$/.test(properties.labelSyncTokenSecretName),
39-
"properties.labelSyncTokenSecretName must look like a GitHub secret name.",
40-
);
41-
42-
return {
43-
organization: properties.organization.trim(),
44-
labelSyncTokenSecretName: properties.labelSyncTokenSecretName.trim(),
45-
};
46-
}
47-
48-
function validateRepositoryEntries(entries, configKey) {
49-
assert(Array.isArray(entries), `config/repository-filter.jsonc field "${configKey}" must contain an array.`);
50-
const seen = new Set();
51-
52-
return new Set(entries.map((entry, index) => {
53-
assert(typeof entry === "string" && entry.trim(), `"${configKey}" entry at index ${index} must be a non-empty string.`);
54-
55-
const name = entry.trim();
56-
assert(
57-
isRepositoryName(name) || isFullRepositoryName(name),
58-
`"${configKey}" entry "${name}" must be either "repo-name" or "owner/repo-name".`,
59-
);
60-
61-
const key = normalizeRepositoryRef(name);
62-
assert(!seen.has(key), `Duplicate "${configKey}" entry detected: "${name}".`);
63-
seen.add(key);
64-
return key;
65-
}));
66-
}
67-
68-
function validateRepositoryFilter(repositoryFilter) {
69-
assert(
70-
repositoryFilter && typeof repositoryFilter === "object" && !Array.isArray(repositoryFilter),
71-
"config/repository-filter.jsonc must contain an object.",
72-
);
73-
74-
if (repositoryFilter.useWhitelist !== undefined) {
75-
assert(typeof repositoryFilter.useWhitelist === "boolean", 'config/repository-filter.jsonc field "useWhitelist" must be a boolean.');
76-
}
77-
78-
return {
79-
useWhitelist: repositoryFilter.useWhitelist ?? false,
80-
whitelist: validateRepositoryEntries(repositoryFilter.whitelist ?? [], "whitelist"),
81-
blacklist: validateRepositoryEntries(repositoryFilter.blacklist ?? [], "blacklist"),
82-
};
83-
}
84-
8524
function validateRunInputs() {
8625
assert(labelName, "LABEL_NAME is required.");
8726
assert(runOnIssues !== undefined, "RUN_ON_ISSUES must be provided.");
@@ -249,7 +188,10 @@ async function processRepository(token, repository, requestedLabel) {
249188
}
250189

251190
async function main() {
252-
const properties = validateProperties(await readJsonc(propertiesPath));
191+
const properties = validateProperties(await readJsonc(propertiesPath), {
192+
requireOrganization: true,
193+
requireLabelSyncTokenSecretName: true,
194+
});
253195
const repositoryFilter = validateRepositoryFilter(await readJsonc(repositoryFilterPath));
254196
const activeFilterCount = repositoryFilter.useWhitelist ? repositoryFilter.whitelist.size : repositoryFilter.blacklist.size;
255197
const activeFilterMode = repositoryFilter.useWhitelist ? "whitelist" : "blacklist";

scripts/sync-config-labels.mjs

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,13 @@ import {
77
readJsonc,
88
writeJsoncPreservingHeader,
99
} from "./lib/config-utils.mjs";
10+
import { validateDeleteLabels, validateProperties } from "./lib/config-validation.mjs";
1011

1112
const workspaceRoot = process.cwd();
1213
const propertiesPath = path.join(workspaceRoot, "config", "properties.jsonc");
1314
const labelsPath = path.join(workspaceRoot, "config", "labels.jsonc");
1415
const autoPrunedLabelsPath = path.join(workspaceRoot, "config", "auto-pruned-labels.jsonc");
1516

16-
function validateProperties(properties) {
17-
assert(properties && typeof properties === "object" && !Array.isArray(properties), "config/properties.jsonc must contain an object.");
18-
19-
if (properties.sourceRepository !== undefined) {
20-
assert(
21-
typeof properties.sourceRepository === "string" && /^[^/\s]+\/[^/\s]+$/.test(properties.sourceRepository.trim()),
22-
"properties.sourceRepository must match owner/repo when provided.",
23-
);
24-
}
25-
26-
return {
27-
sourceRepository: (properties.sourceRepository ?? process.env.GITHUB_REPOSITORY ?? "").trim(),
28-
};
29-
}
30-
31-
function validateDeleteLabels(deleteLabels) {
32-
assert(Array.isArray(deleteLabels), "config/auto-pruned-labels.jsonc must contain an array.");
33-
34-
const seen = new Set();
35-
36-
return new Set(
37-
deleteLabels.map((entry, index) => {
38-
assert(typeof entry === "string" && entry.trim(), `Delete label at index ${index} must be a non-empty string.`);
39-
40-
const name = normalizeName(entry);
41-
assert(!seen.has(name), `Duplicate delete label detected: "${entry}".`);
42-
seen.add(name);
43-
return name;
44-
}),
45-
);
46-
}
47-
4817
async function githubRequest(token, method, apiPath) {
4918
const response = await fetch(`https://api.github.com${apiPath}`, {
5019
method,
@@ -95,11 +64,16 @@ async function main() {
9564
const token = process.env.CONFIG_LABEL_SYNC_TOKEN ?? process.env.GITHUB_TOKEN;
9665
assert(token, "CONFIG_LABEL_SYNC_TOKEN or GITHUB_TOKEN is required.");
9766

98-
const properties = validateProperties(await readJsonc(propertiesPath));
67+
const properties = validateProperties(await readJsonc(propertiesPath), {
68+
includeSourceRepository: true,
69+
defaultSourceRepository: process.env.GITHUB_REPOSITORY ?? "",
70+
});
9971
const repository = process.env.SOURCE_REPOSITORY ?? properties.sourceRepository;
10072
assert(repository, "SOURCE_REPOSITORY or GITHUB_REPOSITORY is required.");
10173

102-
const deleteLabels = validateDeleteLabels(await readJsonc(autoPrunedLabelsPath));
74+
const deleteLabels = new Set(
75+
validateDeleteLabels(await readJsonc(autoPrunedLabelsPath)).map((entry) => normalizeName(entry)),
76+
);
10377
const repositoryLabels = await getAllLabels(token, repository);
10478
const managedLabels = toManagedLabels(repositoryLabels, deleteLabels);
10579

0 commit comments

Comments
 (0)