Skip to content

Commit c44d2ce

Browse files
committed
delete-missing-labels feature
1 parent 44d584f commit c44d2ce

8 files changed

Lines changed: 172 additions & 26 deletions

File tree

‎.github/workflows/config-label-sync.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ jobs:
5555
run: |
5656
git config user.name "github-actions[bot]"
5757
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
58-
if git diff --quiet -- config/labels.jsonc; then
58+
if git diff --quiet -- config/labels.jsonc config/deleted-labels.jsonc; then
5959
echo "No config changes detected."
6060
exit 0
6161
fi
62-
git add config/labels.jsonc
62+
git add config/labels.jsonc config/deleted-labels.jsonc
6363
git commit -m "Sync labels config from source repository"
6464
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
6565
git push origin "HEAD:${{ github.event.repository.default_branch }}"

‎README.md‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This repository is a standalone GitHub label management repo for an organization
55
Its job is to:
66

77
- keep this repo's label config in sync with the labels currently defined on the repo
8+
- track labels removed from that source config so they can be deleted from synced repositories
89
- validate config changes automatically
910
- sync the resulting label set across the rest of the organization
1011
- remove an exact label from issues and pull requests across the filtered repository set
@@ -25,9 +26,10 @@ The normal flow is:
2526
1. You run `Org-Label-Sync` manually.
2627
2. It first calls `Config-Label_Sync`.
2728
3. `Config-Label_Sync` reads the labels on this repository and rewrites `config/labels.jsonc` so the file matches the repo's current labels.
28-
4. If that file changed, `Config-Label_Sync` commits and pushes the update.
29-
5. That config change triggers `Validate-Configs`.
30-
6. `Org-Label-Sync` then checks out the latest default branch, validates the config again, and syncs labels across the organization.
29+
4. Any labels that were previously managed but are no longer on the source repository are moved to `config/deleted-labels.jsonc`.
30+
5. If either config file changed, `Config-Label_Sync` commits and pushes the update.
31+
6. That config change triggers `Validate-Configs`.
32+
7. `Org-Label-Sync` then checks out the latest default branch, validates the config again, and syncs labels across the organization.
3133

3234
The reverse flow is:
3335

@@ -48,6 +50,7 @@ The reverse flow is:
4850
| |-- reverse-config-label-sync.yml
4951
| `-- validate-configs.yml
5052
|-- config/
53+
| |-- deleted-labels.jsonc
5154
| |-- github-default-labels.jsonc
5255
| |-- labels.jsonc
5356
| |-- properties.jsonc
@@ -67,6 +70,7 @@ The reverse flow is:
6770
|-- sync-labels.mjs
6871
`-- lib/
6972
|-- changelog-utils.mjs
73+
|-- config-validation.mjs
7074
`-- config-utils.mjs
7175
```
7276

@@ -134,6 +138,20 @@ Example:
134138
]
135139
```
136140

141+
### `config/deleted-labels.jsonc`
142+
143+
This is the list of labels that should be deleted from synced repositories.
144+
145+
It is normally maintained automatically by `Config-Label_Sync`. When a label existed in `config/labels.jsonc` and is later removed from the source repository, the last managed label spec is moved here.
146+
147+
Each entry uses the same object shape as `config/labels.jsonc`:
148+
149+
- `name`
150+
- `color`
151+
- `description`
152+
153+
`Org-Label-Sync` deletes matching label names from target repositories regardless of color or description. If a label appears again on the source repository, `Config-Label_Sync` removes it from `config/deleted-labels.jsonc` and restores it to `config/labels.jsonc`.
154+
137155
### `config/github-default-labels.jsonc`
138156

139157
This is the list of exact GitHub default labels that can be removed from synced repositories when they are not managed by `config/labels.jsonc`.
@@ -218,8 +236,9 @@ What it does:
218236
3. Resolves either the configured PAT or a GitHub App installation token
219237
4. Reads the current labels on the source repository
220238
5. Rewrites `config/labels.jsonc` so it exactly matches the source repository labels
221-
6. Validates `config/github-default-labels.jsonc` so exact GitHub default label specs remain well-formed
222-
7. Commits and pushes the change if the config was updated
239+
6. Moves any previously managed labels that are missing from the source repository into `config/deleted-labels.jsonc`
240+
7. Validates `config/github-default-labels.jsonc` so exact GitHub default label specs remain well-formed
241+
8. Commits and pushes the change if either config file was updated
223242

224243
This workflow is the bridge between "the labels on this repo right now" and "the managed config we sync elsewhere."
225244

@@ -242,6 +261,7 @@ Validation includes:
242261
- JSONC parsing
243262
- required property checks
244263
- duplicate label detection
264+
- deleted-label shape and duplicate-name validation
245265
- repository filter shape and `useWhitelist` validation
246266
- duplicate whitelist and blacklist detection
247267
- invalid colors
@@ -295,9 +315,10 @@ What it does:
295315
6. Discovers repos in the configured organization
296316
7. Applies `config/repository-filter.jsonc`, unless `repositories` was provided as a workflow dispatch config override
297317
8. Creates or updates labels from `config/labels.jsonc`
298-
9. Deletes labels that exactly match entries in `config/github-default-labels.jsonc` only when the `delete_github_default_labels` workflow checkbox is checked, unless that label name is managed by `config/labels.jsonc`
299-
10. Optionally deletes any other unmanaged labels only when the `delete_missing` workflow checkbox is checked; exact GitHub default labels are reserved for `delete_github_default_labels`
300-
11. If at least one target repo changed or would change, writes a changelog and commits it with `[skip ci]`; real runs write to `changelogs/YYYY-MM-DD/`, while preview runs write fake changelogs to `fake-changelogs/YYYY-MM-DD/` and do not apply repository changes
318+
9. Deletes labels whose names match entries in `config/deleted-labels.jsonc`
319+
10. Deletes labels that exactly match entries in `config/github-default-labels.jsonc` only when the `delete_github_default_labels` workflow checkbox is checked, unless that label name is managed by `config/labels.jsonc`
320+
11. Optionally deletes any other unmanaged labels only when the `delete_missing` workflow checkbox is checked; exact GitHub default labels are reserved for `delete_github_default_labels`
321+
12. If at least one target repo changed or would change, writes a changelog and commits it with `[skip ci]`; real runs write to `changelogs/YYYY-MM-DD/`, while preview runs write fake changelogs to `fake-changelogs/YYYY-MM-DD/` and do not apply repository changes
301322

302323
### `Remove-Labels`
303324

@@ -336,6 +357,7 @@ Each changelog file is created only when a workflow actually changes, or in prev
336357

337358
- created labels
338359
- updated labels, including changed fields
360+
- labels deleted by `config/deleted-labels.jsonc`
339361
- GitHub default labels that were deleted
340362
- unmanaged labels that were deleted when delete-missing is enabled
341363

@@ -363,7 +385,7 @@ That PAT needs enough access to:
363385
- read and update labels on target repositories
364386
- read and update labels on the source repository when running `Reverse-Config-Label-Sync`
365387
- read and update issues and pull requests when running `Remove-Labels`
366-
- push config updates back to this repository when `Config-Label_Sync` changes `labels.jsonc`
388+
- push config updates back to this repository when `Config-Label_Sync` changes `labels.jsonc` or `deleted-labels.jsonc`
367389
- push changelog commits back to this repository when an action changes another repository
368390

369391
For GitHub App auth, create repository secrets whose names match:
@@ -388,6 +410,7 @@ The GitHub App installation must be granted access to this configuration reposit
388410
## Safe Defaults
389411

390412
- `labels.jsonc` starts empty until you define or sync labels on this repo
413+
- `deleted-labels.jsonc` starts empty and is populated when synced labels are removed from the source repository
391414
- all repos in the org are targeted unless excluded by `repository-filter.jsonc`
392415
- GitHub default labels are pruned by default when they exactly match `config/github-default-labels.jsonc`; uncheck `delete_github_default_labels` to keep them
393416
- deleting unmanaged labels is off by default unless you check `delete_missing` when running `Org-Label-Sync`

‎config/deleted-labels.jsonc‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Labels removed from config/labels.jsonc by Config-Label_Sync are moved here.
2+
// Org-Label-Sync deletes matching label names from selected repositories.
3+
//
4+
// Example:
5+
// [
6+
// {
7+
// "name": "status: stale",
8+
// "color": "ededed",
9+
// "description": "No recent activity"
10+
// }
11+
// ]
12+
[]

‎scripts/lib/changelog-utils.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ export function renderLabelSyncSection(result) {
122122
lines.push("");
123123
}
124124

125+
const deletedConfigured = renderList(
126+
result.deletedConfiguredLabels,
127+
(label) => `Deleted configured removed label \`${label.name}\``,
128+
);
129+
if (deletedConfigured) {
130+
lines.push("Deleted labels from deleted-labels config:");
131+
lines.push(deletedConfigured);
132+
lines.push("");
133+
}
134+
125135
const deletedGithubDefaults = renderList(
126136
result.deletedGithubDefaultLabels,
127137
(label) => `Deleted GitHub default label \`${label.name}\``,

‎scripts/lib/config-validation.mjs‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,28 +175,54 @@ export function validateProperties(properties, options = {}) {
175175
return validated;
176176
}
177177

178-
export function validateLabels(labels) {
179-
assert(Array.isArray(labels), "config/labels.jsonc must contain an array.");
178+
function validateLabelSpecs(labels, { configPath, itemLabel }) {
179+
assert(Array.isArray(labels), `${configPath} must contain an array.`);
180180

181181
const seen = new Set();
182182

183183
return labels.map((label, index) => {
184-
assert(label && typeof label === "object" && !Array.isArray(label), `Label at index ${index} must be an object.`);
185-
assert(typeof label.name === "string" && label.name.trim(), `Label at index ${index} is missing a valid name.`);
186-
assert(typeof label.color === "string" && /^[0-9a-fA-F]{6}$/.test(normalizeColor(label.color)), `Label "${label.name}" must have a 6-character hex color.`);
184+
assert(label && typeof label === "object" && !Array.isArray(label), `${itemLabel} at index ${index} must be an object.`);
185+
assert(typeof label.name === "string" && label.name.trim(), `${itemLabel} at index ${index} is missing a valid name.`);
186+
assert(typeof label.color === "string" && /^[0-9a-fA-F]{6}$/.test(normalizeColor(label.color)), `${itemLabel} "${label.name}" must have a 6-character hex color.`);
187187

188188
const key = normalizeName(label.name);
189-
assert(!seen.has(key), `Duplicate label name detected: "${label.name}".`);
189+
assert(!seen.has(key), `Duplicate ${itemLabel.toLowerCase()} name detected: "${label.name}".`);
190190
seen.add(key);
191191

192192
if (label.description !== undefined) {
193-
assert(typeof label.description === "string", `Label "${label.name}" has a non-string description.`);
193+
assert(typeof label.description === "string", `${itemLabel} "${label.name}" has a non-string description.`);
194194
}
195195

196196
return normalizeLabelSpec(label);
197197
});
198198
}
199199

200+
export function validateLabels(labels) {
201+
return validateLabelSpecs(labels, {
202+
configPath: "config/labels.jsonc",
203+
itemLabel: "Label",
204+
});
205+
}
206+
207+
export function validateDeletedLabels(deletedLabels) {
208+
return validateLabelSpecs(deletedLabels, {
209+
configPath: "config/deleted-labels.jsonc",
210+
itemLabel: "Deleted label",
211+
});
212+
}
213+
214+
export function assertNoManagedDeletedLabelOverlap(managedLabels, deletedLabels) {
215+
const managedByName = new Map(managedLabels.map((label) => [normalizeName(label.name), label.name]));
216+
const overlappingLabels = deletedLabels
217+
.filter((label) => managedByName.has(normalizeName(label.name)))
218+
.map((label) => label.name);
219+
220+
assert(
221+
overlappingLabels.length === 0,
222+
`Labels cannot be both managed and deleted: ${overlappingLabels.join(", ")}.`,
223+
);
224+
}
225+
200226
export function validateGithubDefaultLabels(githubDefaultLabels) {
201227
assert(Array.isArray(githubDefaultLabels), "config/github-default-labels.jsonc must contain an array.");
202228

‎scripts/reverse-config-label-sync.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
readJsonc,
99
} from "./lib/config-utils.mjs";
1010
import {
11+
assertNoManagedDeletedLabelOverlap,
12+
validateDeletedLabels,
1113
validateGithubDefaultLabels,
1214
validateLabels,
1315
validateProperties,
@@ -16,6 +18,7 @@ import {
1618
const workspaceRoot = process.cwd();
1719
const propertiesPath = path.join(workspaceRoot, "config", "properties.jsonc");
1820
const labelsPath = path.join(workspaceRoot, "config", "labels.jsonc");
21+
const deletedLabelsPath = path.join(workspaceRoot, "config", "deleted-labels.jsonc");
1922
const githubDefaultLabelsPath = path.join(workspaceRoot, "config", "github-default-labels.jsonc");
2023

2124
const validateOnly = process.argv.includes("--validate-only");
@@ -176,12 +179,14 @@ async function main() {
176179
defaultSourceRepository: process.env.GITHUB_REPOSITORY ?? "",
177180
});
178181
const labels = validateLabels(await readJsonc(labelsPath));
182+
const deletedLabels = validateDeletedLabels(await readJsonc(deletedLabelsPath));
183+
assertNoManagedDeletedLabelOverlap(labels, deletedLabels);
179184
const githubDefaultLabels = validateGithubDefaultLabels(await readJsonc(githubDefaultLabelsPath));
180185
const repository = process.env.SOURCE_REPOSITORY ?? properties.sourceRepository;
181186
assert(repository, "SOURCE_REPOSITORY or properties.sourceRepository is required.");
182187

183188
console.log(
184-
`Loaded ${labels.length} managed labels and ${githubDefaultLabels.length} exact GitHub default label specs for ${repository}.`,
189+
`Loaded ${labels.length} managed labels, ${deletedLabels.length} deleted labels, and ${githubDefaultLabels.length} exact GitHub default label specs for ${repository}.`,
185190
);
186191

187192
if (validateOnly) {

‎scripts/sync-config-labels.mjs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import path from "node:path";
22
import {
3+
normalizeName,
34
assert,
45
normalizeColor,
56
normalizeDescription,
67
readJsonc,
78
writeJsoncPreservingHeader,
89
} from "./lib/config-utils.mjs";
9-
import { validateGithubDefaultLabels, validateProperties } from "./lib/config-validation.mjs";
10+
import { validateDeletedLabels, validateGithubDefaultLabels, validateLabels, 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");
15+
const deletedLabelsPath = path.join(workspaceRoot, "config", "deleted-labels.jsonc");
1416
const githubDefaultLabelsPath = path.join(workspaceRoot, "config", "github-default-labels.jsonc");
1517

1618
async function githubRequest(token, method, apiPath) {
@@ -58,6 +60,27 @@ function toManagedLabels(labels) {
5860
.sort((left, right) => left.name.localeCompare(right.name));
5961
}
6062

63+
function mergeDeletedLabels(previousManagedLabels, currentManagedLabels, deletedLabels) {
64+
const currentKeys = new Set(currentManagedLabels.map((label) => normalizeName(label.name)));
65+
const nextDeletedByName = new Map();
66+
67+
for (const label of deletedLabels) {
68+
const key = normalizeName(label.name);
69+
if (!currentKeys.has(key)) {
70+
nextDeletedByName.set(key, label);
71+
}
72+
}
73+
74+
for (const label of previousManagedLabels) {
75+
const key = normalizeName(label.name);
76+
if (!currentKeys.has(key)) {
77+
nextDeletedByName.set(key, label);
78+
}
79+
}
80+
81+
return [...nextDeletedByName.values()].sort((left, right) => left.name.localeCompare(right.name));
82+
}
83+
6184
async function main() {
6285
const token = process.env.CONFIG_LABEL_SYNC_TOKEN ?? process.env.GITHUB_TOKEN;
6386
assert(token, "CONFIG_LABEL_SYNC_TOKEN or GITHUB_TOKEN is required.");
@@ -69,14 +92,18 @@ async function main() {
6992
const repository = process.env.SOURCE_REPOSITORY ?? properties.sourceRepository;
7093
assert(repository, "SOURCE_REPOSITORY or GITHUB_REPOSITORY is required.");
7194

95+
const previousManagedLabels = validateLabels(await readJsonc(labelsPath));
96+
const deletedLabels = validateDeletedLabels(await readJsonc(deletedLabelsPath));
7297
const githubDefaultLabels = validateGithubDefaultLabels(await readJsonc(githubDefaultLabelsPath));
7398
const repositoryLabels = await getAllLabels(token, repository);
7499
const managedLabels = toManagedLabels(repositoryLabels);
100+
const nextDeletedLabels = mergeDeletedLabels(previousManagedLabels, managedLabels, deletedLabels);
75101

76102
await writeJsoncPreservingHeader(labelsPath, managedLabels);
103+
await writeJsoncPreservingHeader(deletedLabelsPath, nextDeletedLabels);
77104

78105
console.log(
79-
`Synced ${managedLabels.length} managed labels from ${repository} into config/labels.jsonc. Source labels take precedence over ${githubDefaultLabels.length} exact GitHub default label specs.`,
106+
`Synced ${managedLabels.length} managed labels from ${repository} into config/labels.jsonc and tracked ${nextDeletedLabels.length} deleted labels in config/deleted-labels.jsonc. Source labels take precedence over ${githubDefaultLabels.length} exact GitHub default label specs.`,
80107
);
81108
}
82109

0 commit comments

Comments
 (0)