From 0882bec330561e14a04fd30fa577e9ef81a0b8b4 Mon Sep 17 00:00:00 2001 From: bri becker <39387238+bbckr@users.noreply.github.com> Date: Tue, 7 Nov 2023 03:21:04 -0600 Subject: [PATCH] feat(pr): expandCodeOwnersGroups (#25317) Co-authored-by: Michael Kriese <michael.kriese@visualon.de> Co-authored-by: Rhys Arkins <rhys@arkins.net> --- docs/usage/configuration-options.md | 5 ++ lib/config/options/index.ts | 8 +++ lib/config/types.ts | 1 + lib/modules/platform/gitlab/http.ts | 13 +++- lib/modules/platform/gitlab/index.spec.ts | 64 +++++++++++++++++++ lib/modules/platform/gitlab/index.ts | 42 +++++++++++- lib/modules/platform/types.ts | 1 + lib/util/common.ts | 4 ++ lib/workers/repository/init/index.spec.ts | 4 ++ lib/workers/repository/init/index.ts | 8 +++ .../repository/update/pr/participants.spec.ts | 61 ++++++++++++++++++ .../repository/update/pr/participants.ts | 19 ++++-- 12 files changed, 220 insertions(+), 10 deletions(-) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index dd3cdf42b6..a3ed397c64 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1224,6 +1224,11 @@ Example: The above would mean Renovate would not include files matching the above glob pattern in the commit, even if it thinks they should be updated. +## expandCodeOwnersGroups + +If configured, Renovate will expand any matching `CODEOWNERS` groups into a full list of group members and assign them individually instead of the group. +This is particularly useful when combined with `assigneesSampleSize` and `assigneesFromCodeOwners`, so that only a subset of the Codeowners are assigned instead of the whole group. + ## extends See [shareable config presets](./config-presets.md) for details. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index ccdef64f89..8c4869252f 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -2094,6 +2094,14 @@ const options: RenovateOptions[] = [ type: 'boolean', default: false, }, + { + name: 'expandCodeOwnersGroups', + description: + 'Expand the configured code owner groups into a full list of group members.', + type: 'boolean', + default: false, + supportedPlatforms: ['gitlab'], + }, { name: 'assigneesSampleSize', description: 'Take a random sample of given size from `assignees`.', diff --git a/lib/config/types.ts b/lib/config/types.ts index 0c4ef22649..f9732cba2f 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -275,6 +275,7 @@ export interface AllConfig export interface AssigneesAndReviewersConfig { assigneesFromCodeOwners?: boolean; + expandCodeOwnersGroups?: boolean; assignees?: string[]; assigneesSampleSize?: number; ignoreReviewers?: string[]; diff --git a/lib/modules/platform/gitlab/http.ts b/lib/modules/platform/gitlab/http.ts index 07cbd04ed9..54a5555be8 100644 --- a/lib/modules/platform/gitlab/http.ts +++ b/lib/modules/platform/gitlab/http.ts @@ -10,14 +10,23 @@ export async function getUserID(username: string): Promise<number> { ).body[0].id; } -export async function getMemberUserIDs(group: string): Promise<number[]> { +async function getMembers(group: string): Promise<GitLabUser[]> { const groupEncoded = encodeURIComponent(group); - const members = ( + return ( await gitlabApi.getJson<GitLabUser[]>(`groups/${groupEncoded}/members`) ).body; +} + +export async function getMemberUserIDs(group: string): Promise<number[]> { + const members = await getMembers(group); return members.map((u) => u.id); } +export async function getMemberUsernames(group: string): Promise<string[]> { + const members = await getMembers(group); + return members.map((u) => u.username); +} + export async function isUserBusy(user: string): Promise<boolean> { try { const url = `/users/${user}/status`; diff --git a/lib/modules/platform/gitlab/index.spec.ts b/lib/modules/platform/gitlab/index.spec.ts index fadc5178b0..31d0cb4028 100644 --- a/lib/modules/platform/gitlab/index.spec.ts +++ b/lib/modules/platform/gitlab/index.spec.ts @@ -2779,4 +2779,68 @@ These updates have all been created already. Click a checkbox below to force a r expect(filteredUsers).toEqual(['maria']); }); }); + + describe('expandGroupMembers(reviewersOrAssignees)', () => { + it('expands group members for groups with members', async () => { + httpMock + .scope(gitlabApiHost) + .get('/api/v4/groups/group-a/members') + .reply(200, [{ username: 'maria' }, { username: 'jimmy' }]) + .get('/api/v4/groups/group-b/members') + .reply(200, [{ username: 'john' }]); + const expandedGroupMembers = await gitlab.expandGroupMembers?.([ + 'u@email.com', + '@group-a', + '@group-b', + ]); + expect(expandedGroupMembers).toEqual([ + 'u@email.com', + 'maria', + 'jimmy', + 'john', + ]); + }); + + it('users are not expanded when 404', async () => { + httpMock + .scope(gitlabApiHost) + .get('/api/v4/groups/john/members') + .reply(404, { message: '404 Group Not Found' }); + const expandedGroupMembers = await gitlab.expandGroupMembers?.(['john']); + expect(expandedGroupMembers).toEqual(['john']); + }); + + it('users are not expanded when non 404', async () => { + httpMock + .scope(gitlabApiHost) + .get('/api/v4/groups/group/members') + .reply(403, { message: '403 Authorization' }); + const expandedGroupMembers = await gitlab.expandGroupMembers?.([ + '@group', + ]); + expect(expandedGroupMembers).toEqual(['group']); + expect(logger.debug).toHaveBeenCalledWith( + expect.any(Object), + 'Unable to fetch group' + ); + }); + + it('groups with no members expand into empty list', async () => { + httpMock + .scope(gitlabApiHost) + .get('/api/v4/groups/group-c/members') + .reply(200, []); + const expandedGroupMembers = await gitlab.expandGroupMembers?.([ + '@group-c', + ]); + expect(expandedGroupMembers).toEqual([]); + }); + + it('includes email in final result', async () => { + const expandedGroupMembers = await gitlab.expandGroupMembers?.([ + 'u@email.com', + ]); + expect(expandedGroupMembers).toEqual(['u@email.com']); + }); + }); }); diff --git a/lib/modules/platform/gitlab/index.ts b/lib/modules/platform/gitlab/index.ts index 58e8c6bf9f..ce4bd3ec53 100644 --- a/lib/modules/platform/gitlab/index.ts +++ b/lib/modules/platform/gitlab/index.ts @@ -18,6 +18,7 @@ import { import { logger } from '../../../logger'; import type { BranchStatus } from '../../../types'; import { coerceArray } from '../../../util/array'; +import { noLeadingAtSymbol } from '../../../util/common'; import { parseJson } from '../../../util/common'; import * as git from '../../../util/git'; import * as hostRules from '../../../util/host-rules'; @@ -53,7 +54,13 @@ import type { } from '../types'; import { repoFingerprint } from '../util'; import { smartTruncate } from '../utils/pr-body'; -import { getMemberUserIDs, getUserID, gitlabApi, isUserBusy } from './http'; +import { + getMemberUserIDs, + getMemberUsernames, + getUserID, + gitlabApi, + isUserBusy, +} from './http'; import { getMR, updateMR } from './merge-request'; import { LastPipelineId } from './schema'; import type { @@ -1339,3 +1346,36 @@ export async function filterUnavailableUsers( } return filteredUsers; } + +export async function expandGroupMembers( + reviewersOrAssignees: string[] +): Promise<string[]> { + const expandedReviewersOrAssignees: string[] = []; + const normalizedReviewersOrAssigneesWithoutEmails: string[] = []; + + // Skip passing user emails to Gitlab API, but include them in the final result + for (const reviewerOrAssignee of reviewersOrAssignees) { + if (reviewerOrAssignee.indexOf('@') > 0) { + expandedReviewersOrAssignees.push(reviewerOrAssignee); + continue; + } + + // Normalize the potential group names before passing to Gitlab API + normalizedReviewersOrAssigneesWithoutEmails.push( + noLeadingAtSymbol(reviewerOrAssignee) + ); + } + + for (const reviewerOrAssignee of normalizedReviewersOrAssigneesWithoutEmails) { + try { + const members = await getMemberUsernames(reviewerOrAssignee); + expandedReviewersOrAssignees.push(...members); + } catch (err) { + if (err.statusCode !== 404) { + logger.debug({ err, reviewerOrAssignee }, 'Unable to fetch group'); + } + expandedReviewersOrAssignees.push(reviewerOrAssignee); + } + } + return expandedReviewersOrAssignees; +} diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index ca98ae3337..28a1a699ef 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -229,6 +229,7 @@ export interface Platform { initPlatform(config: PlatformParams): Promise<PlatformResult>; filterUnavailableUsers?(users: string[]): Promise<string[]>; commitFiles?(config: CommitFilesConfig): Promise<CommitSha | null>; + expandGroupMembers?(reviewersOrAssignees: string[]): Promise<string[]>; } export interface PlatformScm { diff --git a/lib/util/common.ts b/lib/util/common.ts index d7348a763c..cb1af76f3d 100644 --- a/lib/util/common.ts +++ b/lib/util/common.ts @@ -62,6 +62,10 @@ export function detectPlatform( return null; } +export function noLeadingAtSymbol(input: string): string { + return input.startsWith('@') ? input.slice(1) : input; +} + export function parseJson(content: string | null, filename: string): unknown { if (!content) { return null; diff --git a/lib/workers/repository/init/index.spec.ts b/lib/workers/repository/init/index.spec.ts index 4300e9231c..cd69dc65bc 100644 --- a/lib/workers/repository/init/index.spec.ts +++ b/lib/workers/repository/init/index.spec.ts @@ -52,6 +52,7 @@ describe('workers/repository/init/index', () => { onboarding.checkOnboardingBranch.mockResolvedValueOnce({}); config.getRepoConfig.mockResolvedValueOnce({ filterUnavailableUsers: true, + expandCodeOwnersGroups: true, }); merge.mergeRenovateConfig.mockResolvedValueOnce({}); secrets.applySecretsToConfig.mockReturnValueOnce( @@ -61,6 +62,9 @@ describe('workers/repository/init/index', () => { expect(logger.logger.warn).toHaveBeenCalledWith( "Configuration option 'filterUnavailableUsers' is not supported on the current platform 'undefined'." ); + expect(logger.logger.warn).toHaveBeenCalledWith( + "Configuration option 'expandCodeOwnersGroups' is not supported on the current platform 'undefined'." + ); }); }); }); diff --git a/lib/workers/repository/init/index.ts b/lib/workers/repository/init/index.ts index 77cef2b149..e67a255d9c 100644 --- a/lib/workers/repository/init/index.ts +++ b/lib/workers/repository/init/index.ts @@ -30,6 +30,14 @@ function warnOnUnsupportedOptions(config: RenovateConfig): void { `Configuration option 'filterUnavailableUsers' is not supported on the current platform '${platform}'.` ); } + + if (config.expandCodeOwnersGroups && !platform.expandGroupMembers) { + // TODO: types (#22198) + const platform = GlobalConfig.get('platform')!; + logger.warn( + `Configuration option 'expandCodeOwnersGroups' is not supported on the current platform '${platform}'.` + ); + } } export async function initRepo( diff --git a/lib/workers/repository/update/pr/participants.spec.ts b/lib/workers/repository/update/pr/participants.spec.ts index 04e3f148cc..8da83ebb2c 100644 --- a/lib/workers/repository/update/pr/participants.spec.ts +++ b/lib/workers/repository/update/pr/participants.spec.ts @@ -46,6 +46,67 @@ describe('workers/repository/update/pr/participants', () => { expect(platform.addAssignees).toHaveBeenCalledWith(123, ['a', 'b']); }); + it('expands group code owners assignees', async () => { + codeOwners.codeOwnersForPr.mockResolvedValueOnce([ + 'user', + '@group', + 'u@email.com', + ]); + platform.expandGroupMembers = jest + .fn() + .mockResolvedValueOnce(['u@email.com', 'user', 'group.user']); + await addParticipants( + { + ...config, + assigneesFromCodeOwners: true, + expandCodeOwnersGroups: true, + }, + pr + ); + expect(platform.expandGroupMembers).toHaveBeenCalledWith([ + 'user', + '@group', + 'u@email.com', + ]); + expect(codeOwners.codeOwnersForPr).toHaveBeenCalledOnce(); + expect(platform.addAssignees).toHaveBeenCalledWith(123, [ + 'a', + 'b', + 'c', + 'u@email.com', + 'user', + 'group.user', + ]); + }); + + it('does not expand group code owners assignees when assigneesFromCodeOwners disabled', async () => { + codeOwners.codeOwnersForPr.mockResolvedValueOnce(['user', '@group']); + platform.expandGroupMembers = jest + .fn() + .mockResolvedValueOnce(['user', 'group.user']); + await addParticipants(config, pr); + expect(codeOwners.codeOwnersForPr).not.toHaveBeenCalled(); + expect(platform.expandGroupMembers).not.toHaveBeenCalled(); + expect(platform.addAssignees).toHaveBeenCalledWith(123, ['a', 'b', 'c']); + }); + + it('does not expand group code owners assignees when expandCodeOwnersGroups disabled', async () => { + codeOwners.codeOwnersForPr.mockResolvedValueOnce(['user', '@group']); + platform.expandGroupMembers = jest + .fn() + .mockResolvedValueOnce(['user', 'group.user']); + await addParticipants({ ...config, assigneesFromCodeOwners: true }, pr); + expect(codeOwners.codeOwnersForPr).toHaveBeenCalledOnce(); + expect(platform.expandGroupMembers).not.toHaveBeenCalled(); + expect(platform.addAssignees).toHaveBeenCalledWith(123, [ + 'a', + 'b', + 'c', + 'user', + 'group', + ]); + }); + it('supports assigneesSampleSize', async () => { util.sampleSize.mockReturnValueOnce(['a', 'c']); await addParticipants({ ...config, assigneesSampleSize: 2 }, pr); diff --git a/lib/workers/repository/update/pr/participants.ts b/lib/workers/repository/update/pr/participants.ts index 9e2c5e218a..a8ee37928f 100644 --- a/lib/workers/repository/update/pr/participants.ts +++ b/lib/workers/repository/update/pr/participants.ts @@ -3,14 +3,23 @@ import { GlobalConfig } from '../../../../config/global'; import type { RenovateConfig } from '../../../../config/types'; import { logger } from '../../../../logger'; import { Pr, platform } from '../../../../modules/platform'; +import { noLeadingAtSymbol } from '../../../../util/common'; import { sampleSize } from '../../../../util/sample'; import { codeOwnersForPr } from './code-owners'; async function addCodeOwners( + config: RenovateConfig, assigneesOrReviewers: string[], pr: Pr ): Promise<string[]> { - return [...new Set(assigneesOrReviewers.concat(await codeOwnersForPr(pr)))]; + const codeOwners = await codeOwnersForPr(pr); + + const assignees = + config.expandCodeOwnersGroups && platform.expandGroupMembers + ? await platform.expandGroupMembers(codeOwners) + : codeOwners; + + return [...new Set(assigneesOrReviewers.concat(assignees))]; } function filterUnavailableUsers( @@ -22,10 +31,6 @@ function filterUnavailableUsers( : Promise.resolve(users); } -function noLeadingAtSymbol(input: string): string { - return input.length && input.startsWith('@') ? input.slice(1) : input; -} - function prepareParticipants( config: RenovateConfig, usernames: string[] @@ -41,7 +46,7 @@ export async function addParticipants( let assignees = config.assignees ?? []; logger.debug(`addParticipants(pr=${pr?.number})`); if (config.assigneesFromCodeOwners) { - assignees = await addCodeOwners(assignees, pr); + assignees = await addCodeOwners(config, assignees, pr); } if (assignees.length > 0) { try { @@ -67,7 +72,7 @@ export async function addParticipants( let reviewers = config.reviewers ?? []; if (config.reviewersFromCodeOwners) { - reviewers = await addCodeOwners(reviewers, pr); + reviewers = await addCodeOwners(config, reviewers, pr); } if ( is.array(config.additionalReviewers) && -- GitLab