tfsrestservice
Version:
Service which abstracts calls to the TFS/VSTS REST API and exposes the objects to the clients for further usage.
616 lines (488 loc) • 27 kB
text/typescript
import * as fs from "fs";
import * as url from "url";
import { List } from "linqts";
import path = require('path');
import * as vsts from "azure-devops-node-api";
import * as buildApi from "azure-devops-node-api/BuildApi";
import * as buildInterfaces from "azure-devops-node-api/interfaces/BuildInterfaces";
import * as testInterfaces from "azure-devops-node-api/interfaces/TestInterfaces";
import * as testApi from "azure-devops-node-api/TestApi";
import * as taskAgentApi from "azure-devops-node-api/TaskAgentApi";
import * as coreApi from "azure-devops-node-api/CoreApi";
import * as coreInterfaces from "azure-devops-node-api/interfaces/CoreInterfaces";
import * as taskAgentInterface from "azure-devops-node-api/interfaces/TaskAgentInterfaces";
import * as baseInterfaces from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces";
import { IRequestHandler } from "typed-rest-client/Interfaces";
import Stack from "ts-data.stack";
import common = require("./generalfunctions");
export const TeamFoundationCollectionUri: string = "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI";
export const TeamProject: string = "SYSTEM_TEAMPROJECT";
export const TeamProjectId: string = "SYSTEM_TEAMPROJECTID";
export const RequestedForUsername: string = "BUILD_REQUESTEDFOR";
export const RequestedForUserId: string = "BUILD_REQUESTEDFORID";
export const ReleaseRequestedForUsername: string = "RELEASE_REQUESTEDFOR";
export const ReleaseRequestedForId: string = "RELEASE_REQUESTEDFORID";
export const SourceVersion: string = "BUILD_SOURCEVERSION";
export const SourceBranch: string = "BUILD_SOURCEBRANCH";
export const CurrentBuildDefinition: string = "BUILD_DEFINITIONNAME";
export const OAuthAccessToken: string = "SYSTEM_ACCESSTOKEN";
export const RepositoryType: string = "BUILD_REPOSITORY_PROVIDER";
export const TfsRepositoryType: string = "TfsVersionControl";
export const AuthenticationMethodOAuthToken: string = "OAuth Token";
export const AuthenticationMethodBasicAuthentication: string = "Basic Authentication";
export const AuthenticationMethodPersonalAccessToken: string = "Personal Access Token";
export interface ITfsRestService {
initialize(
authenticationMethod: string, username: string, password: string, tfsServer: string, teamProject: string, ignoreSslError: boolean):
Promise<void>;
getBuildsByStatus(buildDefinitionName: string, statusFilter?: buildInterfaces.BuildStatus): Promise<buildInterfaces.Build[]>;
triggerBuild(
buildDefinitionName: string,
branch: string,
requestedFor: string,
sourceVersion: string,
demands: string[],
queueId: number,
buildParameters: string,
templateParameters: string): Promise<buildInterfaces.Build>;
downloadArtifacts(buildId: number, downloadDirectory: string): Promise<void>;
getQueueIdByName(buildQueue: string): Promise<number>;
getBuildInfo(buildId: number): Promise<buildInterfaces.Build>;
areBuildsFinished(triggeredBuilds: number[], failIfNotSuccessful: boolean, failIfPartiallySucceeded: boolean): Promise<boolean>;
isBuildFinished(buildId: number): Promise<boolean>;
wasBuildSuccessful(buildId: number): Promise<boolean>;
getBuildDefinitionId(buildDefinitionName: string): Promise<number>;
getTestRuns(testRunName: string, numberOfRunsToFetch: number): Promise<testInterfaces.TestRun[]>;
getAssociatedChanges(build: buildInterfaces.Build): Promise<buildInterfaces.Change[]>;
cancelBuild(buildId: number): Promise<void>;
}
export interface IAzureDevOpsWebApi {
getBearerHandler(bearerToken: string): IRequestHandler;
getBasicHandler(username: string, password: string): IRequestHandler;
getHandlerFromToken(token: string): IRequestHandler;
initializeConnection(tfsServer: string, authHandler: IRequestHandler, requestOptions: baseInterfaces.IRequestOptions): void;
getBuildApi(): Promise<buildApi.IBuildApi>;
getTestApi(): Promise<testApi.ITestApi>;
getTaskAgentApi(): Promise<taskAgentApi.ITaskAgentApi>;
getCoreApi(): Promise<coreApi.ICoreApi>;
}
class AzureDevOpsWebApi implements IAzureDevOpsWebApi {
connection: vsts.WebApi = null;
public initializeConnection(tfsServer: string, authHandler: IRequestHandler, requestOptions: baseInterfaces.IRequestOptions): void {
this.connection = new vsts.WebApi(tfsServer, authHandler, requestOptions);
}
public async getBuildApi(): Promise<buildApi.IBuildApi> {
this.verifyConnection();
return await this.connection.getBuildApi();
}
public async getTestApi(): Promise<testApi.ITestApi> {
this.verifyConnection();
return await this.connection.getTestApi();
}
public async getTaskAgentApi(): Promise<taskAgentApi.ITaskAgentApi> {
this.verifyConnection();
return await this.connection.getTaskAgentApi();
}
public async getCoreApi(): Promise<coreApi.ICoreApi> {
this.verifyConnection();
return await this.connection.getCoreApi();
}
public getBearerHandler(bearerToken: string): IRequestHandler {
return vsts.getBearerHandler(bearerToken);
}
public getBasicHandler(username: string, password: string): IRequestHandler {
return vsts.getBasicHandler(username, password);
}
public getHandlerFromToken(token: string): IRequestHandler {
return vsts.getHandlerFromToken(token);
}
private verifyConnection(): void {
if (this.connection === null) {
throw new Error("Initialize must be called before api's can be fetched!");
}
}
}
/* Tfs Rest Service Implementation */
export class TfsRestService implements ITfsRestService {
vstsBuildApi: buildApi.IBuildApi = null;
vstsTestApi: testApi.ITestApi = null;
taskAgentApi: taskAgentApi.ITaskAgentApi = null;
teamProjectId: string = "";
azureDevOpsWebApi: IAzureDevOpsWebApi = null;
genralFunctions: common.IGeneralFunctions = null;
constructor(azureDevOpsWebApi?: IAzureDevOpsWebApi, generalFunctions?: common.IGeneralFunctions) {
if (azureDevOpsWebApi === undefined) {
azureDevOpsWebApi = new AzureDevOpsWebApi();
}
if (generalFunctions === undefined) {
generalFunctions = new common.GeneralFunctions();
}
this.azureDevOpsWebApi = azureDevOpsWebApi;
this.genralFunctions = generalFunctions;
}
public async initialize(
authenticationMethod: string, username: string, password: string, tfsServer: string, teamProject: string, ignoreSslError: boolean):
Promise<void> {
if (teamProject === "" || teamProject === undefined) {
throw new Error("Team Project has to be defined!");
}
this.verifyAuthenticationMethod(authenticationMethod, username, password);
let authHandler: baseInterfaces.IRequestHandler;
switch (authenticationMethod) {
case AuthenticationMethodOAuthToken:
console.log("Using OAuth Access Token");
authHandler = this.azureDevOpsWebApi.getBearerHandler(password);
break;
case AuthenticationMethodBasicAuthentication:
console.log("Using Basic Authentication");
authHandler = this.azureDevOpsWebApi.getBasicHandler(username, password);
break;
case AuthenticationMethodPersonalAccessToken:
console.log("Using Personal Access Token");
authHandler = this.azureDevOpsWebApi.getHandlerFromToken(password);
break;
default:
throw new Error("Cannot handle authentication method " + authenticationMethod);
}
let requestOptions: baseInterfaces.IRequestOptions = {
ignoreSslError: ignoreSslError
};
this.azureDevOpsWebApi.initializeConnection(tfsServer, authHandler, requestOptions);
this.vstsBuildApi = await this.azureDevOpsWebApi.getBuildApi();
this.vstsTestApi = await this.azureDevOpsWebApi.getTestApi();
this.taskAgentApi = await this.azureDevOpsWebApi.getTaskAgentApi();
await this.setTeamProjectId(this.azureDevOpsWebApi, teamProject);
}
public async getBuildsByStatus(buildDefinitionName: string, statusFilter?: buildInterfaces.BuildStatus):
Promise<buildInterfaces.Build[]> {
var buildDefinitionID: number = await this.getBuildDefinitionId(buildDefinitionName);
var result: buildInterfaces.Build[] = await this.vstsBuildApi.getBuilds(
this.teamProjectId, [buildDefinitionID], null, null, null, null, null, null, statusFilter);
return result;
}
public async triggerBuild(
buildDefinitionName: string,
branch: string,
requestedForUserID: string,
sourceVersion: string,
demands: string[],
queueId: number,
buildParameters: string,
templateParameters: string): Promise<buildInterfaces.Build> {
var buildId: number = await this.getBuildDefinitionId(buildDefinitionName);
var buildToTrigger: buildInterfaces.Build = {
definition: { id: buildId },
parameters: this.createBuildParameterObject(buildParameters)
};
var tokenizedTemmplateParameters : {[key: string]: string; } = this.parseParameterString(templateParameters)
if (tokenizedTemmplateParameters !== null){
buildToTrigger.templateParameters = tokenizedTemmplateParameters;
}
if (branch !== null) {
buildToTrigger.sourceBranch = branch;
}
if (requestedForUserID !== undefined && requestedForUserID !== "") {
buildToTrigger.requestedFor = { id: requestedForUserID };
}
if (sourceVersion !== undefined && sourceVersion !== "") {
buildToTrigger.sourceVersion = sourceVersion;
}
if (queueId !== null && queueId !== undefined) {
buildToTrigger.queue = { id: queueId };
}
if (demands !== null && demands.length > 0) {
buildToTrigger.demands = [];
demands.forEach(demand => {
var demandSplit : string[] = demand.split('=');
var demandName: string = demandSplit[0].trim();
var demandValue: string = null;
if (demandSplit.length > 1){
demandValue = demandSplit[1].trim();
}
buildToTrigger.demands.push({name: demandName, value: demandValue})
});
}
var result: buildInterfaces.Build = await this.makeRequest(
() => this.vstsBuildApi.queueBuild(buildToTrigger, this.teamProjectId, true));
return result;
}
public async areBuildsFinished(
triggeredBuilds: number[], failIfNotSuccessful: boolean, treatPartiallySucceededBuildAsSuccessful: boolean): Promise<boolean> {
var result: boolean = true;
for (let queuedBuildId of triggeredBuilds) {
var buildInfo: buildInterfaces.Build = await this.getBuildInfo(queuedBuildId);
var buildFinished: boolean = buildInfo.status === buildInterfaces.BuildStatus.Completed;
if (!buildFinished) {
result = false;
} else {
result = result && true;
var buildSuccessful: boolean = buildInfo.result === buildInterfaces.BuildResult.Succeeded;
if (!buildSuccessful && treatPartiallySucceededBuildAsSuccessful) {
buildSuccessful = buildInfo.result === buildInterfaces.BuildResult.PartiallySucceeded;
}
if (failIfNotSuccessful && !buildSuccessful) {
throw new Error(`Build ${queuedBuildId} (${buildInfo.definition.name}) was not successful. See following link for more info: ${buildInfo._links.web.href}`);
}
}
}
return result;
}
public async cancelBuild(buildId: number): Promise<void> {
var buildInfo: buildInterfaces.Build = await this.getBuildInfo(buildId);
if (buildInfo.status === buildInterfaces.BuildStatus.Completed) {
console.log(`Build ${buildId} has already finished.`);
return;
}
var requestBody: any = { status: buildInterfaces.BuildStatus.Cancelling };
await this.makeRequest(
() => this.vstsBuildApi.updateBuild(requestBody, this.teamProjectId, buildId));
}
public async downloadArtifacts(buildId: number, downloadDirectory: string): Promise<void> {
console.log(`Downloading artifacts for Build ${buildId}`);
if (!fs.existsSync(downloadDirectory)) {
console.log(`Directory ${downloadDirectory} does not exist - will be created`);
fs.mkdirSync(downloadDirectory);
}
var result: buildInterfaces.BuildArtifact[] =
await this.makeRequest(() => this.vstsBuildApi.getArtifacts(this.teamProjectId, buildId));
if (result.length === 0) {
console.log(`No artifacts found for build ${buildId} - skipping...`);
return;
}
console.log(`Found ${result.length} artifact(s)`);
for (let artifact of result) {
if (artifact.resource.type.toLowerCase() !== "container") {
console.log(`Cannot download artifact ${artifact.name}. Only Containers are supported (type is \"${artifact.resource.type}"\)`);
continue;
}
console.log(`Downloading artifact ${artifact.name}...`);
var fileFormat: any = url.parse(artifact.resource.downloadUrl, true).query.$format;
// if for whatever reason we cannot get the file format from the url just try with zip.
if (fileFormat === null || fileFormat === undefined) {
fileFormat = "zip";
}
var fileName: string = `${artifact.name}.${fileFormat}`;
var index: number = 1;
var filePath : string = path.join(downloadDirectory, fileName);
while (fs.existsSync(filePath)) {
console.log(`${fileName} already exists...`);
fileName = `${artifact.name}${index}.${fileFormat}`;
filePath = path.join(downloadDirectory, fileName);
index++;
}
const artifactStream: NodeJS.ReadableStream = await this.vstsBuildApi.getArtifactContentZip(
this.teamProjectId, buildId, artifact.name);
const fileStream: any = fs.createWriteStream(filePath);
artifactStream.pipe(fileStream);
fileStream.on("close", () => {
console.log(`Stored artifact here: ${filePath}`);
});
}
}
public async getTestRuns(testRunName: string, numberOfRunsToFetch: number): Promise<testInterfaces.TestRun[]> {
var testRunSummaries: testInterfaces.TestRun[] = await this.makeRequest(() => this.vstsTestApi.getTestRuns(this.teamProjectId));
// reverse to fetch newest to oldest.
let testRuns: testInterfaces.TestRun[] = new List<testInterfaces.TestRun>(testRunSummaries)
.Reverse()
.Where(x => x !== undefined && x.state === testInterfaces.TestRunState.Completed.toString()
&& x.name === testRunName)
.Take(numberOfRunsToFetch)
.ToArray();
// reverse again to get the matching test runs orderd from oldest to newest.
return testRuns.reverse();
}
public async getQueueIdByName(buildQueue: string): Promise<number> {
var agentQueues: taskAgentInterface.TaskAgentQueue[] =
await this.makeRequest(() => this.taskAgentApi.getAgentQueues(this.teamProjectId, buildQueue));
if (agentQueues.length === 1) {
var agentQueue: taskAgentInterface.TaskAgentQueue = agentQueues[0];
return agentQueue.id;
}
console.error(`No queue found with the name: ${buildQueue}. Following Queues were found (Name (id)):`);
for (let queue of agentQueues) {
console.error(`${queue.name} (${queue.id})`);
}
throw new Error(`Could not find any Queue with the name ${buildQueue}`);
}
public async isBuildFinished(buildId: number): Promise<boolean> {
var result: buildInterfaces.Build = await this.getBuildInfo(buildId);
return result.status === buildInterfaces.BuildStatus.Completed;
}
public async wasBuildSuccessful(buildId: number): Promise<boolean> {
var result: buildInterfaces.Build = await this.getBuildInfo(buildId);
return result.result === buildInterfaces.BuildResult.Succeeded;
}
public async getBuildDefinitionId(buildDefinitionName: string): Promise<number> {
var result: buildInterfaces.BuildDefinitionReference[] = await this.makeRequest(
() => this.vstsBuildApi.getDefinitions(this.teamProjectId, buildDefinitionName));
if (result.length === 0) {
console.log(`No build definition with name ${buildDefinitionName} found...`);
var buildId: number = parseInt(buildDefinitionName);
if (isNaN(buildId)) {
throw new Error(`Did not find any build definition with this name: ${buildDefinitionName}`);
}
console.log(`Specified build name is a number - will treat as build id...`);
return buildId;
}
return result[0].id;
}
public async getAssociatedChanges(build: buildInterfaces.Build): Promise<buildInterfaces.Change[]> {
var result: buildInterfaces.Change[] = await this.makeRequest(
() => this.vstsBuildApi.getBuildChanges(this.teamProjectId, build.id));
return result;
}
public async getBuildInfo(buildId: number): Promise<buildInterfaces.Build> {
var build: buildInterfaces.Build = await this.makeRequest(
() => this.vstsBuildApi.getBuild(this.teamProjectId, buildId));
return build;
}
private parseParameterString(parameterString: string) : { [key: string]: string; } {
var parameterDictionary: { [id: string]: string } = {};
if (parameterString === null || parameterString === undefined || parameterString === "") {
return null;
}
parameterString = parameterString.trim();
var keyValuePairs: string[] = parameterString.split(",");
for (var index: number = 0; index < keyValuePairs.length; index++) {
var kvp: string = keyValuePairs[index];
var splittedKvp: string[] = kvp.split(/:(.+)/);
if (splittedKvp[0] === undefined || splittedKvp[1] === undefined) {
var errorMessage: string = `Parameters were not in expected format. Please verify that parameters are in the following format: \"VariableName: Value\"`;
console.error(errorMessage);
console.error(`Specified build parameters: ${parameterString}`);
throw new Error(errorMessage);
}
var key: string = this.cleanValue(splittedKvp[0].trim());
var value: string = this.cleanValue(splittedKvp[1].trim());
var checkNextValues: boolean = true;
var openingCurlyBracesStack: Stack<string> = new Stack<string>();
if (value.startsWith("{")) {
console.log(`Identified value as Json Object - will use as is`);
this.updateCurlyBracesStack(openingCurlyBracesStack, value);
}
while (index < keyValuePairs.length - 1 && checkNextValues) {
var nextKvp: string = keyValuePairs[index + 1];
var nextValue: string = `${this.cleanValue(nextKvp)}`;
if (!openingCurlyBracesStack.isEmpty()) {
value += `,${nextValue}`;
index++;
this.updateCurlyBracesStack(openingCurlyBracesStack, nextValue);
if (openingCurlyBracesStack.isEmpty()) {
checkNextValues = false;
}
} else if (nextKvp.indexOf(":") === -1) {
// next Value is part of the value and was just separated by comma
value += `,${nextValue}`;
index++;
} else {
checkNextValues = false;
}
}
console.log(`Found parameter ${key} with value: ${value}`);
parameterDictionary[key] = value;
}
return parameterDictionary;
}
private createBuildParameterObject(buildParameters: string): string {
if (buildParameters === null || buildParameters === undefined) {
return "";
}
buildParameters = buildParameters.trim();
if (buildParameters.startsWith("{") && buildParameters.endsWith("}")) {
console.log(`Specified Build Parameters are a json object - will be treated as is. Please make sure you handled any kind of escaping etc. yourself.`);
console.log(`Parameters: ${buildParameters}`);
return buildParameters;
}
return JSON.stringify(this.parseParameterString(buildParameters));
}
private updateCurlyBracesStack(openingCurlyBracesStack: Stack<string>, value: string): void {
var openingCurlyBracesInValue: number = (value.match(/{/g) || []).length;
for (var index: number = 0; index < openingCurlyBracesInValue; index++) {
openingCurlyBracesStack.push("{");
}
var closingCurlyBracesInValue: number = (value.match(/}/g) || []).length;
for (var index: number = 0; index < closingCurlyBracesInValue; index++) {
openingCurlyBracesStack.pop();
}
}
private cleanValue(value: string): string {
if (value.startsWith("\\\"") && value.endsWith("\\\"")) {
value = value.substr(2, value.length - 4);
}
return value;
}
private async setTeamProjectId(connection: IAzureDevOpsWebApi, teamProject: string): Promise<void> {
try {
var guidCheckRegex: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (guidCheckRegex.test(teamProject)) {
console.log("Provided team project was guid.");
this.teamProjectId = teamProject;
return;
}
// 300 is the current maximum amount of projects for azure devops
// See: https://docs.microsoft.com/en-us/azure/devops/organizations/settings/work/object-limits?view=azure-devops
var numberOfProjects = 300;
if (process.env.AzureNodeAPI_GetProjects_Top){
numberOfProjects = Number.parseInt(process.env.AzureNodeAPI_GetProjects_Top);
}
console.log("Provided team project was no guid, trying to resolve ID via API...");
var coreAgentApi: coreApi.ICoreApi = await connection.getCoreApi();
var projects: coreInterfaces.TeamProjectReference[] = await coreAgentApi.getProjects(null, numberOfProjects);
for (let project of projects) {
if (project.name === teamProject) {
this.teamProjectId = project.id;
console.log(`Found id for team project ${teamProject}: ${this.teamProjectId}`);
break;
}
}
} catch (err) {
throw new Error("Could not access projects - you're version of TFS might be too old, please check online for help.");
}
if (this.teamProjectId === "") {
throw new Error(`Could not find any Team Project with name ${teamProject}`);
}
}
private verifyAuthenticationMethod(authenticationMethod: string, username: string, password: string): void {
switch (authenticationMethod) {
case AuthenticationMethodOAuthToken:
if (password === undefined || password === null || password === "") {
throw new Error("No valid OAuth token provided. Please check that you have the OAuth Token Access allowed for your builds.");
}
break;
case AuthenticationMethodBasicAuthentication:
if (username === undefined || username === null || username === "") {
throw new Error("No Username provided. Please check that you specified the user correctly in the configuration.");
}
if (password === undefined || password === null || password === "") {
throw new Error("No password provided. Please check that you specified the password correctly in the configuration.");
}
break;
case AuthenticationMethodPersonalAccessToken:
if (password === undefined || password === null || password === "") {
throw new Error("No valid Personal Access Token provided. Please check that you specified the token to be used correctly in the configuration.");
}
break;
}
}
private async makeRequest<T>(requestFunction: () => Promise<T>): Promise<T> {
var maxRequestTryCount: number = 5;
var maxWaitingTime: number = 64;
for (var requestCount: number = 0; requestCount < maxRequestTryCount; requestCount++) {
try {
return await requestFunction();
} catch (error) {
console.log(`Error during request (${requestCount + 1}/${maxRequestTryCount})`);
console.log(`Error message: ${error}`);
if (requestCount < maxRequestTryCount - 1) {
var waitTimeInSeconds: number = Math.pow(2, requestCount);
if (waitTimeInSeconds > maxWaitingTime) {
waitTimeInSeconds = maxWaitingTime;
}
console.log(`Will wait ${waitTimeInSeconds} seconds before retrying request...`);
await this.genralFunctions.sleep(waitTimeInSeconds * 1000);
}
}
}
throw new Error(`Request failed after ${maxRequestTryCount} tries - see error messages in the log`);
}
}