create-interview-cockpit 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/codeowners.ts +792 -0
- package/template/client/src/components/CodeContextPanel.tsx +44 -0
- package/template/client/src/components/DiagramsModal.tsx +839 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +291 -264
- package/template/client/src/components/LabsPanel.tsx +3 -3
- package/template/client/src/components/PullRequestPanel.tsx +1142 -0
- package/template/client/src/components/SettingsPanel.tsx +1395 -0
- package/template/client/src/githubActionsLab.ts +461 -3
- package/template/client/src/types.ts +219 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +1 -1
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// ─── CODEOWNERS parser, evaluator, and roster helpers ──────────────────
|
|
2
|
+
//
|
|
3
|
+
// This module is a small, self-contained imitation of the CODEOWNERS
|
|
4
|
+
// engine GitHub runs on every pull request. It powers:
|
|
5
|
+
//
|
|
6
|
+
// 1. Live validation in the Monaco editor when the user opens the
|
|
7
|
+
// CODEOWNERS file (unknown owners, bad globs, comments, blank lines).
|
|
8
|
+
// 2. The "Pull Request" tab — given a set of changed files, work out
|
|
9
|
+
// which rule (if any) wins for each file and which logins/teams get
|
|
10
|
+
// auto-requested as reviewers.
|
|
11
|
+
// 3. The "merge box" — combine CODEOWNERS results with branch
|
|
12
|
+
// protection rules to decide if the PR is mergeable.
|
|
13
|
+
// 4. Console messages emitted by the server when an `act push` or
|
|
14
|
+
// `act pull_request` event runs in the lab.
|
|
15
|
+
//
|
|
16
|
+
// References / behaviour mirrored:
|
|
17
|
+
// - LAST matching rule wins (https://docs.github.com/repositories/
|
|
18
|
+
// managing-your-repositories-settings-and-features/customizing-your-
|
|
19
|
+
// repository/about-code-owners#codeowners-syntax).
|
|
20
|
+
// - Blank lines and `# comments` are ignored.
|
|
21
|
+
// - A rule with no owners explicitly clears ownership for the matched
|
|
22
|
+
// pattern (used to "unprotect" sub-paths).
|
|
23
|
+
// - Owner handles look like `@user`, `@org/team`, or `email@host`.
|
|
24
|
+
//
|
|
25
|
+
// We intentionally support a useful subset of glob features rather than
|
|
26
|
+
// re-implementing GitHub's exact matcher: `*`, `**`, `?`, leading `/`,
|
|
27
|
+
// trailing `/` (folder-only). That covers ~all real CODEOWNERS rules.
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
GithubLabOrg,
|
|
31
|
+
GithubLabTeam,
|
|
32
|
+
GithubLabPullRequest,
|
|
33
|
+
GithubLabBranchProtection,
|
|
34
|
+
GithubLabReview,
|
|
35
|
+
GithubLabCheckRun,
|
|
36
|
+
GithubLabRuleset,
|
|
37
|
+
GithubLabRulesetRules,
|
|
38
|
+
} from "./types";
|
|
39
|
+
|
|
40
|
+
// ─── Parsing ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface CodeOwnersRule {
|
|
43
|
+
/** 1-based line number in the source file (for editor markers). */
|
|
44
|
+
line: number;
|
|
45
|
+
/** Raw line text (after comment stripping) — handy for debugging. */
|
|
46
|
+
raw: string;
|
|
47
|
+
/** The glob pattern as written by the user (e.g. `src/**` or `*.ts`). */
|
|
48
|
+
pattern: string;
|
|
49
|
+
/** Owner handles (without leading `@`) — empty array means "no owners". */
|
|
50
|
+
owners: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CodeOwnersIssue {
|
|
54
|
+
line: number;
|
|
55
|
+
severity: "error" | "warning";
|
|
56
|
+
message: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CodeOwnersParseResult {
|
|
60
|
+
rules: CodeOwnersRule[];
|
|
61
|
+
issues: CodeOwnersIssue[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const HANDLE_RE =
|
|
65
|
+
/^@?[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\/[A-Za-z0-9][A-Za-z0-9-]{0,38})?$/;
|
|
66
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
67
|
+
|
|
68
|
+
/** Parse a CODEOWNERS file body into ordered rules + diagnostics. */
|
|
69
|
+
export function parseCodeOwners(text: string): CodeOwnersParseResult {
|
|
70
|
+
const rules: CodeOwnersRule[] = [];
|
|
71
|
+
const issues: CodeOwnersIssue[] = [];
|
|
72
|
+
const lines = text.split(/\r?\n/);
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
75
|
+
const rawLine = lines[i] ?? "";
|
|
76
|
+
// Strip inline `# comment` but keep escaped `\#`.
|
|
77
|
+
let stripped = "";
|
|
78
|
+
for (let c = 0; c < rawLine.length; c += 1) {
|
|
79
|
+
const ch = rawLine[c];
|
|
80
|
+
if (ch === "#" && rawLine[c - 1] !== "\\") break;
|
|
81
|
+
stripped += ch;
|
|
82
|
+
}
|
|
83
|
+
const trimmed = stripped.trim();
|
|
84
|
+
if (!trimmed) continue;
|
|
85
|
+
|
|
86
|
+
const tokens = trimmed.split(/\s+/);
|
|
87
|
+
const pattern = tokens[0] ?? "";
|
|
88
|
+
const owners = tokens.slice(1);
|
|
89
|
+
|
|
90
|
+
if (!pattern) continue;
|
|
91
|
+
|
|
92
|
+
// Pattern sanity check: must contain at least one path-y character.
|
|
93
|
+
if (/[\\]/.test(pattern)) {
|
|
94
|
+
issues.push({
|
|
95
|
+
line: i + 1,
|
|
96
|
+
severity: "warning",
|
|
97
|
+
message: `Pattern "${pattern}" uses a backslash; CODEOWNERS expects forward slashes.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate owner shapes (don't yet check if they exist — that's
|
|
102
|
+
// a separate pass that needs the roster).
|
|
103
|
+
for (const o of owners) {
|
|
104
|
+
const handle = o.startsWith("@") ? o.slice(1) : o;
|
|
105
|
+
const looksLikeEmail = EMAIL_RE.test(o);
|
|
106
|
+
const looksLikeHandle = HANDLE_RE.test(handle);
|
|
107
|
+
if (!looksLikeEmail && !looksLikeHandle) {
|
|
108
|
+
issues.push({
|
|
109
|
+
line: i + 1,
|
|
110
|
+
severity: "error",
|
|
111
|
+
message: `Owner "${o}" is not a valid @user, @org/team, or email.`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
rules.push({
|
|
117
|
+
line: i + 1,
|
|
118
|
+
raw: stripped,
|
|
119
|
+
pattern,
|
|
120
|
+
owners: owners.map((o) => (o.startsWith("@") ? o.slice(1) : o)),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { rules, issues };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Cross-check parsed rules against the lab's fake org roster. Any owner
|
|
129
|
+
* that does not resolve to a known user, team, or email gets a warning
|
|
130
|
+
* marker. This is the "your reviewers don't exist" hint a real GitHub
|
|
131
|
+
* org sees in the CODEOWNERS settings page.
|
|
132
|
+
*/
|
|
133
|
+
export function lintCodeOwnersAgainstOrg(
|
|
134
|
+
parsed: CodeOwnersParseResult,
|
|
135
|
+
org: GithubLabOrg | undefined,
|
|
136
|
+
): CodeOwnersIssue[] {
|
|
137
|
+
if (!org) return parsed.issues;
|
|
138
|
+
const knownUsers = new Set(org.users.map((u) => u.toLowerCase()));
|
|
139
|
+
const knownTeams = new Set(
|
|
140
|
+
org.teams.map((t) => `${org.slug.toLowerCase()}/${t.slug.toLowerCase()}`),
|
|
141
|
+
);
|
|
142
|
+
const extra: CodeOwnersIssue[] = [];
|
|
143
|
+
for (const rule of parsed.rules) {
|
|
144
|
+
for (const owner of rule.owners) {
|
|
145
|
+
if (EMAIL_RE.test(owner)) continue; // emails are always opaque.
|
|
146
|
+
if (owner.includes("/")) {
|
|
147
|
+
if (!knownTeams.has(owner.toLowerCase())) {
|
|
148
|
+
extra.push({
|
|
149
|
+
line: rule.line,
|
|
150
|
+
severity: "warning",
|
|
151
|
+
message: `Team @${owner} is not in the lab roster.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} else if (!knownUsers.has(owner.toLowerCase())) {
|
|
155
|
+
extra.push({
|
|
156
|
+
line: rule.line,
|
|
157
|
+
severity: "warning",
|
|
158
|
+
message: `User @${owner} is not in the lab roster.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return [...parsed.issues, ...extra];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Glob matching ─────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Convert a CODEOWNERS-style glob into a RegExp. Supports `*` (single
|
|
170
|
+
* segment wildcard), `**` (multi-segment), `?` (single char), leading
|
|
171
|
+
* `/` (root-anchored), trailing `/` (folder + anything inside).
|
|
172
|
+
*/
|
|
173
|
+
export function codeOwnersPatternToRegExp(pattern: string): RegExp {
|
|
174
|
+
let p = pattern;
|
|
175
|
+
const rootAnchored = p.startsWith("/");
|
|
176
|
+
if (rootAnchored) p = p.slice(1);
|
|
177
|
+
|
|
178
|
+
// Folder-only patterns (trailing slash) match the folder and anything
|
|
179
|
+
// beneath it.
|
|
180
|
+
const folderOnly = p.endsWith("/");
|
|
181
|
+
if (folderOnly) p = p.slice(0, -1);
|
|
182
|
+
|
|
183
|
+
// Tokenise so `**` is treated atomically before `*`.
|
|
184
|
+
let re = "";
|
|
185
|
+
for (let i = 0; i < p.length; i += 1) {
|
|
186
|
+
const c = p[i];
|
|
187
|
+
if (c === "*" && p[i + 1] === "*") {
|
|
188
|
+
re += ".*";
|
|
189
|
+
i += 1;
|
|
190
|
+
} else if (c === "*") {
|
|
191
|
+
re += "[^/]*";
|
|
192
|
+
} else if (c === "?") {
|
|
193
|
+
re += "[^/]";
|
|
194
|
+
} else if (/[.+^${}()|[\]\\]/.test(c ?? "")) {
|
|
195
|
+
re += `\\${c}`;
|
|
196
|
+
} else {
|
|
197
|
+
re += c;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (folderOnly) re += "(?:/.*)?";
|
|
202
|
+
|
|
203
|
+
// Root-anchored: must match from start.
|
|
204
|
+
// Non-anchored: behave like gitignore — match against any path segment.
|
|
205
|
+
const anchored = rootAnchored ? `^${re}$` : `(?:^|/)${re}(?:/.*)?$`;
|
|
206
|
+
|
|
207
|
+
return new RegExp(anchored);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Evaluation ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export interface CodeOwnersMatch {
|
|
213
|
+
/** Path that was evaluated. */
|
|
214
|
+
path: string;
|
|
215
|
+
/** Rule that won (last match wins). undefined when no rule matched. */
|
|
216
|
+
rule?: CodeOwnersRule;
|
|
217
|
+
/** Resolved logins (users) after expanding teams. */
|
|
218
|
+
resolvedReviewers: string[];
|
|
219
|
+
/** The raw owner handles from the winning rule (for display). */
|
|
220
|
+
owners: string[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface CodeOwnersEvaluation {
|
|
224
|
+
perFile: CodeOwnersMatch[];
|
|
225
|
+
/** Unique owner handles across all matched files (e.g. `@frontend`, `@octocat`). */
|
|
226
|
+
requestedHandles: string[];
|
|
227
|
+
/** Unique resolved logins (without @) — these are the people pinged. */
|
|
228
|
+
requestedReviewers: string[];
|
|
229
|
+
/** Files that have at least one CODEOWNERS rule matching them. */
|
|
230
|
+
ownedFiles: string[];
|
|
231
|
+
/** Files with no matching rule. */
|
|
232
|
+
unownedFiles: string[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function evaluateCodeOwners(
|
|
236
|
+
rules: CodeOwnersRule[],
|
|
237
|
+
changedFiles: string[],
|
|
238
|
+
org?: GithubLabOrg,
|
|
239
|
+
): CodeOwnersEvaluation {
|
|
240
|
+
const compiled = rules.map((r) => ({
|
|
241
|
+
rule: r,
|
|
242
|
+
re: codeOwnersPatternToRegExp(r.pattern),
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const perFile: CodeOwnersMatch[] = changedFiles.map((path) => {
|
|
246
|
+
// Last match wins — iterate in reverse.
|
|
247
|
+
let winner: CodeOwnersRule | undefined;
|
|
248
|
+
for (let i = compiled.length - 1; i >= 0; i -= 1) {
|
|
249
|
+
const c = compiled[i];
|
|
250
|
+
if (c && c.re.test(path)) {
|
|
251
|
+
winner = c.rule;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const owners = winner?.owners ?? [];
|
|
256
|
+
const resolved = resolveOwnersToLogins(owners, org);
|
|
257
|
+
return {
|
|
258
|
+
path,
|
|
259
|
+
...(winner ? { rule: winner } : {}),
|
|
260
|
+
owners,
|
|
261
|
+
resolvedReviewers: resolved,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const handleSet = new Set<string>();
|
|
266
|
+
const reviewerSet = new Set<string>();
|
|
267
|
+
const ownedFiles: string[] = [];
|
|
268
|
+
const unownedFiles: string[] = [];
|
|
269
|
+
|
|
270
|
+
for (const m of perFile) {
|
|
271
|
+
if (m.rule && m.owners.length > 0) {
|
|
272
|
+
ownedFiles.push(m.path);
|
|
273
|
+
m.owners.forEach((h) => handleSet.add(h));
|
|
274
|
+
m.resolvedReviewers.forEach((r) => reviewerSet.add(r));
|
|
275
|
+
} else {
|
|
276
|
+
unownedFiles.push(m.path);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
perFile,
|
|
282
|
+
requestedHandles: [...handleSet].sort(),
|
|
283
|
+
requestedReviewers: [...reviewerSet].sort(),
|
|
284
|
+
ownedFiles,
|
|
285
|
+
unownedFiles,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Expand `@org/team` handles into individual logins via the roster. */
|
|
290
|
+
export function resolveOwnersToLogins(
|
|
291
|
+
owners: string[],
|
|
292
|
+
org?: GithubLabOrg,
|
|
293
|
+
): string[] {
|
|
294
|
+
if (!owners.length) return [];
|
|
295
|
+
const out = new Set<string>();
|
|
296
|
+
for (const owner of owners) {
|
|
297
|
+
if (EMAIL_RE.test(owner)) continue; // emails don't map to logins.
|
|
298
|
+
if (owner.includes("/")) {
|
|
299
|
+
const [orgSlug, teamSlug] = owner.split("/");
|
|
300
|
+
const team = findTeam(org, orgSlug ?? "", teamSlug ?? "");
|
|
301
|
+
team?.members.forEach((m) => out.add(m));
|
|
302
|
+
} else {
|
|
303
|
+
out.add(owner);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return [...out];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function findTeam(
|
|
310
|
+
org: GithubLabOrg | undefined,
|
|
311
|
+
orgSlug: string,
|
|
312
|
+
teamSlug: string,
|
|
313
|
+
): GithubLabTeam | undefined {
|
|
314
|
+
if (!org) return undefined;
|
|
315
|
+
if (org.slug.toLowerCase() !== orgSlug.toLowerCase()) return undefined;
|
|
316
|
+
return org.teams.find((t) => t.slug.toLowerCase() === teamSlug.toLowerCase());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Reviews ───────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Collapse a review log into the *current* state per reviewer — only
|
|
323
|
+
* the latest review by each author counts toward merge protection on
|
|
324
|
+
* github.com, so we mirror that here.
|
|
325
|
+
*
|
|
326
|
+
* The PR author's own reviews are ignored (you can't review your own PR).
|
|
327
|
+
*/
|
|
328
|
+
export function latestReviewByAuthor(
|
|
329
|
+
reviews: GithubLabReview[] | undefined,
|
|
330
|
+
viewerLogin?: string,
|
|
331
|
+
): Map<string, GithubLabReview> {
|
|
332
|
+
const out = new Map<string, GithubLabReview>();
|
|
333
|
+
if (!reviews?.length) return out;
|
|
334
|
+
const sorted = [...reviews].sort(
|
|
335
|
+
(a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt),
|
|
336
|
+
);
|
|
337
|
+
for (const r of sorted) {
|
|
338
|
+
if (viewerLogin && r.author.toLowerCase() === viewerLogin.toLowerCase()) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// "commented" reviews never override an existing approve/changes_requested.
|
|
342
|
+
const existing = out.get(r.author.toLowerCase());
|
|
343
|
+
if (r.state === "commented" && existing) continue;
|
|
344
|
+
out.set(r.author.toLowerCase(), r);
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Logins that currently sit at state="approved" in the review log. */
|
|
350
|
+
export function approvedLogins(
|
|
351
|
+
reviews: GithubLabReview[] | undefined,
|
|
352
|
+
viewerLogin?: string,
|
|
353
|
+
): string[] {
|
|
354
|
+
const latest = latestReviewByAuthor(reviews, viewerLogin);
|
|
355
|
+
return [...latest.values()]
|
|
356
|
+
.filter((r) => r.state === "approved")
|
|
357
|
+
.map((r) => r.author);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Logins that currently sit at state="changes_requested". */
|
|
361
|
+
export function changesRequestedLogins(
|
|
362
|
+
reviews: GithubLabReview[] | undefined,
|
|
363
|
+
viewerLogin?: string,
|
|
364
|
+
): string[] {
|
|
365
|
+
const latest = latestReviewByAuthor(reviews, viewerLogin);
|
|
366
|
+
return [...latest.values()]
|
|
367
|
+
.filter((r) => r.state === "changes_requested")
|
|
368
|
+
.map((r) => r.author);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── Mergeability ──────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
export interface CheckStatus {
|
|
374
|
+
name: string;
|
|
375
|
+
status: "success" | "failed" | "pending" | "missing";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface MergeabilityResult {
|
|
379
|
+
mergeable: boolean;
|
|
380
|
+
reasons: string[];
|
|
381
|
+
/** Owners still needed for required-CODEOWNERS approval, by file path. */
|
|
382
|
+
pendingOwnersByFile: Record<string, string[]>;
|
|
383
|
+
/** Count of approvals already collected. */
|
|
384
|
+
approvalCount: number;
|
|
385
|
+
/** How many more approvals are required overall. */
|
|
386
|
+
approvalsRemaining: number;
|
|
387
|
+
/** Logins who have actively requested changes (blocks merge). */
|
|
388
|
+
changesRequestedBy: string[];
|
|
389
|
+
/** Required status checks resolved against the latest run. */
|
|
390
|
+
checkStatuses: CheckStatus[];
|
|
391
|
+
/** Active rulesets that affected the result (for the UI summary). */
|
|
392
|
+
appliedRulesets: GithubLabRuleset[];
|
|
393
|
+
/** Combined effective rules — used by the PR sidebar to show config. */
|
|
394
|
+
effectiveRules: GithubLabRulesetRules;
|
|
395
|
+
/** Reasons that come from rulesets in `evaluate` mode (warning-only). */
|
|
396
|
+
evaluateOnlyReasons: string[];
|
|
397
|
+
/** Informational notes for rules the lab can't fully simulate. */
|
|
398
|
+
informationalNotes: string[];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ─── Ruleset matching ──────────────────────────────────────────────────
|
|
402
|
+
//
|
|
403
|
+
// GitHub uses fnmatch-style globs for ruleset targeting plus a couple of
|
|
404
|
+
// special tokens. We support `~ALL` (every ref), `~DEFAULT_BRANCH`,
|
|
405
|
+
// literal names, and the same `*` / `**` / `?` globs CODEOWNERS uses.
|
|
406
|
+
|
|
407
|
+
function matchesBranchPattern(
|
|
408
|
+
pattern: string,
|
|
409
|
+
branch: string,
|
|
410
|
+
defaultBranch: string,
|
|
411
|
+
): boolean {
|
|
412
|
+
if (!pattern) return false;
|
|
413
|
+
if (pattern === "~ALL") return true;
|
|
414
|
+
if (pattern === "~DEFAULT_BRANCH") return branch === defaultBranch;
|
|
415
|
+
if (pattern === branch) return true;
|
|
416
|
+
return codeOwnersPatternToRegExp(pattern).test(branch);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Pick the rulesets that affect a PR targeting `branch`, honoring
|
|
421
|
+
* enforcement state and the bypass list. Disabled rulesets are filtered
|
|
422
|
+
* out entirely; `evaluate` rulesets are kept so the UI can render them
|
|
423
|
+
* as warnings instead of hard blockers.
|
|
424
|
+
*/
|
|
425
|
+
export function getApplicableRulesets(
|
|
426
|
+
rulesets: GithubLabRuleset[] | undefined,
|
|
427
|
+
branch: string,
|
|
428
|
+
defaultBranch: string,
|
|
429
|
+
org?: GithubLabOrg,
|
|
430
|
+
): GithubLabRuleset[] {
|
|
431
|
+
if (!rulesets || rulesets.length === 0) return [];
|
|
432
|
+
const viewer = org?.viewerLogin?.toLowerCase();
|
|
433
|
+
return rulesets.filter((rs) => {
|
|
434
|
+
if (rs.enforcement === "disabled") return false;
|
|
435
|
+
const included = rs.targetInclude.some((p) =>
|
|
436
|
+
matchesBranchPattern(p, branch, defaultBranch),
|
|
437
|
+
);
|
|
438
|
+
if (!included) return false;
|
|
439
|
+
const excluded = (rs.targetExclude ?? []).some((p) =>
|
|
440
|
+
matchesBranchPattern(p, branch, defaultBranch),
|
|
441
|
+
);
|
|
442
|
+
if (excluded) return false;
|
|
443
|
+
if (viewer) {
|
|
444
|
+
const bypassed = (rs.bypass ?? []).some((handle) => {
|
|
445
|
+
const logins = resolveOwnersToLogins([handle], org);
|
|
446
|
+
return logins.some((l) => l.toLowerCase() === viewer);
|
|
447
|
+
});
|
|
448
|
+
if (bypassed) return false;
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Combine the rules from several rulesets into one effective rule bag.
|
|
456
|
+
* GitHub takes the strictest constraint when rulesets overlap, so we
|
|
457
|
+
* OR booleans, take the max of the approving-review count, and union
|
|
458
|
+
* the status-check name lists / environment lists / scanning tools.
|
|
459
|
+
*/
|
|
460
|
+
export function mergeRules(
|
|
461
|
+
rulesets: GithubLabRuleset[],
|
|
462
|
+
): GithubLabRulesetRules {
|
|
463
|
+
const out: GithubLabRulesetRules = {};
|
|
464
|
+
for (const rs of rulesets) {
|
|
465
|
+
const r = rs.rules;
|
|
466
|
+
if (r.restrictCreations) out.restrictCreations = true;
|
|
467
|
+
if (r.restrictUpdates) out.restrictUpdates = true;
|
|
468
|
+
if (r.restrictDeletions) out.restrictDeletions = true;
|
|
469
|
+
if (r.requireLinearHistory) out.requireLinearHistory = true;
|
|
470
|
+
if (r.requireSignedCommits) out.requireSignedCommits = true;
|
|
471
|
+
if (r.blockForcePushes) out.blockForcePushes = true;
|
|
472
|
+
if (r.requireCodeQuality) out.requireCodeQuality = true;
|
|
473
|
+
if (r.pullRequest) {
|
|
474
|
+
const cur = out.pullRequest ?? {
|
|
475
|
+
requiredApprovingReviewCount: 0,
|
|
476
|
+
requireCodeOwnerReview: false,
|
|
477
|
+
dismissStaleReviewsOnPush: false,
|
|
478
|
+
requireLastPushApproval: false,
|
|
479
|
+
};
|
|
480
|
+
out.pullRequest = {
|
|
481
|
+
requiredApprovingReviewCount: Math.max(
|
|
482
|
+
cur.requiredApprovingReviewCount,
|
|
483
|
+
r.pullRequest.requiredApprovingReviewCount,
|
|
484
|
+
),
|
|
485
|
+
requireCodeOwnerReview:
|
|
486
|
+
cur.requireCodeOwnerReview || r.pullRequest.requireCodeOwnerReview,
|
|
487
|
+
dismissStaleReviewsOnPush:
|
|
488
|
+
cur.dismissStaleReviewsOnPush ||
|
|
489
|
+
r.pullRequest.dismissStaleReviewsOnPush,
|
|
490
|
+
requireLastPushApproval:
|
|
491
|
+
cur.requireLastPushApproval || r.pullRequest.requireLastPushApproval,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (r.statusChecks) {
|
|
495
|
+
const cur = out.statusChecks ?? { checks: [], strict: false };
|
|
496
|
+
out.statusChecks = {
|
|
497
|
+
checks: Array.from(
|
|
498
|
+
new Set([...(cur.checks ?? []), ...(r.statusChecks.checks ?? [])]),
|
|
499
|
+
),
|
|
500
|
+
strict: cur.strict || r.statusChecks.strict,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (r.requireDeployments) {
|
|
504
|
+
const cur = out.requireDeployments ?? { environments: [] };
|
|
505
|
+
out.requireDeployments = {
|
|
506
|
+
environments: Array.from(
|
|
507
|
+
new Set([...cur.environments, ...r.requireDeployments.environments]),
|
|
508
|
+
),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (r.requireCodeScanning) {
|
|
512
|
+
const cur = out.requireCodeScanning ?? { tools: [] };
|
|
513
|
+
out.requireCodeScanning = {
|
|
514
|
+
tools: Array.from(
|
|
515
|
+
new Set([...cur.tools, ...r.requireCodeScanning.tools]),
|
|
516
|
+
),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function evaluateMergeability(
|
|
524
|
+
evaluation: CodeOwnersEvaluation,
|
|
525
|
+
pr: GithubLabPullRequest,
|
|
526
|
+
rulesets: GithubLabRuleset[] | undefined,
|
|
527
|
+
org: GithubLabOrg | undefined,
|
|
528
|
+
baseBranch: string,
|
|
529
|
+
defaultBranch: string,
|
|
530
|
+
): MergeabilityResult {
|
|
531
|
+
const reasons: string[] = [];
|
|
532
|
+
const evaluateOnlyReasons: string[] = [];
|
|
533
|
+
const informationalNotes: string[] = [];
|
|
534
|
+
const viewer = org?.viewerLogin;
|
|
535
|
+
|
|
536
|
+
const applicable = getApplicableRulesets(
|
|
537
|
+
rulesets,
|
|
538
|
+
baseBranch,
|
|
539
|
+
defaultBranch,
|
|
540
|
+
org,
|
|
541
|
+
);
|
|
542
|
+
const enforced = applicable.filter((r) => r.enforcement === "active");
|
|
543
|
+
const evaluateOnly = applicable.filter((r) => r.enforcement === "evaluate");
|
|
544
|
+
const enforcedRules = mergeRules(enforced);
|
|
545
|
+
const evaluateRules = mergeRules(evaluateOnly);
|
|
546
|
+
const effectiveRules = mergeRules(applicable);
|
|
547
|
+
|
|
548
|
+
// Reviews drive approvals; fall back to legacy `approvals` for old labs.
|
|
549
|
+
const approved = pr.reviews
|
|
550
|
+
? approvedLogins(pr.reviews, viewer)
|
|
551
|
+
: (pr.approvals ?? []).filter(
|
|
552
|
+
(a) => !viewer || a.toLowerCase() !== viewer.toLowerCase(),
|
|
553
|
+
);
|
|
554
|
+
const approvedSet = new Set(approved.map((a) => a.toLowerCase()));
|
|
555
|
+
const changesRequestedBy = changesRequestedLogins(pr.reviews, viewer);
|
|
556
|
+
|
|
557
|
+
// ── Pull-request rule (required reviews + code owners) ────────
|
|
558
|
+
const prRule = enforcedRules.pullRequest;
|
|
559
|
+
const prRuleEval = evaluateRules.pullRequest;
|
|
560
|
+
const requiredCount = prRule?.requiredApprovingReviewCount ?? 0;
|
|
561
|
+
const approvalsRemaining = Math.max(0, requiredCount - approvedSet.size);
|
|
562
|
+
if (approvalsRemaining > 0) {
|
|
563
|
+
reasons.push(
|
|
564
|
+
`Need ${approvalsRemaining} more approving review${
|
|
565
|
+
approvalsRemaining === 1 ? "" : "s"
|
|
566
|
+
} (have ${approvedSet.size}/${requiredCount}).`,
|
|
567
|
+
);
|
|
568
|
+
} else if (prRuleEval && !prRule) {
|
|
569
|
+
const evalRemaining = Math.max(
|
|
570
|
+
0,
|
|
571
|
+
prRuleEval.requiredApprovingReviewCount - approvedSet.size,
|
|
572
|
+
);
|
|
573
|
+
if (evalRemaining > 0) {
|
|
574
|
+
evaluateOnlyReasons.push(
|
|
575
|
+
`Would need ${evalRemaining} more approving review${
|
|
576
|
+
evalRemaining === 1 ? "" : "s"
|
|
577
|
+
} when ruleset is enforced.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// "Changes requested" reviews always block, regardless of ruleset.
|
|
583
|
+
if (changesRequestedBy.length > 0) {
|
|
584
|
+
reasons.push(
|
|
585
|
+
`Changes requested by ${changesRequestedBy
|
|
586
|
+
.map((a) => `@${a}`)
|
|
587
|
+
.join(", ")}.`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── CODEOWNERS approval per matched file ──────────────────────
|
|
592
|
+
const pendingOwnersByFile: Record<string, string[]> = {};
|
|
593
|
+
const requireCO =
|
|
594
|
+
!!prRule?.requireCodeOwnerReview || !!prRuleEval?.requireCodeOwnerReview;
|
|
595
|
+
if (requireCO) {
|
|
596
|
+
for (const match of evaluation.perFile) {
|
|
597
|
+
if (!match.rule || match.owners.length === 0) continue;
|
|
598
|
+
const stillNeeded = match.owners.filter((handle) => {
|
|
599
|
+
const reviewers = resolveOwnersToLogins([handle], org);
|
|
600
|
+
return !reviewers.some((r) => approvedSet.has(r.toLowerCase()));
|
|
601
|
+
});
|
|
602
|
+
if (stillNeeded.length > 0) {
|
|
603
|
+
pendingOwnersByFile[match.path] = stillNeeded;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const filesPending = Object.keys(pendingOwnersByFile).length;
|
|
607
|
+
if (filesPending > 0) {
|
|
608
|
+
const msg = `Code owner review required on ${filesPending} file${
|
|
609
|
+
filesPending === 1 ? "" : "s"
|
|
610
|
+
}.`;
|
|
611
|
+
if (prRule?.requireCodeOwnerReview) reasons.push(msg);
|
|
612
|
+
else if (prRuleEval?.requireCodeOwnerReview)
|
|
613
|
+
evaluateOnlyReasons.push(msg);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── Required status checks ────────────────────────────────────
|
|
618
|
+
const required = enforcedRules.statusChecks?.checks ?? [];
|
|
619
|
+
const requiredEval = evaluateRules.statusChecks?.checks ?? [];
|
|
620
|
+
const allRequired = Array.from(new Set([...required, ...requiredEval]));
|
|
621
|
+
const jobByName = new Map<string, GithubLabCheckRun["jobs"][number]>();
|
|
622
|
+
for (const j of pr.lastCheckRun?.jobs ?? []) {
|
|
623
|
+
jobByName.set(j.name, j);
|
|
624
|
+
}
|
|
625
|
+
const checkStatuses: CheckStatus[] = allRequired.map((name) => {
|
|
626
|
+
const job = jobByName.get(name);
|
|
627
|
+
if (!job) return { name, status: "missing" };
|
|
628
|
+
if (job.status === "success") return { name, status: "success" };
|
|
629
|
+
if (job.status === "failed" || job.status === "cancelled")
|
|
630
|
+
return { name, status: "failed" };
|
|
631
|
+
return { name, status: "pending" };
|
|
632
|
+
});
|
|
633
|
+
const failingEnforced = checkStatuses.filter(
|
|
634
|
+
(c) => required.includes(c.name) && c.status !== "success",
|
|
635
|
+
);
|
|
636
|
+
if (failingEnforced.length > 0) {
|
|
637
|
+
reasons.push(
|
|
638
|
+
`${failingEnforced.length} required check${
|
|
639
|
+
failingEnforced.length === 1 ? "" : "s"
|
|
640
|
+
} not green: ${failingEnforced.map((c) => c.name).join(", ")}.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
const failingEvalOnly = checkStatuses.filter(
|
|
644
|
+
(c) =>
|
|
645
|
+
!required.includes(c.name) &&
|
|
646
|
+
requiredEval.includes(c.name) &&
|
|
647
|
+
c.status !== "success",
|
|
648
|
+
);
|
|
649
|
+
if (failingEvalOnly.length > 0) {
|
|
650
|
+
evaluateOnlyReasons.push(
|
|
651
|
+
`Status checks would block when enforced: ${failingEvalOnly
|
|
652
|
+
.map((c) => c.name)
|
|
653
|
+
.join(", ")}.`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Required deployments (soft-block: lab can't deploy) ───────
|
|
658
|
+
const deployRule = enforcedRules.requireDeployments;
|
|
659
|
+
if (deployRule && deployRule.environments.length > 0) {
|
|
660
|
+
reasons.push(
|
|
661
|
+
`Awaiting successful deployment to ${deployRule.environments
|
|
662
|
+
.map((e) => `\`${e}\``)
|
|
663
|
+
.join(", ")} (no deploys recorded in lab).`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Informational rules — surfaced for learning, never blocking ─
|
|
668
|
+
if (
|
|
669
|
+
enforcedRules.requireLinearHistory ||
|
|
670
|
+
evaluateRules.requireLinearHistory
|
|
671
|
+
) {
|
|
672
|
+
informationalNotes.push(
|
|
673
|
+
"Require linear history: merge commits would be rejected on the protected branch.",
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (
|
|
677
|
+
enforcedRules.requireSignedCommits ||
|
|
678
|
+
evaluateRules.requireSignedCommits
|
|
679
|
+
) {
|
|
680
|
+
informationalNotes.push(
|
|
681
|
+
"Require signed commits: every push must carry a verified GPG/SSH signature.",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (enforcedRules.blockForcePushes || evaluateRules.blockForcePushes) {
|
|
685
|
+
informationalNotes.push(
|
|
686
|
+
"Block force pushes: `git push --force` is rejected on this branch.",
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
if (enforcedRules.restrictUpdates || evaluateRules.restrictUpdates) {
|
|
690
|
+
informationalNotes.push(
|
|
691
|
+
"Restrict updates: only the bypass list can push directly; everyone else must open a pull request.",
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (enforcedRules.restrictCreations || evaluateRules.restrictCreations) {
|
|
695
|
+
informationalNotes.push(
|
|
696
|
+
"Restrict creations: only bypass actors can create matching refs.",
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (enforcedRules.restrictDeletions || evaluateRules.restrictDeletions) {
|
|
700
|
+
informationalNotes.push(
|
|
701
|
+
"Restrict deletions: only bypass actors can delete matching refs.",
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
if (enforcedRules.requireCodeScanning || evaluateRules.requireCodeScanning) {
|
|
705
|
+
const tools =
|
|
706
|
+
enforcedRules.requireCodeScanning?.tools ??
|
|
707
|
+
evaluateRules.requireCodeScanning?.tools ??
|
|
708
|
+
[];
|
|
709
|
+
informationalNotes.push(
|
|
710
|
+
`Require code-scanning results${
|
|
711
|
+
tools.length ? ` from ${tools.join(", ")}` : ""
|
|
712
|
+
}: GitHub would require alerts to be resolved before merging.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (enforcedRules.requireCodeQuality || evaluateRules.requireCodeQuality) {
|
|
716
|
+
informationalNotes.push(
|
|
717
|
+
"Require code-quality results: configured quality gates must pass before merging.",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
mergeable: reasons.length === 0,
|
|
723
|
+
reasons,
|
|
724
|
+
pendingOwnersByFile,
|
|
725
|
+
approvalCount: approvedSet.size,
|
|
726
|
+
approvalsRemaining,
|
|
727
|
+
changesRequestedBy,
|
|
728
|
+
checkStatuses,
|
|
729
|
+
appliedRulesets: applicable,
|
|
730
|
+
effectiveRules,
|
|
731
|
+
evaluateOnlyReasons,
|
|
732
|
+
informationalNotes,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Bridge for old saved labs (pre-rulesets): turn classic
|
|
738
|
+
* `branchProtection` into a single active ruleset so the new evaluator
|
|
739
|
+
* keeps producing the same answer.
|
|
740
|
+
*/
|
|
741
|
+
export function rulesetFromLegacyProtection(
|
|
742
|
+
protection: GithubLabBranchProtection | undefined,
|
|
743
|
+
defaultBranch: string,
|
|
744
|
+
): GithubLabRuleset | undefined {
|
|
745
|
+
if (!protection) return undefined;
|
|
746
|
+
return {
|
|
747
|
+
id: "legacy",
|
|
748
|
+
name: `Default ${defaultBranch} protection`,
|
|
749
|
+
enforcement: "active",
|
|
750
|
+
targetInclude: ["~DEFAULT_BRANCH"],
|
|
751
|
+
targetExclude: [],
|
|
752
|
+
bypass: [],
|
|
753
|
+
rules: {
|
|
754
|
+
pullRequest: {
|
|
755
|
+
requiredApprovingReviewCount: protection.requiredApprovingReviews,
|
|
756
|
+
requireCodeOwnerReview: protection.requireCodeOwnerReview,
|
|
757
|
+
dismissStaleReviewsOnPush: false,
|
|
758
|
+
requireLastPushApproval: false,
|
|
759
|
+
},
|
|
760
|
+
...(protection.requiredStatusChecks &&
|
|
761
|
+
protection.requiredStatusChecks.length > 0
|
|
762
|
+
? {
|
|
763
|
+
statusChecks: {
|
|
764
|
+
checks: protection.requiredStatusChecks,
|
|
765
|
+
strict: false,
|
|
766
|
+
},
|
|
767
|
+
}
|
|
768
|
+
: {}),
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ─── Locating the CODEOWNERS file ──────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
const CODEOWNERS_LOCATIONS = [
|
|
776
|
+
".github/CODEOWNERS",
|
|
777
|
+
"CODEOWNERS",
|
|
778
|
+
"docs/CODEOWNERS",
|
|
779
|
+
] as const;
|
|
780
|
+
|
|
781
|
+
/** Find the first CODEOWNERS path that exists in the workspace, if any. */
|
|
782
|
+
export function findCodeOwnersPath(
|
|
783
|
+
files: Record<string, string>,
|
|
784
|
+
): string | undefined {
|
|
785
|
+
return CODEOWNERS_LOCATIONS.find((p) =>
|
|
786
|
+
Object.prototype.hasOwnProperty.call(files, p),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function isCodeOwnersPath(path: string): boolean {
|
|
791
|
+
return (CODEOWNERS_LOCATIONS as readonly string[]).includes(path);
|
|
792
|
+
}
|