Skip to content

Commit 206f8b3

Browse files
committed
More strict config rules
1 parent 92d8457 commit 206f8b3

6 files changed

Lines changed: 132 additions & 67 deletions

File tree

README.md

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The normal flow is:
2323

2424
1. You run `Org-Label-Sync` manually.
2525
2. It first calls `Config-Label_Sync`.
26-
3. `Config-Label_Sync` reads the labels on this repository and rewrites `config/labels.jsonc` so the file matches the repo's current managed labels.
26+
3. `Config-Label_Sync` reads the labels on this repository and rewrites `config/labels.jsonc` so the file matches the repo's current labels.
2727
4. If that file changed, `Config-Label_Sync` commits and pushes the update.
2828
5. That config change triggers `Validate-Configs`.
2929
6. `Org-Label-Sync` then checks out the latest default branch, validates the config again, and syncs labels across the organization.
@@ -86,7 +86,7 @@ Example:
8686

8787
This is the managed label set that gets created or updated across the org.
8888

89-
It is normally maintained automatically by `Config-Label_Sync`, based on the labels currently present on this repository after excluding auto-pruned labels.
89+
It is normally maintained automatically by `Config-Label_Sync`, based on the labels currently present on this repository.
9090

9191
Each label object uses:
9292

@@ -108,21 +108,34 @@ Example:
108108

109109
### `config/auto-pruned-labels.jsonc`
110110

111-
This is the list of labels that should always be removed from synced repositories.
111+
This is the list of exact labels that should be removed from synced repositories when they are not managed by `config/labels.jsonc`.
112112

113-
The starter file is prefilled with GitHub's default labels:
113+
Each entry must include:
114114

115-
- `bug`
116-
- `documentation`
117-
- `duplicate`
118-
- `enhancement`
119-
- `good first issue`
120-
- `help wanted`
121-
- `invalid`
122-
- `question`
123-
- `wontfix`
115+
- `name`
116+
- `color`
117+
- `description`
118+
119+
The starter file is prefilled with GitHub's default labels as exact specs:
120+
121+
```jsonc
122+
[
123+
{
124+
"name": "bug",
125+
"color": "d73a4a",
126+
"description": "Something isn't working"
127+
},
128+
{
129+
"name": "enhancement",
130+
"color": "a2eeef",
131+
"description": "New feature or request"
132+
}
133+
]
134+
```
135+
136+
`Org-Label-Sync` only deletes a configured auto-pruned label when the target repository label has the same name casing, color, and description. A custom label such as `Enhancement`, or an `enhancement` label with a different description or color, is not deleted by this file.
124137

125-
If any of those labels exist on this repo, `Config-Label_Sync` excludes them from `labels.jsonc`. If they exist on target repos, `Org-Label-Sync` deletes them.
138+
Managed labels are the source of truth. If a label exists in `config/labels.jsonc`, it is created or updated on target repositories and is not deleted by `config/auto-pruned-labels.jsonc`, even when it has the same name as an auto-pruned default.
126139

127140
### `config/repository-filter.jsonc`
128141

@@ -173,8 +186,8 @@ What it does:
173186
1. Checks out the default branch
174187
2. Loads shared settings from `config/properties.jsonc`
175188
3. Reads the current labels on the source repository
176-
4. Removes any labels listed in `config/auto-pruned-labels.jsonc`
177-
5. Rewrites `config/labels.jsonc` so it exactly matches the remaining labels
189+
4. Rewrites `config/labels.jsonc` so it exactly matches the source repository labels
190+
5. Validates `config/auto-pruned-labels.jsonc` so exact default-delete specs remain well-formed
178191
6. Commits and pushes the change if the config was updated
179192

180193
This workflow is the bridge between "the labels on this repo right now" and "the managed config we sync elsewhere."
@@ -202,7 +215,7 @@ Validation includes:
202215
- duplicate whitelist and blacklist detection
203216
- invalid colors
204217
- invalid repo names
205-
- overlap detection between `labels.jsonc` and `auto-pruned-labels.jsonc`
218+
- exact auto-pruned label shape validation
206219
- validation for the shared config used by `Remove-Labels`
207220

208221
### `Org-Label-Sync`
@@ -228,7 +241,7 @@ What it does:
228241
5. Discovers repos in the configured organization
229242
6. Applies `config/repository-filter.jsonc`
230243
7. Creates or updates labels from `config/labels.jsonc`
231-
8. Deletes labels listed in `config/auto-pruned-labels.jsonc`
244+
8. Deletes labels that exactly match entries in `config/auto-pruned-labels.jsonc` unless that label name is managed by `config/labels.jsonc`
232245
9. Optionally deletes any other unmanaged labels if `delete_missing` or `deleteMissingByDefault` is enabled
233246
10. If the run is not a dry run and at least one target repo changed, writes a changelog under `changelogs/YYYY-MM-DD/` and commits it with `[skip ci]`
234247

@@ -300,7 +313,7 @@ That token needs enough access to:
300313
1. Fork or clone this repository into the organization you want to manage.
301314
2. Create the sync token secret in the repo.
302315
3. Update `config/properties.jsonc` for your org and repo.
303-
4. Adjust `config/auto-pruned-labels.jsonc` if you want a different always-delete list.
316+
4. Adjust `config/auto-pruned-labels.jsonc` if you want different exact default-delete specs.
304317
5. Configure `config/repository-filter.jsonc` for blacklist mode or whitelist mode.
305318
6. Set the labels on this repository to the label set you want to manage.
306319
7. Run `Config-Label_Sync` once if you want to populate `config/labels.jsonc` immediately.
@@ -310,5 +323,5 @@ That token needs enough access to:
310323

311324
- `labels.jsonc` starts empty until you define or sync labels on this repo
312325
- all repos in the org are targeted unless excluded by `repository-filter.jsonc`
313-
- GitHub default labels are auto-pruned by default
326+
- GitHub default labels are auto-pruned by default only when they exactly match the stored name, color, and description and are not managed in `labels.jsonc`
314327
- deleting unmanaged labels is off by default unless you enable it

config/auto-pruned-labels.jsonc

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,55 @@
11
// Example:
22
// [
3-
// "bug",
4-
// "documentation",
5-
// "duplicate"
3+
// {
4+
// "name": "bug",
5+
// "color": "d73a4a",
6+
// "description": "Something isn't working"
7+
// }
68
// ]
79
[
8-
"bug",
9-
"documentation",
10-
"duplicate",
11-
"enhancement",
12-
"good first issue",
13-
"help wanted",
14-
"invalid",
15-
"question",
16-
"wontfix"
10+
{
11+
"name": "bug",
12+
"color": "d73a4a",
13+
"description": "Something isn't working"
14+
},
15+
{
16+
"name": "documentation",
17+
"color": "0075ca",
18+
"description": "Improvements or additions to documentation"
19+
},
20+
{
21+
"name": "duplicate",
22+
"color": "cfd3d7",
23+
"description": "This issue or pull request already exists"
24+
},
25+
{
26+
"name": "enhancement",
27+
"color": "a2eeef",
28+
"description": "New feature or request"
29+
},
30+
{
31+
"name": "good first issue",
32+
"color": "7057ff",
33+
"description": "Good for newcomers"
34+
},
35+
{
36+
"name": "help wanted",
37+
"color": "008672",
38+
"description": "Extra attention is needed"
39+
},
40+
{
41+
"name": "invalid",
42+
"color": "e4e669",
43+
"description": "This doesn't seem right"
44+
},
45+
{
46+
"name": "question",
47+
"color": "d876e3",
48+
"description": "Further information is requested"
49+
},
50+
{
51+
"name": "wontfix",
52+
"color": "ffffff",
53+
"description": "This will not be worked on"
54+
}
1755
]

scripts/lib/config-utils.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export function normalizeDescription(description) {
1414
return description ?? "";
1515
}
1616

17+
export function normalizeLabelSpec(label) {
18+
return {
19+
name: label.name.trim(),
20+
color: normalizeColor(label.color),
21+
description: normalizeDescription(label.description),
22+
};
23+
}
24+
1725
export function normalizeName(name) {
1826
return name.trim().toLowerCase();
1927
}
@@ -22,6 +30,22 @@ export function normalizeRepositoryRef(value) {
2230
return value.trim().toLowerCase();
2331
}
2432

33+
export function labelsExactlyMatch(left, right) {
34+
const normalizedLeft = normalizeLabelSpec(left);
35+
const normalizedRight = normalizeLabelSpec(right);
36+
37+
return (
38+
normalizedLeft.name === normalizedRight.name
39+
&& normalizedLeft.color === normalizedRight.color
40+
&& normalizedLeft.description === normalizedRight.description
41+
);
42+
}
43+
44+
export function labelSpecKey(label) {
45+
const normalized = normalizeLabelSpec(label);
46+
return `${normalized.name}\0${normalized.color}\0${normalized.description}`;
47+
}
48+
2549
function stripJsonComments(contents) {
2650
let result = "";
2751
let inString = false;

scripts/lib/config-validation.mjs

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
assert,
3+
labelSpecKey,
34
normalizeColor,
4-
normalizeDescription,
5+
normalizeLabelSpec,
56
normalizeName,
67
normalizeRepositoryRef,
78
} from "./config-utils.mjs";
@@ -83,11 +84,7 @@ export function validateLabels(labels) {
8384
assert(typeof label.description === "string", `Label "${label.name}" has a non-string description.`);
8485
}
8586

86-
return {
87-
name: label.name.trim(),
88-
color: normalizeColor(label.color),
89-
description: normalizeDescription(label.description),
90-
};
87+
return normalizeLabelSpec(label);
9188
});
9289
}
9390

@@ -97,26 +94,19 @@ export function validateDeleteLabels(deleteLabels) {
9794
const seen = new Set();
9895

9996
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}".`);
97+
assert(entry && typeof entry === "object" && !Array.isArray(entry), `Delete label at index ${index} must be an object.`);
98+
assert(typeof entry.name === "string" && entry.name.trim(), `Delete label at index ${index} is missing a valid name.`);
99+
assert(typeof entry.color === "string" && /^[0-9a-fA-F]{6}$/.test(normalizeColor(entry.color)), `Delete label "${entry.name}" must have a 6-character hex color.`);
100+
assert(typeof entry.description === "string", `Delete label "${entry.name}" must include a string description.`);
101+
102+
const normalized = normalizeLabelSpec(entry);
103+
const key = labelSpecKey(normalized);
104+
assert(!seen.has(key), `Duplicate exact delete label detected: "${normalized.name}".`);
105105
seen.add(key);
106-
return name;
106+
return normalized;
107107
});
108108
}
109109

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-
120110
function validateRepositoryEntries(entries, configKey) {
121111
assert(Array.isArray(entries), `config/repository-filter.jsonc field "${configKey}" must contain an array.`);
122112

scripts/sync-config-labels.mjs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
assert,
44
normalizeColor,
55
normalizeDescription,
6-
normalizeName,
76
readJsonc,
87
writeJsoncPreservingHeader,
98
} from "./lib/config-utils.mjs";
@@ -49,9 +48,8 @@ async function getAllLabels(token, repo) {
4948
}
5049
}
5150

52-
function toManagedLabels(labels, deleteLabels) {
51+
function toManagedLabels(labels) {
5352
return labels
54-
.filter((label) => !deleteLabels.has(normalizeName(label.name)))
5553
.map((label) => ({
5654
name: label.name.trim(),
5755
color: normalizeColor(label.color),
@@ -71,16 +69,14 @@ async function main() {
7169
const repository = process.env.SOURCE_REPOSITORY ?? properties.sourceRepository;
7270
assert(repository, "SOURCE_REPOSITORY or GITHUB_REPOSITORY is required.");
7371

74-
const deleteLabels = new Set(
75-
validateDeleteLabels(await readJsonc(autoPrunedLabelsPath)).map((entry) => normalizeName(entry)),
76-
);
72+
const deleteLabels = validateDeleteLabels(await readJsonc(autoPrunedLabelsPath));
7773
const repositoryLabels = await getAllLabels(token, repository);
78-
const managedLabels = toManagedLabels(repositoryLabels, deleteLabels);
74+
const managedLabels = toManagedLabels(repositoryLabels);
7975

8076
await writeJsoncPreservingHeader(labelsPath, managedLabels);
8177

8278
console.log(
83-
`Synced ${managedLabels.length} managed labels from ${repository} into config/labels.jsonc after excluding ${deleteLabels.size} auto-pruned labels.`,
79+
`Synced ${managedLabels.length} managed labels from ${repository} into config/labels.jsonc. Source labels take precedence over ${deleteLabels.length} exact auto-pruned label specs.`,
8480
);
8581
}
8682

scripts/sync-labels.mjs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import path from "node:path";
22
import {
33
assert,
4+
labelsExactlyMatch,
45
normalizeColor,
56
normalizeDescription,
67
normalizeName,
78
normalizeRepositoryRef,
89
readJsonc,
910
} from "./lib/config-utils.mjs";
1011
import {
11-
assertNoLabelOverlap,
1212
validateDeleteLabels,
1313
validateLabels,
1414
validateProperties,
@@ -171,12 +171,15 @@ function summarizeLabelDiff(existing, desired) {
171171
return "update";
172172
}
173173

174+
function isExactAutoPrunedLabel(label, deleteLabels) {
175+
return deleteLabels.some((deleteLabel) => labelsExactlyMatch(label, deleteLabel));
176+
}
177+
174178
async function syncRepository(token, repository, desiredLabels, deleteLabels, deleteMissing) {
175179
console.log(`\nSyncing ${repository.full_name}`);
176180
const existingLabels = await getAllLabels(token, repository.full_name);
177181
const existingByName = new Map(existingLabels.map((label) => [normalizeName(label.name), label]));
178182
const desiredKeys = new Set(desiredLabels.map((label) => normalizeName(label.name)));
179-
const deleteKeys = new Set(deleteLabels.map((label) => normalizeName(label)));
180183
const result = {
181184
repository: repository.full_name,
182185
createdLabels: [],
@@ -238,7 +241,9 @@ async function syncRepository(token, repository, desiredLabels, deleteLabels, de
238241
}
239242

240243
for (const existing of existingLabels) {
241-
if (!deleteKeys.has(normalizeName(existing.name))) {
244+
const existingKey = normalizeName(existing.name);
245+
246+
if (desiredKeys.has(existingKey) || !isExactAutoPrunedLabel(existing, deleteLabels)) {
242247
continue;
243248
}
244249

@@ -264,7 +269,7 @@ async function syncRepository(token, repository, desiredLabels, deleteLabels, de
264269
for (const existing of existingLabels) {
265270
const existingKey = normalizeName(existing.name);
266271

267-
if (desiredKeys.has(existingKey) || deleteKeys.has(existingKey)) {
272+
if (desiredKeys.has(existingKey) || isExactAutoPrunedLabel(existing, deleteLabels)) {
268273
continue;
269274
}
270275

@@ -303,7 +308,6 @@ async function main() {
303308
});
304309
const labels = validateLabels(await readJsonc(labelsPath));
305310
const deleteLabels = validateDeleteLabels(await readJsonc(autoPrunedLabelsPath));
306-
assertNoLabelOverlap(labels, deleteLabels);
307311
const repositoryFilter = validateRepositoryFilter(await readJsonc(repositoryFilterPath));
308312
const activeFilterCount = repositoryFilter.useWhitelist ? repositoryFilter.whitelist.size : repositoryFilter.blacklist.size;
309313
const activeFilterMode = repositoryFilter.useWhitelist ? "whitelist" : "blacklist";

0 commit comments

Comments
 (0)