virtual-code-owners 4.1.1 → 5.1.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/README.md CHANGED
@@ -167,6 +167,23 @@ Yes.
167
167
  Just make sure there's no name clashes between the username and a (virtual)
168
168
  team name and _virtual-code-owners_ will leave the real name alone.
169
169
 
170
+ ### What validations does virtual-code-owners perform?
171
+
172
+ On the VIRTUAL-CODEOWNERS.txt file it performs is a little bit of validation:
173
+
174
+ - it will find invalid user/ team names (those that don't start with an `@` or
175
+ aren't an e-mail address)
176
+ - it will find invalid 'rules'; which is the case when there is a file pattern on
177
+ the line, but no user or team names.
178
+
179
+ When it encounters any of these virtual-code-owners will emit a clear error message
180
+ with the location of the error and exit with a non-zero code, to prevent the
181
+ creation of a potentially invalid CODEOWNERS file.
182
+
183
+ It _does not_ check whether the user or team names actually exist in the current
184
+ project, though. Although nice, there's already tooling on the generated CODEOWNERS
185
+ file that will check that for you.
186
+
170
187
  ### Any limitations I should know of?
171
188
 
172
189
  - ~~Currently only works for _user names_ to identify team members - not for e-mail
@@ -202,3 +219,20 @@ like this:
202
219
  ]
203
220
  }
204
221
  ```
222
+
223
+ ### It'd be _pretty_ handy if I could see for which virtual teams a PR is. For instance with a bunch of :label: labels.
224
+
225
+ How do I go about that?
226
+
227
+ You can use the [actions/labeler](https://github.com/actions/labeler) action for
228
+ for this. Maintaining the configuration file (`.github/labeler.yml`) and keeping it
229
+ sync with the virtual-teams and virtual code-owners files manually is a bit of
230
+ a chore, though, so `virtual-code-owners` has an option to automate that.
231
+
232
+ ```sh
233
+ npx virtual-code-owners --emitLabeler
234
+ # Wrote .github/CODEOWNERS AND .github/labeler.yml
235
+ ```
236
+
237
+ If you have an alternate file location for the `labeler.yml` you can specify that
238
+ with virtual-code-owner's `--labelerLocation` parameter.
@@ -0,0 +1,46 @@
1
+ import { EOL } from "node:os";
2
+ import { isEmailIshUsername } from "./utensils.js";
3
+ const DEFAULT_WARNING = `#${EOL}` +
4
+ `# DO NOT EDIT - this file is generated and your edits will be overwritten${EOL}` +
5
+ `#${EOL}` +
6
+ `# To make changes:${EOL}` +
7
+ `#${EOL}` +
8
+ `# - edit .github/VIRTUAL-CODEOWNERS.txt${EOL}` +
9
+ `# - and/ or add team members to .github/virtual-teams.yml${EOL}` +
10
+ `# - run 'npx virtual-code-owners' (or 'npx virtual-code-owners --emitLabeler' if you also${EOL}` +
11
+ `# want to generate a .github/labeler.yml)${EOL}` +
12
+ `#${EOL}${EOL}`;
13
+ export default function generateCodeOwners(pVirtualCodeOwners, pTeamMap, pGeneratedWarning = DEFAULT_WARNING) {
14
+ return (pGeneratedWarning +
15
+ pVirtualCodeOwners
16
+ .filter((pLine) => pLine.type !== "ignorable-comment")
17
+ .map((pLine) => generateLine(pLine, pTeamMap))
18
+ .join(EOL));
19
+ }
20
+ function generateLine(pCSTLine, pTeamMap) {
21
+ if (pCSTLine.type === "rule") {
22
+ const lUserNames = uniq(pCSTLine.users.flatMap((pUser) => expandTeamToUserNames(pUser, pTeamMap)))
23
+ .sort()
24
+ .join(" ");
25
+ return pCSTLine.filesPattern + pCSTLine.spaces + lUserNames;
26
+ }
27
+ return pCSTLine.raw;
28
+ }
29
+ function expandTeamToUserNames(pUser, pTeamMap) {
30
+ if (pUser.type == "virtual-team-name") {
31
+ return stringifyTeamMembers(pTeamMap, pUser.bareName);
32
+ }
33
+ return [pUser.raw];
34
+ }
35
+ function stringifyTeamMembers(pTeamMap, pTeamName) {
36
+ return pTeamMap[pTeamName].map(userNameToCodeOwner);
37
+ }
38
+ function userNameToCodeOwner(pUserName) {
39
+ if (isEmailIshUsername(pUserName)) {
40
+ return pUserName;
41
+ }
42
+ return `@${pUserName}`;
43
+ }
44
+ function uniq(pUserNames) {
45
+ return Array.from(new Set(pUserNames));
46
+ }
@@ -0,0 +1,47 @@
1
+ import { EOL } from "node:os";
2
+ const DEFAULT_WARNING = `#${EOL}` +
3
+ `# DO NOT EDIT - this file is generated and your edits will be overwritten${EOL}` +
4
+ `#${EOL}` +
5
+ `# To make changes:${EOL}` +
6
+ `#${EOL}` +
7
+ `# - edit .github/VIRTUAL-CODEOWNERS.txt${EOL}` +
8
+ `# - and/ or add teams (& members) to .github/virtual-teams.yml${EOL}` +
9
+ `# - run 'npx virtual-code-owners --emitLabeler'${EOL}` +
10
+ `#${EOL}${EOL}`;
11
+ export default function generateLabelerYml(pCodeOwners, pTeamMap, pGeneratedWarning = DEFAULT_WARNING) {
12
+ let lReturnValue = pGeneratedWarning;
13
+ for (const lTeamName in pTeamMap) {
14
+ const lPatternsForTeam = getPatternsForTeam(pCodeOwners, lTeamName)
15
+ .map((pPattern) => ` - ${transformForYamlAndMinimatch(pPattern)}${EOL}`)
16
+ .join("");
17
+ if (lPatternsForTeam) {
18
+ lReturnValue += `${lTeamName}:${EOL}${lPatternsForTeam}${EOL}`;
19
+ }
20
+ }
21
+ return lReturnValue;
22
+ }
23
+ function getPatternsForTeam(pCodeOwners, pTeamName) {
24
+ return (pCodeOwners
25
+ .filter((pLine) => {
26
+ const isARule = pLine.type === "rule";
27
+ return (isARule &&
28
+ lineContainsTeamName(pLine, pTeamName));
29
+ })
30
+ .map((pLine) => pLine.filesPattern));
31
+ }
32
+ function transformForYamlAndMinimatch(pOriginalString) {
33
+ let lReturnValue = pOriginalString;
34
+ if (pOriginalString === "*") {
35
+ lReturnValue = "**";
36
+ }
37
+ if (lReturnValue.startsWith("*")) {
38
+ lReturnValue = `'${lReturnValue}'`;
39
+ }
40
+ if (pOriginalString.endsWith("/")) {
41
+ lReturnValue = `${lReturnValue}**`;
42
+ }
43
+ return lReturnValue;
44
+ }
45
+ function lineContainsTeamName(pLine, pTeamName) {
46
+ return pLine.users.some((pUser) => pUser.type === "virtual-team-name" && pUser.bareName === pTeamName);
47
+ }
package/dist/main.js CHANGED
@@ -1,8 +1,12 @@
1
- import { VERSION } from "./version.js";
2
- import { readAndConvert } from "./read-and-convert.js";
3
1
  import { writeFileSync } from "node:fs";
4
2
  import { EOL } from "node:os";
5
3
  import { parseArgs } from "node:util";
4
+ import generateCodeOwners from "./generate-codeowners.js";
5
+ import generateLabelerYml from "./generate-labeler-yml.js";
6
+ import readTeamMap from "./read-team-map.js";
7
+ import readVirtualCodeOwners from "./read-virtual-code-owners.js";
8
+ import { VERSION } from "./version.js";
9
+ import { getAnomalies } from "./parse.js";
6
10
  const HELP_MESSAGE = `Usage: virtual-code-owners [options]
7
11
 
8
12
  Merges a VIRTUAL-CODEOWNERS.txt and a virtual-teams.yml into CODEOWNERS
@@ -17,10 +21,15 @@ Options:
17
21
  (default: ".github/virtual-teams.yml")
18
22
  -c, --codeOwners [file-name] The location of the CODEOWNERS file
19
23
  (default: ".github/CODEOWNERS")
24
+ -l, --emitLabeler Whether or not to emit a labeler.yml to be
25
+ used with actions/labeler
26
+ (default: false)
27
+ --labelerLocation [file-name] The location of the labeler.yml file
28
+ (default: ".github/labeler.yml")
20
29
  -h, --help display help for command`;
21
30
  export function main(pArguments = process.argv.slice(2), pOutStream = process.stdout, pErrorStream = process.stderr) {
22
31
  try {
23
- let lOptions = getOptions(pArguments);
32
+ const lOptions = getOptions(pArguments);
24
33
  if (lOptions.help) {
25
34
  pOutStream.write(`${HELP_MESSAGE}${EOL}`);
26
35
  return;
@@ -29,17 +38,45 @@ export function main(pArguments = process.argv.slice(2), pOutStream = process.st
29
38
  pOutStream.write(`${VERSION}${EOL}`);
30
39
  return;
31
40
  }
32
- const lCodeOwnersContent = readAndConvert(lOptions.virtualCodeOwners, lOptions.virtualTeams);
41
+ const lTeamMap = readTeamMap(lOptions.virtualTeams);
42
+ const lVirtualCodeOwners = readVirtualCodeOwners(lOptions.virtualCodeOwners, lTeamMap);
43
+ const lAnomalies = getAnomalies(lVirtualCodeOwners);
44
+ if (lAnomalies.length > 0) {
45
+ throw new Error(`${EOL}${reportAnomalies(lOptions.virtualCodeOwners, lAnomalies)}`);
46
+ }
47
+ const lCodeOwnersContent = generateCodeOwners(lVirtualCodeOwners, lTeamMap);
33
48
  writeFileSync(lOptions.codeOwners, lCodeOwnersContent, {
34
49
  encoding: "utf-8",
35
50
  });
36
- pErrorStream.write(`${EOL}Wrote ${lOptions.codeOwners}${EOL}${EOL}`);
51
+ if (lOptions.emitLabeler) {
52
+ const lLabelerContent = generateLabelerYml(lVirtualCodeOwners, lTeamMap);
53
+ writeFileSync(lOptions.labelerLocation, lLabelerContent, {
54
+ encoding: "utf-8",
55
+ });
56
+ pErrorStream.write(`${EOL}Wrote ${lOptions.codeOwners} AND ${lOptions.labelerLocation}${EOL}${EOL}`);
57
+ }
58
+ else {
59
+ pErrorStream.write(`${EOL}Wrote ${lOptions.codeOwners}${EOL}${EOL}`);
60
+ }
37
61
  }
38
62
  catch (pError) {
39
63
  pErrorStream.write(`${EOL}ERROR: ${pError.message}${EOL}${EOL}`);
40
64
  process.exitCode = 1;
41
65
  }
42
66
  }
67
+ function reportAnomalies(pFileName, pAnomalies) {
68
+ return pAnomalies
69
+ .map((pAnomaly) => {
70
+ if (pAnomaly.type === "invalid-line") {
71
+ return `${pFileName}:${pAnomaly.line}:1 invalid line - neither a rule, comment nor empty: '${pAnomaly.raw}'`;
72
+ }
73
+ else {
74
+ return (`${pFileName}:${pAnomaly.line}:1 invalid user or team name '${pAnomaly.raw}' (# ${pAnomaly.userNumberWithinLine} on this line). ` +
75
+ `It should either start with '@' or be an e-mail address.`);
76
+ }
77
+ })
78
+ .join(EOL);
79
+ }
43
80
  function getOptions(pArguments) {
44
81
  return parseArgs({
45
82
  args: pArguments,
@@ -59,6 +96,15 @@ function getOptions(pArguments) {
59
96
  short: "c",
60
97
  default: ".github/CODEOWNERS",
61
98
  },
99
+ emitLabeler: {
100
+ type: "boolean",
101
+ short: "l",
102
+ default: false,
103
+ },
104
+ labelerLocation: {
105
+ type: "string",
106
+ default: ".github/labeler.yml",
107
+ },
62
108
  help: { type: "boolean", short: "h", default: false },
63
109
  version: { type: "boolean", short: "V", default: false },
64
110
  },
package/dist/parse.js ADDED
@@ -0,0 +1,92 @@
1
+ import { EOL } from "node:os";
2
+ import { isEmailIshUsername } from "./utensils.js";
3
+ export function parse(pVirtualCodeOwnersAsString, pTeamMap = {}) {
4
+ return pVirtualCodeOwnersAsString
5
+ .split(EOL)
6
+ .map((pUntreatedLine, pLineNo) => parseLine(pUntreatedLine, pTeamMap, pLineNo + 1));
7
+ }
8
+ export function getAnomalies(pVirtualCodeOwners) {
9
+ const weirdLines = pVirtualCodeOwners
10
+ .filter((pLine) => pLine.type === "unknown")
11
+ .map((pLine) => ({
12
+ ...pLine,
13
+ type: "invalid-line",
14
+ }));
15
+ const weirdUsers = pVirtualCodeOwners.flatMap((pLine) => {
16
+ if (pLine.type === "rule") {
17
+ return pLine.users
18
+ .filter((pUser) => pUser.type === "invalid")
19
+ .map((pUser) => ({
20
+ ...pUser,
21
+ line: pLine.line,
22
+ type: "invalid-user",
23
+ }));
24
+ }
25
+ return [];
26
+ });
27
+ return weirdLines.concat(weirdUsers).sort(orderAnomaly);
28
+ }
29
+ function orderAnomaly(pLeft, pRight) {
30
+ if (pLeft.line === pRight.line &&
31
+ pLeft.type === "invalid-user" &&
32
+ pRight.type === "invalid-user") {
33
+ return pLeft.userNumberWithinLine > pRight.userNumberWithinLine ? 1 : -1;
34
+ }
35
+ else {
36
+ return pLeft.line > pRight.line ? 1 : -1;
37
+ }
38
+ }
39
+ function parseLine(pUntreatedLine, pTeamMap, pLineNo) {
40
+ const lTrimmedLine = pUntreatedLine.trim();
41
+ const lSplitLine = lTrimmedLine.match(/^(?<filesPattern>[^\s]+)(?<spaces>\s+)(?<userNames>.*)$/);
42
+ if (lTrimmedLine.startsWith("#!")) {
43
+ return { type: "ignorable-comment", line: pLineNo, raw: pUntreatedLine };
44
+ }
45
+ if (lTrimmedLine.startsWith("#")) {
46
+ return { type: "comment", line: pLineNo, raw: pUntreatedLine };
47
+ }
48
+ if (!lSplitLine?.groups) {
49
+ if (lTrimmedLine === "") {
50
+ return { type: "empty", line: pLineNo, raw: pUntreatedLine };
51
+ }
52
+ return { type: "unknown", line: pLineNo, raw: pUntreatedLine };
53
+ }
54
+ return {
55
+ type: "rule",
56
+ line: pLineNo,
57
+ filesPattern: lSplitLine.groups.filesPattern,
58
+ spaces: lSplitLine.groups.spaces,
59
+ users: parseUsers(lSplitLine.groups.userNames, pTeamMap),
60
+ raw: pUntreatedLine,
61
+ };
62
+ }
63
+ function parseUsers(pUserNamesString, pTeamMap) {
64
+ const lUserNames = pUserNamesString.split(/\s+/);
65
+ return lUserNames.map((pUserName, pIndex) => {
66
+ const lBareName = getBareUserName(pUserName);
67
+ return {
68
+ type: getUserNameType(pUserName, lBareName, pTeamMap),
69
+ userNumberWithinLine: pIndex + 1,
70
+ bareName: lBareName,
71
+ raw: pUserName,
72
+ };
73
+ });
74
+ }
75
+ function getUserNameType(pUserName, pBareName, pTeamMap) {
76
+ if (isEmailIshUsername(pUserName)) {
77
+ return "e-mail-address";
78
+ }
79
+ if (pUserName.startsWith("@")) {
80
+ if (pTeamMap.hasOwnProperty(pBareName)) {
81
+ return "virtual-team-name";
82
+ }
83
+ return "other-user-or-team";
84
+ }
85
+ return "invalid";
86
+ }
87
+ function getBareUserName(pUserName) {
88
+ if (pUserName.startsWith("@")) {
89
+ return pUserName.slice(1);
90
+ }
91
+ return pUserName;
92
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parse as parseYaml } from "yaml";
3
+ export default function readTeamMap(pVirtualTeamsFileName) {
4
+ const lVirtualTeamsAsAString = readFileSync(pVirtualTeamsFileName, {
5
+ encoding: "utf-8",
6
+ });
7
+ return parseYaml(lVirtualTeamsAsAString);
8
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parse as parseVirtualCodeOwners } from "./parse.js";
3
+ export default function readVirtualCodeOwners(pVirtualCodeOwnersFileName, pTeamMap) {
4
+ const lVirtualCodeOwnersAsAString = readFileSync(pVirtualCodeOwnersFileName, {
5
+ encoding: "utf-8",
6
+ });
7
+ return parseVirtualCodeOwners(lVirtualCodeOwnersAsAString, pTeamMap);
8
+ }
@@ -0,0 +1,4 @@
1
+ export function isEmailIshUsername(pUsername) {
2
+ const lEmailIshUsernameRE = /^.+@.+$/;
3
+ return Boolean(pUsername.match(lEmailIshUsernameRE));
4
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "4.1.1";
1
+ export const VERSION = "5.1.0";
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "virtual-code-owners",
3
- "version": "4.1.1",
3
+ "version": "5.1.0",
4
4
  "description": "Merges a VIRTUAL-CODEOWNERS.txt and a virtual-teams.yml into CODEOWNERS",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": [
8
8
  {
9
- "import": "./dist/read-and-convert.js"
9
+ "import": "./dist/parse-and-generate.js",
10
+ "types": "./types/parse-and-generate.d.ts"
10
11
  },
11
- "./dist/read-and-convert.js"
12
+ "./dist/parse-and-generate.js"
12
13
  ]
13
14
  },
14
- "main": "dist/read-and-convert.js",
15
+ "main": "dist/parse-and-generate.js",
15
16
  "bin": "dist/cli.js",
16
17
  "files": [
17
18
  "dist",
@@ -53,7 +54,7 @@
53
54
  },
54
55
  "devDependencies": {
55
56
  "@types/mocha": "10.0.1",
56
- "@types/node": "20.2.5",
57
+ "@types/node": "20.3.0",
57
58
  "c8": "7.14.0",
58
59
  "dependency-cruiser": "13.0.3",
59
60
  "husky": "8.0.3",
@@ -62,7 +63,7 @@
62
63
  "prettier": "2.8.8",
63
64
  "ts-node": "10.9.1",
64
65
  "typescript": "5.1.3",
65
- "upem": "7.3.2",
66
+ "upem": "8.0.0",
66
67
  "watskeburt": "0.11.3"
67
68
  },
68
69
  "dependencies": {
@@ -1,56 +0,0 @@
1
- import { EOL } from "node:os";
2
- const DEFAULT_WARNING = `#${EOL}` +
3
- `# DO NOT EDIT - this file is generated and your edits will be overwritten${EOL}` +
4
- `#${EOL}` +
5
- `# To make changes:${EOL}` +
6
- `#${EOL}` +
7
- `# - edit .github/VIRTUAL-CODEOWNERS.txt${EOL}` +
8
- `# - and/ or add team members to .github/virtual-teams.yml${EOL}` +
9
- `# - run 'npx virtual-code-owners'${EOL}` +
10
- `#${EOL}${EOL}`;
11
- export function convert(pCodeOwnersFileAsString, pTeamMap, pGeneratedWarning = DEFAULT_WARNING) {
12
- return (pGeneratedWarning +
13
- pCodeOwnersFileAsString
14
- .split(EOL)
15
- .filter(shouldAppearInResult)
16
- .map(convertLine(pTeamMap))
17
- .join(EOL));
18
- }
19
- function shouldAppearInResult(pLine) {
20
- return !pLine.trimStart().startsWith("#!");
21
- }
22
- function convertLine(pTeamMap) {
23
- return (pUntreatedLine) => {
24
- const lTrimmedLine = pUntreatedLine.trim();
25
- const lSplitLine = lTrimmedLine.match(/^(?<filesPattern>[^\s]+)(?<spaces>\s+)(?<userNames>.*)$/);
26
- if (lTrimmedLine.startsWith("#") || !lSplitLine?.groups) {
27
- return pUntreatedLine;
28
- }
29
- const lUserNames = replaceTeamNames(lSplitLine.groups.userNames, pTeamMap);
30
- return (lSplitLine.groups.filesPattern +
31
- lSplitLine.groups.spaces +
32
- uniqAndSortUserNames(lUserNames));
33
- };
34
- }
35
- function replaceTeamNames(pUserNames, pTeamMap) {
36
- let lReturnValue = pUserNames;
37
- for (let lTeamName of Object.keys(pTeamMap)) {
38
- lReturnValue = lReturnValue.replace(new RegExp(`(\\s|^)@${lTeamName}(\\s|$)`, "g"), `$1${stringifyTeamMembers(pTeamMap, lTeamName)}$2`);
39
- }
40
- return lReturnValue;
41
- }
42
- function stringifyTeamMembers(pTeamMap, pTeamName) {
43
- return pTeamMap[pTeamName].map(userNameToCodeOwner).join(" ");
44
- }
45
- function userNameToCodeOwner(pUserName) {
46
- const lEmailIshUsernameRE = /^.+@.+$/;
47
- if (pUserName.match(lEmailIshUsernameRE)) {
48
- return pUserName;
49
- }
50
- return `@${pUserName}`;
51
- }
52
- function uniqAndSortUserNames(pUserNames) {
53
- return Array.from(new Set(pUserNames.split(/\s+/)))
54
- .sort()
55
- .join(" ");
56
- }
@@ -1,13 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { parse } from "yaml";
3
- import { convert } from "./convert-to-codeowners.js";
4
- export function readAndConvert(pVirtualCodeOwnersFileName, pVirtualTeamsFileName) {
5
- const lVirtualCodeOwnersAsAString = readFileSync(pVirtualCodeOwnersFileName, {
6
- encoding: "utf-8",
7
- });
8
- const lVirtualTeamsAsAString = readFileSync(pVirtualTeamsFileName, {
9
- encoding: "utf-8",
10
- });
11
- const lTeamMap = parse(lVirtualTeamsAsAString);
12
- return convert(lVirtualCodeOwnersAsAString, lTeamMap);
13
- }