-
Notifications
You must be signed in to change notification settings - Fork 731
Expand file tree
/
Copy pathcodeowners.ts
More file actions
156 lines (140 loc) · 4.42 KB
/
codeowners.ts
File metadata and controls
156 lines (140 loc) · 4.42 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
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import Logger from './logger';
const CODEOWNERS_ID = 'CodeOwners';
export interface CodeownersEntry {
readonly pattern: string;
readonly owners: readonly string[];
}
/**
* Parses CODEOWNERS file content into a list of entries.
* Later entries take precedence over earlier ones (per GitHub spec).
*/
export function parseCodeownersFile(content: string): CodeownersEntry[] {
const entries: CodeownersEntry[] = [];
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const parts = line.split(/\s+/);
if (parts.length < 2) {
continue;
}
const [pattern, ...owners] = parts;
entries.push({ pattern, owners });
}
return entries;
}
/**
* Given a parsed CODEOWNERS file and a file path, returns the set of owners
* for that path. Returns an empty array if no rule matches.
*
* Matching follows GitHub semantics: the last matching pattern wins.
*/
export function getOwnersForPath(entries: readonly CodeownersEntry[], filePath: string): readonly string[] {
let matched: readonly string[] = [];
for (const entry of entries) {
if (matchesCodeownersPattern(entry.pattern, filePath)) {
matched = entry.owners;
}
}
return matched;
}
/**
* Checks whether the given user login or any of the given team slugs
* (in `@org/team` format) appear among the owners list.
*/
export function isOwnedByUser(
owners: readonly string[],
userLogin: string,
teamSlugs: readonly string[],
): boolean {
const normalizedLogin = `@${userLogin.toLowerCase()}`;
const normalizedTeams = new Set(teamSlugs.map(t => t.toLowerCase()));
return owners.some(owner => {
const normalized = owner.toLowerCase();
return normalized === normalizedLogin || normalizedTeams.has(normalized);
});
}
function matchesCodeownersPattern(pattern: string, filePath: string): boolean {
try {
const regex = codeownersPatternToRegex(pattern);
return regex.test(filePath);
} catch (e) {
Logger.error(`Error matching CODEOWNERS pattern "${pattern}": ${e}`, CODEOWNERS_ID);
return false;
}
}
/**
* Converts a CODEOWNERS pattern to a RegExp.
*
* GitHub CODEOWNERS rules:
* - A leading `/` anchors to the repo root; otherwise the pattern matches anywhere.
* - A trailing `/` means "directory and everything inside".
* - `*` matches within a single path segment; `**` matches across segments.
* - Bare filenames (no `/`) match anywhere in the tree.
* - `?` matches a single non-slash character.
*/
function codeownersPatternToRegex(pattern: string): RegExp {
let p = pattern;
const anchored = p.startsWith('/');
if (anchored) {
p = p.slice(1);
}
if (p.endsWith('/')) {
p = p + '**';
}
const hasSlash = p.includes('/');
let regexStr = '';
let i = 0;
while (i < p.length) {
if (p[i] === '*') {
if (p[i + 1] === '*') {
if (p[i + 2] === '/') {
// `**/` matches zero or more directories
regexStr += '(?:.+/)?';
i += 3;
} else {
// `**` at end or before non-slash: match everything
regexStr += '.*';
i += 2;
}
} else {
// `*` matches anything except `/`
regexStr += '[^/]*';
i++;
}
} else if (p[i] === '?') {
regexStr += '[^/]';
i++;
} else if (p[i] === '.') {
regexStr += '\\.';
i++;
} else if (p[i] === '[') {
const closeBracket = p.indexOf(']', i + 1);
if (closeBracket !== -1) {
regexStr += p.slice(i, closeBracket + 1);
i = closeBracket + 1;
} else {
regexStr += '\\[';
i++;
}
} else {
regexStr += p[i];
i++;
}
}
// If the pattern has no slash (bare filename) and is not anchored,
// it can match anywhere in the tree.
const prefix = (!anchored && !hasSlash) ? '(?:^|.+/)' : '^';
// GitHub treats patterns without glob characters as matching both the
// exact path and everything inside it (implicit directory match).
const hasGlob = /[*?\[]/.test(p);
const suffix = hasGlob ? '$' : '(?:/.*)?$';
return new RegExp(prefix + regexStr + suffix);
}
/** Standard CODEOWNERS file paths in order of precedence (first found wins). */
export const CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'] as const;