mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-12 06:56:24 +00:00
feat(CODEOWNERS): Improve pr files owner detection (#21955)
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com> Co-authored-by: Michael Kriese <michael.kriese@visualon.de> Co-authored-by: Rhys Arkins <rhys@arkins.net>
This commit is contained in:
parent
d8c7a14ef9
commit
5c517f4ada
2 changed files with 317 additions and 28 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
import { codeBlock } from 'common-tags';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { fs, git } from '../../../../../test/util';
|
import { fs, git } from '../../../../../test/util';
|
||||||
import type { Pr } from '../../../../modules/platform';
|
import type { Pr } from '../../../../modules/platform';
|
||||||
|
@ -22,13 +23,195 @@ describe('workers/repository/update/pr/code-owners', () => {
|
||||||
expect(codeOwners).toEqual(['@jimmy']);
|
expect(codeOwners).toEqual(['@jimmy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('respects orphan files', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(
|
||||||
|
codeBlock`
|
||||||
|
* @jimmy
|
||||||
|
yarn.lock
|
||||||
|
`
|
||||||
|
);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce(['yarn.lock']);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return any owners if PR has no changes', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce('* @jimmy');
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns more specific code owners', async () => {
|
it('returns more specific code owners', async () => {
|
||||||
fs.readLocalFile.mockResolvedValueOnce(
|
fs.readLocalFile.mockResolvedValueOnce(
|
||||||
['* @jimmy', 'package.json @john @maria'].join('\n')
|
['* @jimmy', 'package.json @john @maria'].join('\n')
|
||||||
);
|
);
|
||||||
git.getBranchFiles.mockResolvedValueOnce(['package.json']);
|
git.getBranchFiles.mockResolvedValueOnce(['package.json']);
|
||||||
const codeOwners = await codeOwnersForPr(pr);
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
expect(codeOwners).toEqual(['@john', '@maria']);
|
expect(codeOwners).toEqual(['@john', '@maria', '@jimmy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('returns more specific code owners in monorepos', () => {
|
||||||
|
const mockCodeOwners = codeBlock`
|
||||||
|
# By default, assign to @john
|
||||||
|
#
|
||||||
|
* @john
|
||||||
|
|
||||||
|
# Lockfiles are not owned by anyone, any package dependency update may modify them.
|
||||||
|
# Assigning lockfiles an owner will cause issues as merge requests to be assigned to incorrect users
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Assign each package to its respective user
|
||||||
|
#
|
||||||
|
packages/a/ @maria
|
||||||
|
packages/b/ @jimmy
|
||||||
|
packages/c/ @dan
|
||||||
|
packages/d/ @maria @jimmy
|
||||||
|
packages/e/ @jimmy
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('does not assign changes for yarn.lock', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce(['yarn.lock']);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns root changes to @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce(['package.json', 'yarn.lock']);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package A to @maria (a), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/a/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@maria', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package B to @jimmy (b), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/b/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@jimmy', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package C to @dan (c), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/c/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@dan', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package D to @maria (d), @jimmy (d), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/d/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@maria', '@jimmy', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package A and B to @maria (a), @jimmy (b), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/a/package.json',
|
||||||
|
'packages/b/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@maria', '@jimmy', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package A, B and C to @john, @maria (a), @jimmy (b), @dan (c), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/a/package.json',
|
||||||
|
'packages/b/package.json',
|
||||||
|
'packages/c/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@maria', '@jimmy', '@dan', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package C and D to @dan (c), @maria (d), @jimmy (e), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/c/package.json',
|
||||||
|
'packages/d/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@dan', '@maria', '@jimmy', '@john']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns changes in package D and E to @jimmy (d, e), @maria (d), @john (*)', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(mockCodeOwners);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'packages/d/package.json',
|
||||||
|
'packages/e/package.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual(['@jimmy', '@maria', '@john']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require all files to match a single rule, regression test for #12611', async () => {
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(
|
||||||
|
codeBlock`
|
||||||
|
* @reviewer-1 @reviewer-2 @reviewer-3 @reviewer-4 @reviewer-5
|
||||||
|
|
||||||
|
server/pom.xml @reviewer-1
|
||||||
|
client/package.json @reviewer-1
|
||||||
|
client/package-lock.json @reviewer-1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce(['server/pom.xml']);
|
||||||
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners).toEqual([
|
||||||
|
'@reviewer-1', // matched by file
|
||||||
|
'@reviewer-2', // matched by wildcard
|
||||||
|
'@reviewer-3',
|
||||||
|
'@reviewer-4',
|
||||||
|
'@reviewer-5',
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.readLocalFile.mockResolvedValueOnce(
|
||||||
|
codeBlock`
|
||||||
|
* @reviewer-1 @reviewer-2 @reviewer-3 @reviewer-4 @reviewer-5
|
||||||
|
|
||||||
|
server/pom.xml @reviewer-1
|
||||||
|
client/package.json @reviewer-1
|
||||||
|
client/package-lock.json @reviewer-1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
git.getBranchFiles.mockResolvedValueOnce([
|
||||||
|
'client/package.json',
|
||||||
|
'client/package-lock.json',
|
||||||
|
]);
|
||||||
|
const codeOwners2 = await codeOwnersForPr(pr);
|
||||||
|
expect(codeOwners2).toEqual([
|
||||||
|
'@reviewer-1', // matched by file
|
||||||
|
'@reviewer-2', // matched by wildcard
|
||||||
|
'@reviewer-3',
|
||||||
|
'@reviewer-4',
|
||||||
|
'@reviewer-5',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores comments and leading/trailing whitespace', async () => {
|
it('ignores comments and leading/trailing whitespace', async () => {
|
||||||
|
@ -43,7 +226,7 @@ describe('workers/repository/update/pr/code-owners', () => {
|
||||||
);
|
);
|
||||||
git.getBranchFiles.mockResolvedValueOnce(['package.json']);
|
git.getBranchFiles.mockResolvedValueOnce(['package.json']);
|
||||||
const codeOwners = await codeOwnersForPr(pr);
|
const codeOwners = await codeOwnersForPr(pr);
|
||||||
expect(codeOwners).toEqual(['@john', '@maria']);
|
expect(codeOwners).toEqual(['@john', '@maria', '@jimmy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array when no code owners set', async () => {
|
it('returns empty array when no code owners set', async () => {
|
||||||
|
|
|
@ -5,9 +5,79 @@ import { readLocalFile } from '../../../../util/fs';
|
||||||
import { getBranchFiles } from '../../../../util/git';
|
import { getBranchFiles } from '../../../../util/git';
|
||||||
import { newlineRegex, regEx } from '../../../../util/regex';
|
import { newlineRegex, regEx } from '../../../../util/regex';
|
||||||
|
|
||||||
|
interface FileOwnerRule {
|
||||||
|
usernames: string[];
|
||||||
|
pattern: string;
|
||||||
|
score: number;
|
||||||
|
match: (path: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOwnersFromLine(line: string): FileOwnerRule {
|
||||||
|
const [pattern, ...usernames] = line.split(regEx(/\s+/));
|
||||||
|
const matchPattern = ignore().add(pattern);
|
||||||
|
return {
|
||||||
|
usernames,
|
||||||
|
pattern,
|
||||||
|
score: pattern.length,
|
||||||
|
match: (path: string) => matchPattern.ignores(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileOwnersScore {
|
||||||
|
file: string;
|
||||||
|
userScoreMap: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFileToOwners(
|
||||||
|
file: string,
|
||||||
|
rules: FileOwnerRule[]
|
||||||
|
): FileOwnersScore {
|
||||||
|
const usernames = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.match(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of rule.usernames) {
|
||||||
|
usernames.set(user, rule.score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file, userScoreMap: usernames };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnerFileScore {
|
||||||
|
username: string;
|
||||||
|
fileScoreMap: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerList(filesWithOwners: FileOwnersScore[]): OwnerFileScore[] {
|
||||||
|
const userFileMap = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
for (const fileMatch of filesWithOwners) {
|
||||||
|
for (const [username, score] of fileMatch.userScoreMap.entries()) {
|
||||||
|
// Get / create user file score
|
||||||
|
const fileMap = userFileMap.get(username) ?? new Map<string, number>();
|
||||||
|
if (!userFileMap.has(username)) {
|
||||||
|
userFileMap.set(username, fileMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file to user
|
||||||
|
fileMap.set(fileMatch.file, (fileMap.get(fileMatch.file) ?? 0) + score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(userFileMap.entries()).map(([key, value]) => ({
|
||||||
|
username: key,
|
||||||
|
fileScoreMap: value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function codeOwnersForPr(pr: Pr): Promise<string[]> {
|
export async function codeOwnersForPr(pr: Pr): Promise<string[]> {
|
||||||
logger.debug('Searching for CODEOWNERS file');
|
logger.debug('Searching for CODEOWNERS file');
|
||||||
try {
|
try {
|
||||||
|
// Find CODEOWNERS file
|
||||||
const codeOwnersFile =
|
const codeOwnersFile =
|
||||||
(await readLocalFile('CODEOWNERS', 'utf8')) ??
|
(await readLocalFile('CODEOWNERS', 'utf8')) ??
|
||||||
(await readLocalFile('.github/CODEOWNERS', 'utf8')) ??
|
(await readLocalFile('.github/CODEOWNERS', 'utf8')) ??
|
||||||
|
@ -21,37 +91,73 @@ export async function codeOwnersForPr(pr: Pr): Promise<string[]> {
|
||||||
|
|
||||||
logger.debug(`Found CODEOWNERS file: ${codeOwnersFile}`);
|
logger.debug(`Found CODEOWNERS file: ${codeOwnersFile}`);
|
||||||
|
|
||||||
|
// Get list of modified files in PR
|
||||||
const prFiles = await getBranchFiles(pr.sourceBranch);
|
const prFiles = await getBranchFiles(pr.sourceBranch);
|
||||||
const rules = codeOwnersFile
|
|
||||||
.split(newlineRegex)
|
if (!prFiles?.length) {
|
||||||
.map((line) => line.trim())
|
logger.debug('PR includes no files');
|
||||||
.filter((line) => line && !line.startsWith('#'))
|
|
||||||
.map((line) => {
|
|
||||||
const [pattern, ...usernames] = line.split(regEx(/\s+/));
|
|
||||||
return {
|
|
||||||
usernames,
|
|
||||||
match: (path: string) => {
|
|
||||||
const matcher = ignore().add(pattern);
|
|
||||||
return matcher.ignores(path);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.reverse();
|
|
||||||
logger.debug(
|
|
||||||
{ prFiles, rules },
|
|
||||||
'PR files and rules to match for CODEOWNERS'
|
|
||||||
);
|
|
||||||
const matchingRule = rules.find((rule) => prFiles?.every(rule.match));
|
|
||||||
if (!matchingRule) {
|
|
||||||
logger.debug('No matching CODEOWNERS rule found');
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert CODEOWNERS file into list of matching rules
|
||||||
|
const fileOwnerRules = codeOwnersFile
|
||||||
|
.split(newlineRegex)
|
||||||
|
// Remove empty and commented lines
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith('#'))
|
||||||
|
// Extract pattern & usernames
|
||||||
|
.map(extractOwnersFromLine);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`CODEOWNERS matched the following usernames: ${JSON.stringify(
|
{ prFiles, fileOwnerRules },
|
||||||
matchingRule.usernames
|
'PR files and rules to match for CODEOWNERS'
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
return matchingRule.usernames;
|
|
||||||
|
// Apply rules & get list of owners for each prFile
|
||||||
|
const emptyRules = fileOwnerRules.filter(
|
||||||
|
(rule) => rule.usernames.length === 0
|
||||||
|
);
|
||||||
|
const fileOwners =
|
||||||
|
// Map through all prFiles and match said file(s) with all the rules
|
||||||
|
prFiles
|
||||||
|
.map((file) => matchFileToOwners(file, fileOwnerRules))
|
||||||
|
|
||||||
|
// Match file again but this time only with emptyRules, to ensure that files which have no owner set remain owner-less
|
||||||
|
.map((fileMatch) => {
|
||||||
|
const matchEmpty = emptyRules.find((rule) =>
|
||||||
|
rule.match(fileMatch.file)
|
||||||
|
);
|
||||||
|
if (matchEmpty) {
|
||||||
|
return { ...fileMatch, userScoreMap: new Map<string, number>() };
|
||||||
|
}
|
||||||
|
return fileMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`CODEOWNERS matched the following files: ${fileOwners
|
||||||
|
.map((f) => f.file)
|
||||||
|
.join(', ')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get list of all matched users and the files they own (reverse keys of fileOwners)
|
||||||
|
const usersWithOwnedFiles = getOwnerList(fileOwners);
|
||||||
|
|
||||||
|
// Calculate a match score for each user. This allows sorting of the final user array in a way that guarantees that users matched with more precise patterns are first and users matched with less precise patterns are last (wildcards)
|
||||||
|
const userScore = usersWithOwnedFiles
|
||||||
|
.map((userMatch) => ({
|
||||||
|
user: userMatch.username,
|
||||||
|
score: Array.from(userMatch.fileScoreMap.values()).reduce(
|
||||||
|
(acc, score) => acc + score,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`CODEOWNERS matched the following users: ${JSON.stringify(userScore)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return userScore.map((u) => u.user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, pr }, 'Failed to determine CODEOWNERS for PR.');
|
logger.warn({ err, pr }, 'Failed to determine CODEOWNERS for PR.');
|
||||||
return [];
|
return [];
|
||||||
|
|
Loading…
Reference in a new issue