-
Notifications
You must be signed in to change notification settings - Fork 119
212 lines (194 loc) · 9.18 KB
/
Copy pathtier-classifier.yml
File metadata and controls
212 lines (194 loc) · 9.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
name: Tier Classifier
# Labels PRs with exactly one tier/* label based on the set of file paths
# touched. The tier system is a graduated-autonomy model adapted from the
# fullsend-ai/fullsend research framework — safe changes (dep bumps, docs,
# generated files) can move fast; risky changes (workflows, auth, RBAC)
# need multi-maintainer sign-off.
#
# This workflow only LABELS — it does not enforce approval rules yet. A
# follow-up PR will wire up auto-merge for tier/0-automatic once we've
# validated the classifier against real PRs for a week.
#
# Rules live in .github/tier-classifier-rules.yml so they can be tweaked
# without editing the workflow itself.
on:
pull_request_target:
types: [opened, synchronize, reopened]
concurrency:
group: tier-classifier-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
classify:
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Checkout rules file
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: |
.github/tier-classifier-rules.yml
sparse-checkout-cone-mode: false
- name: Classify and apply tier label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
// -----------------------------------------------------------------
// Load the rules file
// -----------------------------------------------------------------
const rulesPath = '.github/tier-classifier-rules.yml';
if (!fs.existsSync(rulesPath)) {
core.setFailed(`Rules file missing: ${rulesPath}`);
return;
}
const rulesText = fs.readFileSync(rulesPath, 'utf8');
// Tiny YAML subset parser — we only need top-level tier keys
// each followed by a list of glob patterns (one per line, indented).
// Using a hand-parser avoids pulling in a yaml dep for ~30 lines.
const rules = {};
let currentTier = null;
for (const rawLine of rulesText.split('\n')) {
const line = rawLine.replace(/#.*$/, '').trimEnd();
if (!line.trim()) continue;
const tierMatch = line.match(/^(tier\/[0-9a-z-]+):\s*$/);
if (tierMatch) {
currentTier = tierMatch[1];
rules[currentTier] = [];
continue;
}
const itemMatch = line.match(/^\s*-\s*(?:"([^"]+)"|'([^']+)'|(.+))\s*$/);
if (itemMatch && currentTier) {
const pattern = (itemMatch[1] || itemMatch[2] || itemMatch[3] || '').trim();
if (pattern) rules[currentTier].push(pattern);
}
}
core.info(`Loaded tiers: ${Object.keys(rules).join(', ')}`);
// -----------------------------------------------------------------
// Fetch changed files for this PR
// -----------------------------------------------------------------
const pr = context.payload.pull_request;
const files = await github.paginate(
github.rest.pulls.listFiles,
{ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100 }
);
const paths = files.map(f => f.filename);
core.info(`PR #${pr.number} touches ${paths.length} files`);
// -----------------------------------------------------------------
// Match-glob helper — supports *, **, ? and leading/trailing /
// -----------------------------------------------------------------
function globToRegex(glob) {
// Escape regex specials, then translate glob syntax.
let re = '';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*' && glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i++;
} else if (c === '*') {
re += '[^/]*';
i++;
} else if (c === '?') {
re += '[^/]';
i++;
} else if ('.+^$()|[]{}\\'.includes(c)) {
re += '\\' + c;
i++;
} else {
re += c;
i++;
}
}
return new RegExp('^' + re + '$');
}
function matchAny(filePath, patterns) {
return patterns.some(p => globToRegex(p).test(filePath));
}
// -----------------------------------------------------------------
// Classify — evaluate tiers from most-restrictive to least.
// A PR is classified to the HIGHEST tier any of its files matches.
// Default (no match anywhere) = tier/2-standard.
// -----------------------------------------------------------------
const tierOrder = [
'tier/3-restricted',
'tier/2-standard',
'tier/1-lightweight',
'tier/0-automatic',
];
// For tier/3: ANY matching file pulls the whole PR up to tier 3.
const tier3Patterns = rules['tier/3-restricted'] || [];
const hasTier3 = paths.some(p => matchAny(p, tier3Patterns));
// For tier/0 and tier/1: EVERY file must match (and no tier/3 hits).
const tier0Patterns = rules['tier/0-automatic'] || [];
const tier1Patterns = rules['tier/1-lightweight'] || [];
const allTier0 = paths.length > 0 && paths.every(p => matchAny(p, tier0Patterns));
const allTier1 = paths.length > 0 && paths.every(p => matchAny(p, [...tier0Patterns, ...tier1Patterns]));
let classified;
if (hasTier3) classified = 'tier/3-restricted';
else if (allTier0) classified = 'tier/0-automatic';
else if (allTier1) classified = 'tier/1-lightweight';
else classified = 'tier/2-standard';
core.info(`Classified PR #${pr.number} as ${classified}`);
// -----------------------------------------------------------------
// Apply: remove any other tier/* labels, add the classified one
// -----------------------------------------------------------------
const currentLabels = pr.labels.map(l => l.name);
const toRemove = currentLabels.filter(l => l.startsWith('tier/') && l !== classified);
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name,
});
} catch (e) {
core.warning(`Could not remove label ${name}: ${e.message}`);
}
}
if (!currentLabels.includes(classified)) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [classified],
});
} catch (e) {
// Label may not exist yet — create it and retry.
core.warning(`addLabels failed: ${e.message}. Creating label and retrying.`);
const colors = {
'tier/0-automatic': '0e8a16', // green
'tier/1-lightweight': 'a2eeef', // light blue
'tier/2-standard': 'fbca04', // yellow
'tier/3-restricted': 'd93f0b', // red
};
const descriptions = {
'tier/0-automatic': 'Safe changes (deps, docs, generated) — future auto-merge candidate',
'tier/1-lightweight': 'Single-concern changes, lightweight review',
'tier/2-standard': 'Default classification — standard review required',
'tier/3-restricted': 'Touches security-sensitive paths — needs multi-maintainer sign-off',
};
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: classified,
color: colors[classified] || 'ededed',
description: descriptions[classified] || '',
}).catch(() => {}); // Ignore if it was created by another run in parallel.
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [classified],
});
}
}
core.info(`PR #${pr.number} now labeled ${classified}`);