Skip to content

Commit eb267be

Browse files
committed
use new codeowners logic
1 parent 20606a6 commit eb267be

File tree

3 files changed

+140
-44
lines changed

3 files changed

+140
-44
lines changed

action.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ author: joelharkes
55

66
# Add your action's branding here. This will appear on the GitHub Marketplace.
77
branding:
8-
icon: heart
9-
color: red
8+
icon: align-left
9+
color: white
1010

1111
# Define your inputs here.
1212
inputs:
@@ -21,6 +21,10 @@ inputs:
2121
files:
2222
description: Files to check, can be used to only check git modified files.
2323
required: false
24+
allRulesMustHit:
25+
description: Wether all rules must hit for the action to pass. Do not use when using files input to check only modified files.
26+
required: false
27+
default: 'false'
2428

2529
# Define your outputs here.
2630
outputs:

src/codeowner.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
export interface CodeOwnerRule {
2+
pattern: string;
3+
owners: string[];
4+
isMatch(file: string): boolean;
5+
lineNumber: number;
6+
}
7+
8+
export function parseCodeowners(content: string): CodeOwnerRule[] {
9+
const lines = content.split('\n');
10+
const rules: CodeOwnerRule[] = [];
11+
12+
lines.forEach((line, index) => {
13+
const trimmedLine = line.trim();
14+
if (!trimmedLine) {
15+
return;
16+
}
17+
if (trimmedLine.startsWith('#')) {
18+
return;
19+
}
20+
21+
const [pattern, ...owners] = trimmedLine.split(/\s+/);
22+
23+
rules.push({
24+
pattern,
25+
owners,
26+
isMatch: makeMatcher(pattern),
27+
lineNumber: index + 1,
28+
});
29+
});
30+
31+
return rules;
32+
}
33+
34+
export function makeMatcher(pattern: string): (file: string) => boolean {
35+
if (pattern === '*') {
36+
return () => true;
37+
}
38+
if (!pattern.includes('*')) {
39+
if (pattern.startsWith('/')) {
40+
return (file: string) => file.startsWith(pattern);
41+
}
42+
return (file: string) => file.includes(pattern);
43+
}
44+
if (!pattern.includes('/') && pattern.startsWith('*')) {
45+
return (file: string) => file.endsWith(pattern.slice(1));
46+
}
47+
48+
if (!pattern.startsWith('/') && !pattern.startsWith('*')) {
49+
pattern = `**/` + pattern; // we match preceding directory
50+
}
51+
if (!pattern.endsWith('*')) {
52+
pattern = pattern + '**'; // we match all subdirectories (we are never sure if we match specific file or directory)
53+
}
54+
55+
// Escape special regex characters
56+
let regexPattern = pattern.replace(/[-/\\^$+?.()|[\]{}*]/g, '\\$&');
57+
58+
// Replace ** with a pattern that matches any character, including slashes
59+
regexPattern = regexPattern.replace(/\\\*\\\*/g, '.*');
60+
61+
// Replace * with a pattern that matches any character except slashes
62+
regexPattern = regexPattern.replace(/\\\*/g, '[^/]*');
63+
64+
// Ensure the pattern matches from the start to the end of the string
65+
regexPattern = `^${regexPattern}$`;
66+
67+
const regex = new RegExp(regexPattern);
68+
69+
return (file: string) => regex.test(file);
70+
}

src/main.ts

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import * as core from '@actions/core';
22
import * as glob from '@actions/glob';
33
import { readFileSync, existsSync } from 'fs';
4+
import { parseCodeowners } from './codeowner.js';
45

56
interface Input {
67
'include-gitignore': boolean;
78
'ignore-default': boolean;
89
files: string;
10+
allRulesMustHit: boolean;
911
}
1012

1113
function getInputs(): Input {
1214
const result = {} as Input;
1315
result['include-gitignore'] = getBoolInput('include-gitignore');
1416
result['ignore-default'] = getBoolInput('ignore-default');
17+
result.allRulesMustHit = getBoolInput('allRulesMustHit');
1518
result.files = core.getInput('files');
1619
return result;
1720
}
@@ -25,61 +28,67 @@ export const runAction = async (input: Input): Promise<void> => {
2528
core.startGroup(`Loading files to check.`);
2629
if (input.files) {
2730
filesToCheck = input.files.split(' ');
28-
filesToCheck = await (await glob.create(filesToCheck.join('\n'))).glob();
2931
} else {
3032
filesToCheck = await (await glob.create('*')).glob();
33+
if (input['include-gitignore'] === true) {
34+
core.info('Ignoring .gitignored files');
35+
let gitIgnoreFiles: string[] = [];
36+
if (!existsSync('.gitignore')) {
37+
core.warning('No .gitignore file found, skipping check.');
38+
} else {
39+
const gitIgnoreBuffer = readFileSync('.gitignore', 'utf8');
40+
const gitIgnoreGlob = await glob.create(gitIgnoreBuffer);
41+
gitIgnoreFiles = await gitIgnoreGlob.glob();
42+
core.info(`.gitignore Files: ${gitIgnoreFiles.length}`);
43+
const lengthBefore = filesToCheck.length;
44+
filesToCheck = filesToCheck.filter(
45+
(file) => !gitIgnoreFiles.includes(file),
46+
);
47+
const filesIgnored = lengthBefore - filesToCheck.length;
48+
core.info(`Files Ignored: ${filesIgnored}`);
49+
}
50+
}
51+
}
52+
core.info(`Found ${filesToCheck.length} files to check.`);
53+
if (core.isDebug()) {
54+
core.debug(filesToCheck.join('\n'));
3155
}
32-
// core.info(JSON.stringify(filesToCheck));
3356
core.endGroup();
3457

35-
core.startGroup('Reading CODEOWNERS File');
58+
core.startGroup('Parsing CODEOWNERS File');
3659
const codeownerContent = getCodeownerContent();
37-
let codeownerFileGlobs = codeownerContent
38-
.split('\n')
39-
.map((line) => line.split(' ')[0])
40-
.filter((file) => !file.startsWith('#'))
41-
.map((file) => file.replace(/^\//, ''));
60+
let parsedCodeowners = parseCodeowners(codeownerContent);
4261
if (input['ignore-default'] === true) {
43-
codeownerFileGlobs = codeownerFileGlobs.filter((file) => file !== '*');
62+
parsedCodeowners = parsedCodeowners.filter((rule) => rule.pattern !== '*');
4463
}
45-
const codeownersGlob = await glob.create(codeownerFileGlobs.join('\n'));
46-
let codeownersFiles = await codeownersGlob.glob();
47-
// core.info(JSON.stringify(codeownersFiles));
64+
core.info(`CODEOWNERS Rules: ${parsedCodeowners.length}`);
4865
core.endGroup();
4966

5067
core.startGroup('Matching CODEOWNER Files with found files');
51-
codeownersFiles = codeownersFiles.filter((file) =>
52-
filesToCheck.includes(file),
53-
);
54-
core.info(`CODEOWNER Files in All Files: ${codeownersFiles.length}`);
55-
core.info(JSON.stringify(codeownersFiles));
56-
core.endGroup();
57-
58-
if (input['include-gitignore'] === true) {
59-
core.startGroup('Ignoring .gitignored files');
60-
let gitIgnoreFiles: string[] = [];
61-
if (!existsSync('.gitignore')) {
62-
core.warning('No .gitignore file found');
68+
const rulesResult = parsedCodeowners.map((rule) => ({
69+
rule,
70+
filtes: [] as string[],
71+
}));
72+
rulesResult.reverse(); // last rule takes precedence.
73+
const missedFiles: string[] = [];
74+
filesToCheck.forEach((file) => {
75+
const matchedRule = rulesResult.find(({ rule }) => rule.isMatch(file));
76+
if (matchedRule) {
77+
matchedRule.filtes.push(file);
6378
} else {
64-
const gitIgnoreBuffer = readFileSync('.gitignore', 'utf8');
65-
const gitIgnoreGlob = await glob.create(gitIgnoreBuffer);
66-
gitIgnoreFiles = await gitIgnoreGlob.glob();
67-
core.info(`.gitignore Files: ${gitIgnoreFiles.length}`);
68-
const lengthBefore = filesToCheck.length;
69-
filesToCheck = filesToCheck.filter(
70-
(file) => !gitIgnoreFiles.includes(file),
71-
);
72-
const filesIgnored = lengthBefore - filesToCheck.length;
73-
core.info(`Files Ignored: ${filesIgnored}`);
79+
missedFiles.push(file);
7480
}
75-
core.endGroup();
81+
});
82+
83+
core.info(`${missedFiles.length} files missing codeowners`);
84+
if (core.isDebug()) {
85+
core.debug(JSON.stringify(missedFiles));
7686
}
87+
core.endGroup();
7788

7889
core.startGroup('Checking CODEOWNERS Coverage');
79-
const filesNotCovered = filesToCheck.filter(
80-
(file) => !codeownersFiles.includes(file),
81-
);
82-
const amountCovered = filesToCheck.length - filesNotCovered.length;
90+
91+
const amountCovered = filesToCheck.length - missedFiles.length;
8392

8493
const coveragePercent =
8594
filesToCheck.length === 0
@@ -92,18 +101,31 @@ export const runAction = async (input: Input): Promise<void> => {
92101
});
93102
core.endGroup();
94103
core.startGroup('Annotating files');
95-
filesNotCovered.forEach((file) =>
104+
missedFiles.forEach((file) =>
96105
core.error(`File not covered by CODEOWNERS: ${file}`, {
97106
title: 'File mssing in CODEOWNERS',
98107
file: file,
99108
}),
100109
);
101110
core.endGroup();
102-
if (filesNotCovered.length > 0) {
111+
if (missedFiles.length > 0) {
103112
core.setFailed(
104-
`${filesNotCovered.length}/${filesToCheck.length} files not covered in CODEOWNERS`,
113+
`${missedFiles.length}/${filesToCheck.length} files not covered in CODEOWNERS`,
105114
);
106115
}
116+
if (input.allRulesMustHit) {
117+
const unusedRules = rulesResult.filter(({ filtes }) => filtes.length === 0);
118+
if (unusedRules.length > 0) {
119+
core.setFailed(`${unusedRules.length} rules not used`);
120+
}
121+
unusedRules.forEach(({ rule }) => {
122+
core.error(`Rule not used: ${rule.pattern}`, {
123+
title: 'Rule not used',
124+
file: 'CODEOWNERS',
125+
startLine: rule.lineNumber,
126+
});
127+
});
128+
}
107129
};
108130

109131
export async function run(): Promise<void> {

0 commit comments

Comments
 (0)