marcells/node-build-monitor

View on GitHub
app/services/Tfs.js

Summary

Maintainability
F
3 days
Test Coverage
const request = require('../requests');
const async = require('async');

/**
 * The service which provides build information by using the VSTS REST API
 *  v2.0. Although the naming of the variables suggest usage with VSTS, the
 *  code is compatible with both VSTS and TFS. The naming is simply the
 *  artifact of making the code generalised for both after initial usage
 *  with VSTS.
 * @public
 * @constructor
 * @see https://www.visualstudio.com/en-us/docs/integrate/api/build/overview
 */
function VSTSRestBuilds() {
  let basicAuth = null;
  let url = null;
  let project = null;
  let collection = null;
  let params = null;
  let includeQueued = null;
  let previousBuildsToGet = [];
  let apiVersion = null;
  let showBuildStep = null;
  
  /**
   * This object is the representation of resultFilter mentioned in the docs
   * @private
   * @see https://www.visualstudio.com/en-us/docs/integrate/api/build/builds
   */
  const resultFilter = Object.freeze({
    succeeded:          'succeeded',
    partiallySucceeded: 'partiallySucceeded',
    failed:             'failed',
    canceled:           'canceled'
  });
  
  /** This object is the representation of statusFilter mentioned in the docs
   * @private
   * @see https://www.visualstudio.com/en-us/docs/integrate/api/build/builds
   */
  const statusFilter = Object.freeze({
    inProgress: 'inProgress',
    completed:  'completed',
    cancelling: 'cancelling',
    postponed:  'postponed',
    notStarted: 'notStarted',
    all:        'all',
  });
  
  const timelineRecordState = Object.freeze({
    completed:  'completed',
    inProgress: 'inProgress',
    pending:    'pending'
  });
  
  /**
   * This object defines the color scheme used.
   * @private
   */
  const colorScheme = Object.freeze({
    succeeded:          'Green',
    partiallySucceeded: '#F8A800',
    failed:             'Red',
    canceled:           'Gray',
    inProgress:         '#0078D7',
    completed:          'Green',
    cancelling:         '#0078D7',
    postponed:          'Gray',
    notStarted:         'Gray',
    all:                'Gray'
  });
  
  /**
   * This object defines the compatable api versions that we are allowed to use
   * @private
   */
  const allowedAPIVersions = Object.freeze({
    '2.0':      '2.0',
    '4.1':      '4.1',
    undefined:  '2.0'
  });
  
  /**
   * @typedef {Object} Build
   * @property {string} definition Build definition id
   * @property {Date} startedAt Build start time
   * @property {Date} finishedAt Build finish time
   * @property {boolean} hasErrors Does the resulting build have errors?
   * @property {boolean} hasWarnings Did the build give some warnings?
   * @property {boolean} isRunning Is the build currently running?
   * @property {boolean} isQueued Is the build currently waiting in the queue?
   * @property {string} id Unique ID of the build
   * @property {string} number Build number
   * @property {string} project Name of the project
   * @property {string} reason Reason for building the project
   * @property {string} requestedFor Name of the Requester
   * @property {string} status The color to be used for displaying
   * @property {string} statusText The status of the build
   * @property {string} url URL of the project
   */
  
  /**
   * It is a node-style callback.
   * @callback buildsInfoRequestCallback
   * @param {Error|null} err It is an instance of Error
   * @param {Array<Build>} listOfBuilds It is an array of {@link Build}
   */
  
  /**
   * It exposes the API needed by the application to check the status of builds.
   * @name check
   * @function
   * @public
   * @instance
   * @memberOf VSTSRestBuilds
   * @param {buildsInfoRequestCallback} cb Callback which handles the
   *  requested build information
   */
  this.check = (callback) => {
    if (basicAuth && url && project) {
      getListOfBuilds(callback);
      return;
    }
    callback('incomplete configuration');
    return;
  };
  
  /**
   * @typedef {Object} VSTSRestBuildsConfiguration
   * @property {string} url VS Team Services account
   *  ({https://{account.visualstudio.com) or TFS server ({http://server:port}).
   * @property {string} project Team project ID or name
   * @property {string} queryparams Additional queryparams to filter the data
   *  and provide additional options
   * @property {string} username Username
   * @property {string} pat Personal Access Token with access to Builds
   *  information
   * @property {boolean} includeQueued Show queued builds
   * @property {string} apiVersion The api version to use
   * @property {boolean} showBuildStep Adds the current build step to the statusString of the build variable
   */
  
  /**
   * It exposes the API needed by the application to provide
   *  configuration parameters.
   * @name check
   * @function
   * @public
   * @instance
   * @memberOf VSTSRestBuilds
   * @param {VSTSRestBuildsConfiguration} config Configuration parameters
   */
  this.configure = (config) => {
    /**
     * It exposes the configuration passed to the
     * configure instance method.
     * @public
     * @instance
     */
    this.configuration = config;
    basicAuth = new Buffer(`${config.username}:${config.pat}`)
      .toString('base64');
    url = config.url;
    params = config.queryparams;
    project = config.project;
    collection = config.collection || 'DefaultCollection';
    includeQueued = config.includeQueued || false;
    apiVersion = allowedAPIVersions[config.apiVersion] || '2.0';
    showBuildStep = config.showBuildStep || false;
    
    
    console.log(config,apiVersion);
  };
  
  /**
   * @private
   * @param {buildsInfoRequestCallback} cb Callback which handles the
   *  requested build information
   */
  const getListOfBuilds = (callback) => {
    const apiUrl = `${url}/${collection}/${project}/_apis/build/builds?api-version=${apiVersion}${params}`;
    let options = {
      url : apiUrl,
      headers: {
        Authorization: `Basic ${basicAuth}`,
      },
    };
    
    // Set up our dependency tree, similar to angular
    // http://caolan.github.io/async/v2/docs.html#autoInject
    async.autoInject({
      // ### 1. Get the list of builds ###
      get_builds: (callback) => {
        request.makeRequest(options, (err, body) => {
          transformData(err, body, callback);
        });
      },
      // ### 2. Get any previous builds ###
      get_previous_builds: (get_builds, callback) => {
        // No builds to get ? then nothing to add
        if (previousBuildsToGet.length === 0) { callback(null); return; }
        getPreviousBuilds(get_builds, callback);
      },
      // ### 3. Merge our 2 arrays ###
      merge_builds: (get_builds, get_previous_builds, callback) => {
        let builds = [];
        if (get_previous_builds && get_previous_builds.shift()) {
          builds = [...new Set([...get_builds.shift(),...get_previous_builds.shift()])]; // Merge arrays, removing duplicates
        } else {
          builds = get_builds;
        }
        callback(null, builds);
      },
      // ### 4. Get the current build steps/stage for each build ###
      get_build_steps: (merge_builds, callback) => {
        // Only get the build step if we are allowed to
        if (!showBuildStep) { callback(null, merge_builds); return; }
        
        // http://caolan.github.io/async/v2/docs.html#map
        // async.map( collection, async function(collection), callback)
        async.map(merge_builds, getLatestBuildStep, (err, results) => {
          callback(null, results);
        });
      }
    }, (err, results) => {
      // Pass back to the monitor app
      callback(err, results.get_build_steps);
    });
    
    
    /**
     * Transforms the data received from the request to VSTS REST API
     * @private
     * @param {any} err If the value is truthy, it indicates an error has
     *  occurred.
     * @param {object} body It contains the response body from VSTS REST API
     * @param {buildsInfoRequestCallback} cb Callback which handles the
     *  requested build information
     * @return {null}
     */
    const transformData = (err, body, callback) => {
      if (err) {
        callback(err);
        return;
      }
      if (!(body && body.value)) {
        callback('No values found');
        return;
      }
      // Filter out any dummy empty objects
      const transformedData = body.value.map(transformer).filter((val) => { return Object.keys(val).length; });

      callback(null, transformedData);
    };
  
    /**
     * The function transforms the data from VSTS API to
     *  the accepted by callback
     * @name transformer
     * @private
     * @param {object} build individual build information object returned
     *  from API
     * @returns {Build} the object is in the format accepted by the application
     */
    const transformer = (build) => {
      let color = colorScheme[
        build.result ?
          resultFilter[build.result] :
          (build.status === statusFilter.notStarted ?
            statusFilter[statusFilter.notStarted] :
            statusFilter[statusFilter.inProgress]
          )
      ];

      let text = build.result ?
      build.result :
        (build.status === statusFilter.notStarted ?
          statusFilter.notStarted :
          statusFilter.inProgress
        );

      let webUrl = build._links ?
        (build._links.web ? build._links.web.href : build.url) :
        build.url;

      let getProjectName = build => {
        if (build.definition.project) return build.definition.project.name;
        if (build.project) return build.project.name;

        return null;
      };

      let result = {
        definition: build.definition.name,
        finishedAt: build.finishTime ? new Date(build.finishTime) : '',
        hasErrors: build.result === resultFilter.failed,
        hasWarnings: build.result === resultFilter.partiallySucceeded,
        id: build.id,
        isRunning: build.status === statusFilter.inProgress,
        isQueued: build.status === statusFilter.notStarted,
        number: build.buildNumber,
        project: getProjectName(build),
        queuedAt: build.queueTime ? new Date(build.queueTime) : new Date(),
        reason: build.reason,
        requestedFor: build.requestedFor ? build.requestedFor.displayName : '',
        startedAt: new Date(build.startTime),
        status: color,
        statusText: text,
        timeline: build._links.timeline ? build._links.timeline.href : '',
        url: webUrl
      };

      // Only show queued builds if we're told to
      if (result.isQueued && !includeQueued) {
        previousBuildsToGet.push(result);
        return {};  // Return a dummy empty object (that we will remove later)
      }

      return result;
    };
  };
  
  /**
   * This function makes an individual API call for each build we need
   *  to get the previous version for.
   * @private
   * @param {buildsInfoRequsetCallback} callback Callback which handles the
   *  requested build information. Each build *must* be returned in the callback,
   *  whether its been changed or not.
   */
  const getPreviousBuilds = (currentBuilds, callback) => {
    async.map(previousBuildsToGet, (build, callback) => {
      let def = build.definition;

      // If we already have a previous build, then we don't need to get another one
      const hasPreviousBuild = (val) => { return (val.definition === build.definition) && (val.project === build.project); };
      if (currentBuilds.some(hasPreviousBuild)) {
        callback(null);
        return;
      }

      // Get the second to last build instead
      const options = {
        url: `${url}/${collection}/${project}/_apis/build/builds?api-version=${apiVersion}&definitions=${def}&$top=2`
      };

      request.makeRequest(options, (err, body) => {
        if (err) { callback(err); return; }
        if (!(body && body.value)) {
          console.log('No previous builds found'); // Don't break the rest of the builds if we can't get a previous one
          callback(null);
          return;
         }

        let prevBuild = [body.value[1]];

        if (prevBuild) {
          const transformedData = prevBuild.map(transformer);
          callback(null, transformedData[0]);
          return;
        }

        console.log('Unable to fetch previous build');  // Don't break the rest of the builds if we can't get a previous one
        callback(null);

      });

    }, (err, results) => {
      callback(null, results);
      previousBuildsToGet = [];
    });
  };
  
  /**
   * This function gets the most recent timeline record (aka step) for a build
   * @private
   * @param {string} timelineURL the url to the timeline VSTS API
   */
  const getLatestBuildStep = (build, callback) => {
    const timelineURL = build.timeline;
    if (!timelineURL || timelineURL === '') {
      console.log('no timeline url');
      callback(null, build);
      return;
    }
    
    if (!build.isRunning) {
        // The build is already finished, no need to get the latest step
        callback(null, build);
        return;
    }
    
    const apiUrl = timelineURL;
    const options = {
      url : apiUrl,
      headers: {
        Authorization: `Basic ${basicAuth}`,
      },
    };
    
    request.makeRequest(options, (err, body) => {
      if (err) {
        console.log('getLatestBuildStep:', err);
        callback(null, build);
        return;
      }
      
      if (!(body && body.records)) {
        console.log('getLatestBuildStep invalid Body', body);
        callback(null, build);
        return;
      }
      
      // As of API version 2.0 there is no better way of doing this, we *have* to retrieve everything
      let records = body.records.sort( (a, b) => {
        return a.order - b.order;
      });
      
      for (let key in records) {
        let record = records[key];
        if (record.state === timelineRecordState.inProgress && record.type === 'Task') {
          build.statusText += ' - ' + record.name;
        }
      }
      
      // Return our modified build
      callback(null, build);
    });
  };
}

module.exports = VSTSRestBuilds;