Skip to content

Commit

Permalink
Support adding Reviewers or Assignees by email instead of an Id (#836)
Browse files Browse the repository at this point in the history
  • Loading branch information
BobSilent authored Jun 4, 2024
1 parent c19237a commit 7a7b5ee
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 6 deletions.
7 changes: 5 additions & 2 deletions extension/task/IDependabotConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ export interface IDependabotUpdate {
/**
* Reviewers.
*/
reviewers?: string;
reviewers?: string[];
/**
* Assignees.
*/
assignees?: string;
assignees?: string[];
/**
* Commit Message.
*/
commitMessage?: string;
/**
* The milestone to associate pull requests with.
Expand Down
7 changes: 5 additions & 2 deletions extension/task/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ToolRunner } from "azure-pipelines-task-lib/toolrunner"
import { IDependabotConfig, IDependabotRegistry, IDependabotUpdate } from "./IDependabotConfig";
import getSharedVariables from "./utils/getSharedVariables";
import { parseConfigFile } from "./utils/parseConfigFile";
import { resolveAzureDevOpsIdentities } from "./utils/resolveAzureDevOpsIdentities";

async function run() {
try {
Expand Down Expand Up @@ -128,12 +129,14 @@ async function run() {

// Set the reviewers
if (update.reviewers) {
dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${update.reviewers}`]);
const reviewers = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.reviewers)
dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${JSON.stringify(reviewers.map(identity => identity.id))}`]);
}

// Set the assignees
if (update.assignees) {
dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${update.assignees}`]);
const assignees = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.assignees)
dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${JSON.stringify(assignees.map(identity => identity.id))}`]);
}

// Set the updater options, if provided
Expand Down
4 changes: 2 additions & 2 deletions extension/task/utils/parseConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ function parseUpdates(config: any): IDependabotUpdate[] {
ignore: update["ignore"] ? JSON.stringify(update["ignore"]) : undefined,
labels: update["labels"] ? JSON.stringify(update["labels"]) : undefined,
reviewers: update["reviewers"]
? JSON.stringify(update["reviewers"])
? update["reviewers"]
: undefined,
assignees: update["assignees"]
? JSON.stringify(update["assignees"])
? update["assignees"]
: undefined,
commitMessage: update["commit-message"]
? JSON.stringify(update["commit-message"])
Expand Down
160 changes: 160 additions & 0 deletions extension/task/utils/resolveAzureDevOpsIdentities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as tl from "azure-pipelines-task-lib/task";
import axios from "axios";
import extractOrganization from "./extractOrganization";

export interface IIdentity {
/**
* The identity id to use for PR reviewer or assignee Id.
*/
id: string,
/**
* Human readable Username.
*/
displayName?: string,
/**
* The provided input to use for searching an identity.
*/
input: string,
}

/**
* Resolves the given input email addresses to an array of IIdentity information.
* It also handles non email input, which is assumed to be already an identity id
* to pass as reviewer id to an PR.
*
* @param organizationUrl
* @param inputs
* @returns
*/
export async function resolveAzureDevOpsIdentities(organizationUrl: URL, inputs: string[]): Promise<IIdentity[]> {
const result: IIdentity[] = [];

tl.debug(`Attempting to fetch configuration file via REST API ...`);
for (const input of inputs) {
if (input.indexOf("@") > 0 ) {
// input is email to look-up
const identityInfo = await querySubject(organizationUrl, input);
if (identityInfo) {
result.push(identityInfo);
}
} else {
// input is already identity id
result.push({id: input, input: input});
}
}
return result;
}

/**
* Returns whether the extension is run in a hosted environment (as opposed to an on-premise environment).
* In Azure DevOps terms, hosted environment is also known as "Azure DevOps Services" and on-premise environment is known as
* "Team Foundation Server" or "Azure DevOps Server".
*/
export function isHostedAzureDevOps(uri: URL): boolean {
const hostname = uri.hostname.toLowerCase();
return hostname === 'dev.azure.com' || hostname.endsWith('.visualstudio.com');
}

function decodeBase64(input: string):string {
return Buffer.from(input, 'base64').toString('utf8');
}

function encodeBase64(input: string):string {
return Buffer.from(input, 'utf8').toString('base64');
}

function isSuccessStatusCode(statusCode?: number) : boolean {
return (statusCode >= 200) && (statusCode <= 299);
}

async function querySubject(organizationUrl: URL, email: string): Promise<IIdentity | undefined> {

if (isHostedAzureDevOps(organizationUrl)) {
const organization: string = extractOrganization(organizationUrl.toString());
return await querySubjectHosted(organization, email);
} else {
return await querySubjectOnPrem(organizationUrl, email);
}
}

/**
* Make the HTTP Request for an OnPrem Azure DevOps Server to resolve an email to an IIdentity
* @param organizationUrl
* @param email
* @returns
*/
async function querySubjectOnPrem(organizationUrl: URL, email: string): Promise<IIdentity | undefined> {
const url = `${organizationUrl}_apis/identities?searchFilter=MailAddress&queryMembership=None&filterValue=${email}`;
tl.debug(`GET ${url}`);
try {
const response = await axios.get(url, {
headers: {
Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`,
Accept: "application/json;api-version=5.0",
},
});

if (isSuccessStatusCode(response.status)) {
return {
id: response.data.value[0]?.id,
displayName: response.data.value[0]?.providerDisplayName,
input: email}
}
} catch (error) {
const responseStatusCode = error?.response?.status;
tl.debug(`HTTP Response Status: ${responseStatusCode}`)
if (responseStatusCode > 400 && responseStatusCode < 500) {
tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`);
throw new Error(
`The access token provided is empty or does not have permissions to access '${url}'`
);
} else {
throw error;
}
}
}

/**
* * Make the HTTP Request for a hosted Azure DevOps Service, to resolve an email to an IIdentity
* @param organization
* @param email
* @returns
*/
async function querySubjectHosted(organization: string, email: string): Promise<IIdentity | undefined> {
// make HTTP request
const url = `https://vssps.dev.azure.com/${organization}/_apis/graph/subjectquery`;
tl.debug(`GET ${url}`);
try {
const response = await axios.post(url, {
headers: {
Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`,
Accept: "application/json;api-version=6.0-preview.1",
"Content-Type": "application/json",
},
data: {
"query": email,
"subjectKind": [ "User" ]
}
});

if (isSuccessStatusCode(response.status)) {
const descriptor: string = response.data.value[0]?.descriptor || "";
const id = decodeBase64(descriptor.substring(descriptor.indexOf(".") + 1))
return {
id: id,
displayName: response.data.value[0]?.displayName,
input: email}
}
} catch (error) {
const responseStatusCode = error?.response?.status;
tl.debug(`HTTP Response Status: ${responseStatusCode}`)
if (responseStatusCode > 400 && responseStatusCode < 500) {
tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`);
throw new Error(
`The access token provided is empty or does not have permissions to access '${url}'`
);
} else {
throw error;
}
}
}
93 changes: 93 additions & 0 deletions extension/tests/utils/resolveAzureDevOpsIdentities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { isHostedAzureDevOps, resolveAzureDevOpsIdentities } from "../../task/utils/resolveAzureDevOpsIdentities";
import { describe } from "node:test";
import axios from "axios";

describe("isHostedAzureDevOps", () => {
it("Old visualstudio url is hosted.", () => {
const url = new URL("https://example.visualstudio.com/abc")
const result = isHostedAzureDevOps(url);

expect(result).toBeTruthy();
});
it("Dev Azure url is hosted.", () => {
const url = new URL("https://dev.azure.com/example")
const result = isHostedAzureDevOps(url);

expect(result).toBeTruthy();
});
it("private url is not hosted.", () => {
const url = new URL("https://tfs.example.com/tfs/Collection")
const result = isHostedAzureDevOps(url);

expect(result).toBeFalsy();
});
});


jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

const aliceOnPrem = {
id: "any id",
email: "[email protected]",
providerDisplayName: "Alice"
}

const aliceHostedId = "any Id"
const aliceHosted = {
descriptor: "aad." + Buffer.from(aliceHostedId, 'utf8').toString('base64'),
email: "[email protected]",
providerDisplayName: "Alice"
}

describe("resolveAzureDevOpsIdentities", () => {
it("No email input, is directly returned.", async () => {
const url = new URL("https://example.visualstudio.com/abc")

const input = ["be9321e2-f404-4ffa-8d6b-44efddb04865"];
const results = await resolveAzureDevOpsIdentities(url, input);

const outputs = results.map(identity => identity.id);
expect(outputs).toHaveLength(1);
expect(outputs).toContain(input[0]);
});
it("successfully resolve id for azure devops server", async () => {
const url = new URL("https://example.onprem.com/abc")

// Provide the data object to be returned
mockedAxios.get.mockResolvedValue({
data: {
count: 1,
value: [aliceOnPrem],
},
status: 200,
});

const input = [aliceOnPrem.email];
const results = await resolveAzureDevOpsIdentities(url, input);

const outputs = results.map(identity => identity.id);
expect(outputs).toHaveLength(1);
expect(outputs).toContain(aliceOnPrem.id);
});
it("successfully resolve id for hosted azure devops", async () => {
const url = new URL("https://dev.azure.com/exampleorganization")


// Provide the data object to be returned
mockedAxios.post.mockResolvedValue({
data: {
count: 1,
value: [aliceHosted],
},
status: 200,
});

const input = [aliceHosted.email];
const results = await resolveAzureDevOpsIdentities(url, input);

const outputs = results.map(identity => identity.id);
expect(outputs).toHaveLength(1);
expect(outputs).toContain(aliceHostedId);
});
});

0 comments on commit 7a7b5ee

Please sign in to comment.