Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support adding Reviewers or Assignees by email instead of an Id #836

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});