Skip to content

Commit 46f401c

Browse files
feat(bitbucket-server): Resolves reviewer email-addresses to Users (renovatebot#37199)
Signed-off-by: portly-halicore-76 <170707699+portly-halicore-76@users.noreply.github.com>
1 parent e477026 commit 46f401c

File tree

3 files changed

+247
-2
lines changed

3 files changed

+247
-2
lines changed

lib/modules/platform/bitbucket-server/index.spec.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ describe('modules/platform/bitbucket-server/index', () => {
186186
name: username,
187187
emailAddress: 'abc@def.com',
188188
displayName: 'Abc Def',
189+
active: true,
190+
slug: 'username',
189191
};
190192

191193
async function initRepo(config = {}): Promise<httpMock.Scope> {
@@ -985,6 +987,57 @@ describe('modules/platform/bitbucket-server/index', () => {
985987
await expect(bitbucket.addReviewers(5, ['name'])).toResolve();
986988
});
987989

990+
it('deals correctly with resolving reviewers', async () => {
991+
const scope = await initRepo();
992+
scope
993+
.get(
994+
`${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`,
995+
)
996+
.twice()
997+
.reply(200, prMock(url, 'SOME', 'repo'));
998+
999+
scope
1000+
.put(
1001+
`${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`,
1002+
(body) => {
1003+
const reviewers = body.reviewers.map(
1004+
(r: { user: { name: any } }) => r.user.name,
1005+
);
1006+
return (
1007+
Array.isArray(reviewers) &&
1008+
reviewers.length === 3 &&
1009+
reviewers.includes('name') &&
1010+
reviewers.includes('userName2') &&
1011+
reviewers.includes('usernamefoundbyemail')
1012+
);
1013+
},
1014+
)
1015+
.reply(200);
1016+
1017+
scope
1018+
// User by email
1019+
.get(`${urlPath}/rest/api/1.0/users`)
1020+
.query(
1021+
(q) =>
1022+
q.filter === 'test@test.com' &&
1023+
q['permission.1'] === 'REPO_READ' &&
1024+
q['permission.1.repositorySlug'] === 'repo' &&
1025+
q['permission.1.projectKey'] === 'SOME',
1026+
)
1027+
.reply(200, [
1028+
{
1029+
slug: 'usernamefoundbyemail',
1030+
active: true,
1031+
displayName: 'Not relevant',
1032+
emailAddress: 'test@test.com',
1033+
},
1034+
]);
1035+
1036+
await expect(
1037+
bitbucket.addReviewers(5, ['name', 'userName2', 'test@test.com']),
1038+
).toResolve();
1039+
});
1040+
9881041
it('throws', async () => {
9891042
const scope = await initRepo();
9901043
scope
@@ -1002,6 +1055,135 @@ describe('modules/platform/bitbucket-server/index', () => {
10021055
});
10031056
});
10041057

1058+
describe('getUserSlugsByEmail', () => {
1059+
it('throws when lookup fails', async () => {
1060+
const scope = await initRepo();
1061+
scope
1062+
// User by email
1063+
.get(`${urlPath}/rest/api/1.0/users`)
1064+
.query(
1065+
(q) =>
1066+
q.filter === 'e-mail@test.com' &&
1067+
q['permission.1'] === 'REPO_READ' &&
1068+
q['permission.1.repositorySlug'] === 'repo' &&
1069+
q['permission.1.projectKey'] === 'SOME',
1070+
)
1071+
.reply(500, []);
1072+
1073+
await expect(
1074+
bitbucket.getUserSlugsByEmail('e-mail@test.com'),
1075+
).rejects.toThrow('Response code 500 (Internal Server Error)');
1076+
});
1077+
1078+
it('return empty array when no results found', async () => {
1079+
const scope = await initRepo();
1080+
scope
1081+
// User by email
1082+
.get(`${urlPath}/rest/api/1.0/users`)
1083+
.query(
1084+
(q) =>
1085+
q.filter === 'e-mail@test.com' &&
1086+
q['permission.1'] === 'REPO_READ' &&
1087+
q['permission.1.repositorySlug'] === 'repo' &&
1088+
q['permission.1.projectKey'] === 'SOME',
1089+
)
1090+
.reply(200, []);
1091+
1092+
const actual = await bitbucket.getUserSlugsByEmail('e-mail@test.com');
1093+
expect(actual).toBeEmptyArray();
1094+
});
1095+
1096+
it('return only active users', async () => {
1097+
const scope = await initRepo();
1098+
scope
1099+
// User by email
1100+
.get(`${urlPath}/rest/api/1.0/users`)
1101+
.query(
1102+
(q) =>
1103+
q.filter === 'e-mail@test.com' &&
1104+
q['permission.1'] === 'REPO_READ' &&
1105+
q['permission.1.repositorySlug'] === 'repo' &&
1106+
q['permission.1.projectKey'] === 'SOME',
1107+
)
1108+
.reply(200, [
1109+
{
1110+
slug: 'usernamefoundbyemail',
1111+
active: false,
1112+
displayName: 'Not relevant',
1113+
emailAddress: 'e-mail@test.com',
1114+
},
1115+
]);
1116+
1117+
const actual = await bitbucket.getUserSlugsByEmail('e-mail@test.com');
1118+
expect(actual).toBeEmptyArray();
1119+
});
1120+
1121+
it('only returns exact matches', async () => {
1122+
const scope = await initRepo();
1123+
scope
1124+
// User by email
1125+
.get(`${urlPath}/rest/api/1.0/users`)
1126+
.query(
1127+
(q) =>
1128+
q.filter === 'mail@test.com' &&
1129+
q['permission.1'] === 'REPO_READ' &&
1130+
q['permission.1.repositorySlug'] === 'repo' &&
1131+
q['permission.1.projectKey'] === 'SOME',
1132+
)
1133+
.reply(200, [
1134+
{
1135+
slug: 'usernamefoundbyemail',
1136+
active: true,
1137+
displayName: 'Not relevant',
1138+
emailAddress: 'e-mail@test.com',
1139+
},
1140+
{
1141+
slug: 'usernamefoundbyemailtoo',
1142+
active: true,
1143+
displayName: 'Not relevant',
1144+
emailAddress: 'e-mail@test.com',
1145+
},
1146+
]);
1147+
1148+
const actual = await bitbucket.getUserSlugsByEmail('mail@test.com');
1149+
expect(actual).toBeEmptyArray();
1150+
});
1151+
1152+
it('returns multiple exact matches', async () => {
1153+
const scope = await initRepo();
1154+
scope
1155+
// User by email
1156+
.get(`${urlPath}/rest/api/1.0/users`)
1157+
.query(
1158+
(q) =>
1159+
q.filter === 'e-mail@test.com' &&
1160+
q['permission.1'] === 'REPO_READ' &&
1161+
q['permission.1.repositorySlug'] === 'repo' &&
1162+
q['permission.1.projectKey'] === 'SOME',
1163+
)
1164+
.reply(200, [
1165+
{
1166+
slug: 'usernamefoundbyemail',
1167+
active: true,
1168+
displayName: 'Not relevant',
1169+
emailAddress: 'e-mail@test.com',
1170+
},
1171+
{
1172+
slug: 'usernamefoundbyemailtoo',
1173+
active: true,
1174+
displayName: 'Not relevant',
1175+
emailAddress: 'e-mail@test.com',
1176+
},
1177+
]);
1178+
1179+
const actual = await bitbucket.getUserSlugsByEmail('e-mail@test.com');
1180+
expect(actual).toStrictEqual([
1181+
'usernamefoundbyemail',
1182+
'usernamefoundbyemailtoo',
1183+
]);
1184+
});
1185+
});
1186+
10051187
describe('deleteLAbel()', () => {
10061188
it('does not throw', async () => {
10071189
expect(await bitbucket.deleteLabel(5, 'renovate')).toMatchSnapshot();

lib/modules/platform/bitbucket-server/index.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import type {
4949
PullRequestActivity,
5050
PullRequestCommentActivity,
5151
} from './schema';
52-
import { UserSchema } from './schema';
52+
import { UserSchema, UsersSchema, isEmail } from './schema';
5353
import type {
5454
BbsConfig,
5555
BbsPr,
@@ -332,6 +332,7 @@ export async function getBranchForceRebase(
332332
res.body?.mergeConfig?.defaultStrategy?.id.includes('ff-only'),
333333
);
334334
}
335+
335336
// Gets details for a PR
336337
export async function getPr(
337338
prNo: number,
@@ -686,11 +687,64 @@ export async function addReviewers(
686687
): Promise<void> {
687688
logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${prNo}`);
688689

689-
await retry(updatePRAndAddReviewers, [prNo, reviewers], 3, [
690+
const reviewerSlugs = new Set<string>();
691+
692+
for (const entry of reviewers) {
693+
// If entry is an email-address, resolve userslugs
694+
if (isEmail(entry)) {
695+
const slugs = await getUserSlugsByEmail(entry);
696+
for (const slug of slugs) {
697+
reviewerSlugs.add(slug);
698+
}
699+
} else {
700+
reviewerSlugs.add(entry);
701+
}
702+
}
703+
704+
await retry(updatePRAndAddReviewers, [prNo, Array.from(reviewerSlugs)], 3, [
690705
REPOSITORY_CHANGED,
691706
]);
692707
}
693708

709+
/**
710+
* Resolves Bitbucket users by email address,
711+
* restricted to users who have REPO_READ permission on the target repository.
712+
*
713+
* @param emailAddress - A string that could be the user's email-address.
714+
* @returns List of user slugs for active, matched users.
715+
*/
716+
export async function getUserSlugsByEmail(
717+
emailAddress: string,
718+
): Promise<string[]> {
719+
try {
720+
const filterUrl =
721+
`./rest/api/1.0/users?filter=${emailAddress}` +
722+
`&permission.1=REPO_READ` +
723+
`&permission.1.projectKey=${config.projectKey}` +
724+
`&permission.1.repositorySlug=${config.repositorySlug}`;
725+
726+
const users = await bitbucketServerHttp.getJson(
727+
filterUrl,
728+
{ limit: 100 },
729+
UsersSchema,
730+
);
731+
732+
if (users.body.length) {
733+
return users.body
734+
.filter((u) => u.active && u.emailAddress === emailAddress)
735+
.map((u) => u.slug);
736+
}
737+
} catch (err) {
738+
logger.warn(
739+
{ err, emailAddress },
740+
`Failed to resolve email address to user slug`,
741+
);
742+
throw err;
743+
}
744+
logger.debug({ userinfo: emailAddress }, 'No users found for email-address');
745+
return [];
746+
}
747+
694748
async function updatePRAndAddReviewers(
695749
prNo: number,
696750
reviewers: string[],

lib/modules/platform/bitbucket-server/schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import { z } from 'zod';
33
export const UserSchema = z.object({
44
displayName: z.string(),
55
emailAddress: z.string(),
6+
active: z.boolean(),
7+
slug: z.string(),
68
});
79

10+
export const UsersSchema = z.array(UserSchema);
11+
812
export const Files = z.array(z.string());
913

1014
export const Comment = z.object({
@@ -30,3 +34,8 @@ export const PullRequestActivity = z.union([
3034
]);
3135

3236
export type PullRequestActivity = z.infer<typeof PullRequestActivity>;
37+
38+
const EmailSchema = z.string().email();
39+
40+
export const isEmail = (value: string): boolean =>
41+
EmailSchema.safeParse(value).success;

0 commit comments

Comments
 (0)