import { codeBlock } from 'common-tags'; import { mock } from 'vitest-mock-extended'; import { fakeSha, fs, git, platform } from '~test/util.ts'; import * as bitbucketserver from '../../../../modules/platform/bitbucket-server/index.ts'; import * as gitlab from '../../../../modules/platform/gitlab/index.ts'; import type { Pr } from '../../../../modules/platform/index.ts'; import { codeOwnersForPr } from './code-owners.ts'; vi.mock('../../../../util/fs/index.ts'); describe('workers/repository/update/pr/code-owners', () => { beforeAll(() => { Object.defineProperty(platform, 'extractRulesFromCodeOwnersLines', { value: undefined, writable: true, }); }); describe('codeOwnersForPr', () => { let pr: Pr; beforeEach(() => { pr = mock(); pr.sha = undefined; }); it('returns global code owner', async () => { fs.readLocalFile.mockResolvedValueOnce(['* @jimmy'].join('\n')); git.getBranchFiles.mockResolvedValueOnce(['README.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@jimmy']); }); it('returns global code owner for commit with sha set', async () => { pr.sha = fakeSha('f7374c2de8a4c95a7fd7182ab24044e3896aac02'); fs.readLocalFile.mockResolvedValueOnce('* @jimmy'); git.getBranchFilesFromCommit.mockResolvedValueOnce(['README.md']); const codeOwners = await codeOwnersForPr(pr); 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 () => { fs.readLocalFile.mockResolvedValueOnce( ['* @jimmy', 'package.json @john @maria'].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['package.json']); const codeOwners = await codeOwnersForPr(pr); 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']); }); }); describe('supports Gitlab sections', () => { beforeAll(() => { Object.defineProperty(platform, 'extractRulesFromCodeOwnersLines', { value: gitlab.extractRulesFromCodeOwnersLines, writable: true, }); }); it('returns section code owner', async () => { fs.readLocalFile.mockResolvedValueOnce( ['[team] @jimmy', '*'].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['README.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@jimmy']); }); const codeOwnerFileWithDefaultApproval = codeBlock` # Required for all files * @general-approvers [Documentation] @docs-team docs/ README.md *.txt # Invalid section Something before [Tests] @tests-team tests/ # Optional section ^[Optional] @optional-team optional/ [Database] @database-team model/db/ config/db/database-setup.md @docs-team `; it('returns code owners of multiple sections', async () => { fs.readLocalFile.mockResolvedValueOnce( codeOwnerFileWithDefaultApproval, ); git.getBranchFiles.mockResolvedValueOnce([ 'config/db/database-setup.md', ]); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@docs-team', '@general-approvers']); }); it('returns default owners when none is explicitly set', async () => { fs.readLocalFile.mockResolvedValueOnce( codeOwnerFileWithDefaultApproval, ); git.getBranchFiles.mockResolvedValueOnce(['model/db/CHANGELOG.txt']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual([ '@database-team', '@docs-team', '@general-approvers', ]); }); it('parses only sections that start at the beginning of a line', async () => { fs.readLocalFile.mockResolvedValueOnce( codeOwnerFileWithDefaultApproval, ); git.getBranchFiles.mockResolvedValueOnce(['tests/setup.ts']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).not.toInclude('@tests-team'); }); it('returns code owners for optional sections', async () => { fs.readLocalFile.mockResolvedValueOnce( codeOwnerFileWithDefaultApproval, ); git.getBranchFiles.mockResolvedValueOnce([ 'optional/optional-file.txt', ]); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual([ '@optional-team', '@docs-team', '@general-approvers', ]); }); }); describe('Bitbucket Server CODEOWNERS integration', () => { beforeAll(() => { Object.defineProperty(platform, 'extractRulesFromCodeOwnersLines', { value: bitbucketserver.extractRulesFromCodeOwnersLines, writable: true, }); }); it('returns code owners for matching file using escaped spaces', async () => { fs.readLocalFile.mockResolvedValueOnce( String.raw`src/** @Jane\\ Doe @john@example.com`, ); git.getBranchFiles.mockResolvedValueOnce(['src/index.ts']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@Jane Doe', '@john@example.com']); }); it('returns code owners from reviewer group with random selection', async () => { fs.readLocalFile.mockResolvedValueOnce( 'docs/** @reviewer-group/content-designers:random', ); git.getBranchFiles.mockResolvedValueOnce(['docs/readme.md']); const codeOwners = await codeOwnersForPr(pr); // Since we don't simulate the actual group resolution, this will just include the literal string expect(codeOwners).toEqual([ '@reviewer-group/content-designers:random', ]); }); it('does not return owners when an empty rule overrides a broader rule', async () => { fs.readLocalFile.mockResolvedValueOnce( [ 'docs/images/**', // empty rule 'docs/** @content-designer', ].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['docs/images/logo.png']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual([]); }); it('matches the most specific rule (bottom takes precedence)', async () => { fs.readLocalFile.mockResolvedValueOnce( [ 'docs/** @team1', 'docs/backend/** @team2', // higher precedence ].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['docs/backend/service.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@team2', '@team1']); }); it('handles multiple owners with mix of usernames and groups', async () => { fs.readLocalFile.mockResolvedValueOnce( 'docs/** @Alice @reviewer-group/devs:random(2)', ); git.getBranchFiles.mockResolvedValueOnce(['docs/manual.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual([ '@Alice', '@reviewer-group/devs:random(2)', ]); }); }); it.fails('does not parse Gitea regex as Gitlab sections', async () => { Object.defineProperty(platform, 'extractRulesFromCodeOwnersLines', { value: undefined, writable: true, }); fs.readLocalFile.mockResolvedValueOnce( codeBlock` # This is a regex, not a Gitlab section, so 002-file.md should be assigned to @reviewer-03 [0-3].*/*.md$ @reviewer-03 002-file.md [4-9].*/*.md$ @reviewer-49 `, ); git.getBranchFiles.mockResolvedValueOnce(['001-file.md', '002-file.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@reviewer-03']); }); 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 () => { fs.readLocalFile.mockResolvedValueOnce( [ '# comment line', ' \t ', ' * @jimmy # inline comment ', ' # comment line with leading whitespace', ' package.json @john @maria#inline comment without leading whitespace ', ].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['package.json']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@john', '@maria', '@jimmy']); }); it('returns empty array when no code owners set', async () => { fs.readLocalFile.mockResolvedValueOnce(null); git.getBranchFiles.mockResolvedValueOnce(['package.json']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toBeEmptyArray(); }); it('returns empty array when no code owners match', async () => { fs.readLocalFile.mockResolvedValueOnce( ['package-lock.json @mike'].join('\n'), ); git.getBranchFiles.mockResolvedValueOnce(['yarn.lock']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual([]); }); it('returns empty array when error occurs', async () => { fs.readLocalFile.mockRejectedValueOnce(new Error()); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toBeEmptyArray(); }); const codeOwnerFilePaths = [ 'CODEOWNERS', '.github/CODEOWNERS', '.gitlab/CODEOWNERS', 'docs/CODEOWNERS', ]; codeOwnerFilePaths.forEach((codeOwnerFilePath) => { it(`detects code owner file at '${codeOwnerFilePath}'`, async () => { fs.readLocalFile.mockImplementation((path): Promise => { if (path === codeOwnerFilePath) { return Promise.resolve(['* @mike'].join('\n')); } return Promise.resolve(null); }); git.getBranchFiles.mockResolvedValueOnce(['README.md']); const codeOwners = await codeOwnersForPr(pr); expect(codeOwners).toEqual(['@mike']); }); }); }); });