Skip to content

Commit 9a4f467

Browse files
committed
Repo based label rule config
1 parent a00d973 commit 9a4f467

9 files changed

Lines changed: 200 additions & 3 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ The rules live only in `config/label-test-workflow-config.jsonc` in this reposit
169169
// "Blocked",
170170
// "Do Not Merge"
171171
],
172+
"repositoryLabels": {
173+
// "your-org-name/special-repo": {
174+
// "requiredLabels": ["Repo Feature"],
175+
// "failingLabels": ["Repo: Do Not Merge"]
176+
// }
177+
},
172178
"protectedLabelApprovals": [
173179
// { "label": "Affects Balance", "approver": "teams/admin" },
174180
// { "label": "Affects Balance", "approver": "UltraProdigy" }
@@ -185,11 +191,15 @@ Behavior:
185191
- If `requiredLabels` is empty, the required-label gate is disabled and the check can pass with any labels or no labels.
186192
- If `requiredLabels` has entries, a PR must have at least one matching label.
187193
- Any matching `failingLabels` entry fails the check.
194+
- `repositoryLabels` keys must use the full, case-insensitive `owner/repository` name. Their required and failing labels are added to the organization-wide lists only for that repository.
195+
- A repository-specific required label enables the required-label gate for that repository even when the organization-wide `requiredLabels` list is empty.
188196
- Failing labels override required labels.
189197
- If a protected label is present, at least one configured approver for that label must have latest effective review state `APPROVED`.
190198
- Plain approvers such as `UltraProdigy` are GitHub users.
191199
- Approvers prefixed with `teams/`, such as `teams/admin`, are GitHub team slugs in the configured organization.
192200

201+
Repository-specific rules are resolved centrally from the calling workflow's existing `github.repository` context. Adding or changing these rules does not require changes to the caller workflows.
202+
193203
For team approval checks, the workflow token must be able to read the configured organization team membership. The same `properties.authentication` setup used by the label sync workflows is used for the reusable Label Test workflow.
194204

195205
The policy job runs on `pull_request_target` only. Review submissions, edits, and dismissals are recorded by a separate unprivileged workflow, then the existing Label Test workflow handles its completion through `workflow_run` and reruns the latest completed policy run for that pull request. This lets a new approval replace an earlier failed result on the same required check.

config/label-test-workflow-config.jsonc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
// "Do Not Merge"
1616
],
1717

18+
// Add required or failing labels that apply only to one repository. Repository keys must use the
19+
// full "owner/repository" name. These lists are added to the organization-wide lists above.
20+
"repositoryLabels": {
21+
// "your-org-name/special-repo": {
22+
// "requiredLabels": ["Repo Feature"],
23+
// "failingLabels": ["Repo: Do Not Merge"]
24+
// }
25+
},
26+
1827
// If a listed label is present on a PR, at least one listed user or team member for that label must have
1928
// latest effective review state APPROVED.
2029
"protectedLabelApprovals": [

scripts/check-pr-label-policy.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ async function main() {
126126
const reviews = await getPullRequestReviews(token, targetRepository, pullRequestNumber);
127127
const result = await evaluatePrLabelTest({
128128
config,
129+
targetRepository,
129130
prLabels: labels,
130131
reviews,
131132
isTeamMember: createTeamMembershipChecker(token, properties.organization),

scripts/lib/config-validation.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,43 @@ function validateLabelNameEntries(entries, configKey) {
365365
});
366366
}
367367

368+
function validateRepositoryLabelEntries(repositoryLabels) {
369+
assert(
370+
repositoryLabels && typeof repositoryLabels === "object" && !Array.isArray(repositoryLabels),
371+
'config/label-test-workflow-config.jsonc field "repositoryLabels" must contain an object.',
372+
);
373+
374+
const validated = new Map();
375+
376+
for (const [repositoryName, rules] of Object.entries(repositoryLabels)) {
377+
const name = repositoryName.trim();
378+
assert(
379+
isFullRepositoryName(name),
380+
`repositoryLabels key "${repositoryName}" must be an "owner/repository" name.`,
381+
);
382+
383+
const key = normalizeRepositoryRef(name);
384+
assert(!validated.has(key), `Duplicate repositoryLabels key detected: "${repositoryName}".`);
385+
assert(
386+
rules && typeof rules === "object" && !Array.isArray(rules),
387+
`repositoryLabels entry "${repositoryName}" must contain an object.`,
388+
);
389+
390+
validated.set(key, {
391+
requiredLabels: validateLabelNameEntries(
392+
rules.requiredLabels ?? [],
393+
`repositoryLabels.${repositoryName}.requiredLabels`,
394+
),
395+
failingLabels: validateLabelNameEntries(
396+
rules.failingLabels ?? [],
397+
`repositoryLabels.${repositoryName}.failingLabels`,
398+
),
399+
});
400+
}
401+
402+
return validated;
403+
}
404+
368405
function validateProtectedLabelApprover(value) {
369406
assert(typeof value === "string" && value.trim(), "protectedLabelApprovals approver must be a non-empty string.");
370407

@@ -441,6 +478,7 @@ export function validateLabelTestWorkflowConfig(labelTestWorkflowConfig) {
441478
return {
442479
requiredLabels: validateLabelNameEntries(labelTestWorkflowConfig.requiredLabels ?? [], "requiredLabels"),
443480
failingLabels: validateLabelNameEntries(labelTestWorkflowConfig.failingLabels ?? [], "failingLabels"),
481+
repositoryLabels: validateRepositoryLabelEntries(labelTestWorkflowConfig.repositoryLabels ?? {}),
444482
protectedLabelApprovals: validateProtectedLabelApprovals(labelTestWorkflowConfig.protectedLabelApprovals ?? []),
445483
workflowDistribution: {
446484
whitelist: validateRepositoryEntries(

scripts/lib/label-test-workflow.mjs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { normalizeName } from "./config-utils.mjs";
1+
import { normalizeName, normalizeRepositoryRef } from "./config-utils.mjs";
22

33
function labelNames(labels) {
44
return new Map(
@@ -91,14 +91,24 @@ async function hasAcceptedProtectedApproval(approvers, approvedReviews, isTeamMe
9191

9292
export async function evaluatePrLabelTest({
9393
config,
94+
targetRepository,
9495
prLabels,
9596
reviews,
9697
isTeamMember,
9798
}) {
9899
const failures = [];
99100
const presentLabels = labelNames(prLabels);
100-
const requiredLabels = config.requiredLabels ?? [];
101-
const failingLabels = config.failingLabels ?? [];
101+
const repositoryRules = targetRepository
102+
? config.repositoryLabels?.get(normalizeRepositoryRef(targetRepository))
103+
: undefined;
104+
const requiredLabels = [
105+
...(config.requiredLabels ?? []),
106+
...(repositoryRules?.requiredLabels ?? []),
107+
];
108+
const failingLabels = [
109+
...(config.failingLabels ?? []),
110+
...(repositoryRules?.failingLabels ?? []),
111+
];
102112
const protectedLabelApprovals = config.protectedLabelApprovals ?? [];
103113

104114
if (

scripts/reset-configs.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ const configDefaults = {
109109
// "Do Not Merge"
110110
],
111111
112+
// Add required or failing labels that apply only to one repository. Repository keys must use the
113+
// full "owner/repository" name. These lists are added to the organization-wide lists above.
114+
"repositoryLabels": {
115+
// "your-org-name/special-repo": {
116+
// "requiredLabels": ["Repo Feature"],
117+
// "failingLabels": ["Repo: Do Not Merge"]
118+
// }
119+
},
120+
112121
// If a listed label is present on a PR, at least one listed user or team member for that label must have
113122
// latest effective review state APPROVED.
114123
"protectedLabelApprovals": [

test/label-test-workflow-validation.test.mjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ test("validateLabelTestWorkflowConfig accepts empty required labels", () => {
77
const config = validateLabelTestWorkflowConfig({
88
requiredLabels: [],
99
failingLabels: ["Blocked"],
10+
repositoryLabels: {
11+
"Example-Org/Special-Repo": {
12+
requiredLabels: [" Repo Feature "],
13+
failingLabels: ["Repo: Do Not Merge"],
14+
},
15+
},
1016
protectedLabelApprovals: [
1117
{ label: "Affects Balance", approver: "teams/admin" },
1218
{ label: "Affects Balance", approver: "UltraProdigy" },
@@ -19,6 +25,12 @@ test("validateLabelTestWorkflowConfig accepts empty required labels", () => {
1925

2026
assert.deepEqual(config.requiredLabels, []);
2127
assert.deepEqual(config.failingLabels, ["Blocked"]);
28+
assert.deepEqual(config.repositoryLabels, new Map([
29+
["example-org/special-repo", {
30+
requiredLabels: ["Repo Feature"],
31+
failingLabels: ["Repo: Do Not Merge"],
32+
}],
33+
]));
2234
assert.deepEqual(config.protectedLabelApprovals, [
2335
{ label: "Affects Balance", approver: { type: "team", slug: "admin", value: "teams/admin" } },
2436
{ label: "Affects Balance", approver: { type: "user", login: "UltraProdigy", value: "UltraProdigy" } },
@@ -27,6 +39,40 @@ test("validateLabelTestWorkflowConfig accepts empty required labels", () => {
2739
assert.equal(config.workflowDistribution.whitelist.has("example-org/other-repo"), true);
2840
});
2941

42+
test("validateLabelTestWorkflowConfig requires full names for repository-specific labels", () => {
43+
assert.throws(
44+
() => validateLabelTestWorkflowConfig({
45+
requiredLabels: [],
46+
failingLabels: [],
47+
repositoryLabels: {
48+
"special-repo": {
49+
requiredLabels: ["Repo Feature"],
50+
failingLabels: [],
51+
},
52+
},
53+
protectedLabelApprovals: [],
54+
workflowDistribution: { whitelist: [], blacklist: [] },
55+
}),
56+
/repositoryLabels key "special-repo" must be an "owner\/repository" name\./,
57+
);
58+
});
59+
60+
test("validateLabelTestWorkflowConfig rejects duplicate normalized repository-specific keys", () => {
61+
assert.throws(
62+
() => validateLabelTestWorkflowConfig({
63+
requiredLabels: [],
64+
failingLabels: [],
65+
repositoryLabels: {
66+
"Example-Org/Special-Repo": { requiredLabels: [], failingLabels: [] },
67+
"example-org/special-repo": { requiredLabels: [], failingLabels: [] },
68+
},
69+
protectedLabelApprovals: [],
70+
workflowDistribution: { whitelist: [], blacklist: [] },
71+
}),
72+
/Duplicate repositoryLabels key detected: "example-org\/special-repo"\./,
73+
);
74+
});
75+
3076
test("validateLabelTestWorkflowConfig rejects duplicate required labels", () => {
3177
assert.throws(
3278
() => validateLabelTestWorkflowConfig({

test/label-test-workflow.test.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const emptyConfig = {
1010
requiredLabels: [],
1111
failingLabels: [],
1212
protectedLabelApprovals: [],
13+
repositoryLabels: new Map(),
1314
};
1415

1516
test("evaluatePrLabelTest passes when required labels are empty and no blocking rules match", async () => {
@@ -59,6 +60,72 @@ test("evaluatePrLabelTest lets failing labels override matching required labels"
5960
]);
6061
});
6162

63+
test("evaluatePrLabelTest accepts a repository-specific required label", async () => {
64+
const result = await evaluatePrLabelTest({
65+
config: {
66+
...emptyConfig,
67+
requiredLabels: ["Bug"],
68+
repositoryLabels: new Map([
69+
["example/special-repo", {
70+
requiredLabels: ["Repo Feature"],
71+
failingLabels: [],
72+
}],
73+
]),
74+
},
75+
targetRepository: "example/special-repo",
76+
prLabels: [{ name: "Repo Feature" }],
77+
reviews: [],
78+
isTeamMember: async () => false,
79+
});
80+
81+
assert.equal(result.passed, true);
82+
assert.deepEqual(result.failures, []);
83+
});
84+
85+
test("evaluatePrLabelTest rejects a repository-specific failing label", async () => {
86+
const result = await evaluatePrLabelTest({
87+
config: {
88+
...emptyConfig,
89+
repositoryLabels: new Map([
90+
["example/special-repo", {
91+
requiredLabels: [],
92+
failingLabels: ["Repo: Do Not Merge"],
93+
}],
94+
]),
95+
},
96+
targetRepository: "EXAMPLE/SPECIAL-REPO",
97+
prLabels: [{ name: "Repo: Do Not Merge" }],
98+
reviews: [],
99+
isTeamMember: async () => false,
100+
});
101+
102+
assert.equal(result.passed, false);
103+
assert.deepEqual(result.failures, [
104+
'PR has failing label "Repo: Do Not Merge".',
105+
]);
106+
});
107+
108+
test("evaluatePrLabelTest ignores label rules configured for another repository", async () => {
109+
const result = await evaluatePrLabelTest({
110+
config: {
111+
...emptyConfig,
112+
repositoryLabels: new Map([
113+
["example/special-repo", {
114+
requiredLabels: ["Repo Feature"],
115+
failingLabels: ["Repo: Do Not Merge"],
116+
}],
117+
]),
118+
},
119+
targetRepository: "example/ordinary-repo",
120+
prLabels: [{ name: "Repo: Do Not Merge" }],
121+
reviews: [],
122+
isTeamMember: async () => false,
123+
});
124+
125+
assert.equal(result.passed, true);
126+
assert.deepEqual(result.failures, []);
127+
});
128+
62129
test("evaluatePrLabelTest accepts a protected label approval from a configured user", async () => {
63130
const result = await evaluatePrLabelTest({
64131
config: {

test/reset-configs.test.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ test("reset-configs can reset label-test-workflow-config.jsonc", async () => {
2424
JSON.stringify({
2525
requiredLabels: ["Bug"],
2626
failingLabels: ["Blocked"],
27+
repositoryLabels: {
28+
"example-org/example-repo": {
29+
requiredLabels: ["Repo Feature"],
30+
failingLabels: ["Repo Blocked"],
31+
},
32+
},
2733
protectedLabelApprovals: [
2834
{ label: "Affects Balance", approver: "UltraProdigy" },
2935
],
@@ -53,6 +59,7 @@ test("reset-configs can reset label-test-workflow-config.jsonc", async () => {
5359
assert.deepEqual(resetConfig, {
5460
requiredLabels: [],
5561
failingLabels: [],
62+
repositoryLabels: {},
5663
protectedLabelApprovals: [],
5764
workflowDistribution: {
5865
whitelist: [],

0 commit comments

Comments
 (0)