Source: index.js

/**
 * @copyright Copyright 2017-2019 Kevin Locke <kevin@kevinlocke.name>
 * @license MIT
 * @module appveyor-status
 */

'use strict';

const SwaggerClient = require('swagger-client');
const appveyorSwagger = require('appveyor-swagger');
const https = require('https');
const nodeify = require('promise-nodeify');

const gitUtils = require('./lib/git-utils');
const appveyorUtils = require('./lib/appveyor-utils');
const CommitMismatchError = require('./lib/commit-mismatch-error');
const AmbiguousProjectError = require('./lib/ambiguous-project-error');

/** Multiplicative increase in delay between retries.
 *
 * @constant
 * @private
 */
const RETRY_DELAY_FACTOR_MS = 2;

/** Minimum/Initial delay between retries (in milliseconds).
 *
 * @constant
 * @private
 */
const RETRY_DELAY_MIN_MS = 4000;

/** Maximum delay between retries (in milliseconds).
 *
 * @constant
 * @private
 */
const RETRY_DELAY_MAX_MS = 60000;

/** Shallow, strict equality of properties in common between two objects.
 *
 * @param {!object} obj1 Object to compare.
 * @param {!object} obj2 Object to compare.
 * @returns {boolean} <code>true</code> if the own-properties in common between
 * <code>obj1</code> and <code>obj2</code> are strictly equal.
 * @private
 */
function shallowStrictCommonEqual(obj1, obj2) {
  return Object.keys(obj1)
    .every((key) => !hasOwnProperty.call(obj2, key) || obj1[key] === obj2[key]);
}

/** Gets JSON body of a SwaggerClient response as an Object.
 *
 * @param {!object} response SwaggerClient response object.
 * @returns {!object} JSON-decoded body of response.
 * @throws {Error} If the response does not contain JSON or can not be decoded.
 * @private
 */
function getResponseJson(response) {
  if (response.obj === null
      || response.obj === undefined
      || response.obj === response.data) {
    try {
      response.obj = JSON.parse(response.data);
    } catch (errJson) {
      const err = new Error(`Unable to parse JSON from ${response.method} `
        + `${response.url} with Content-Type `
        + `${response.headers['content-type']}: ${errJson.message}`);
      err.cause = errJson;
      throw err;
    }
  }

  return response.obj;
}

/** Gets SVG body of a SwaggerClient response as a string.
 *
 * @param {!object} response SwaggerClient response object.
 * @returns {string} SVG body of response.
 * @throws {Error} If the response does not contain SVG.
 * @private
 */
function getResponseSvg(response) {
  const contentType =
    (response.headers['content-type'] || '(none)').toLowerCase();
  const svgType = 'image/svg+xml';
  if (contentType.lastIndexOf(svgType, 0) !== 0) {
    throw new Error(`Expected ${svgType} got ${contentType}`);
  }

  return response.data.toString();
}

/** Makes a function to catch SwaggerClient errors from operations and add
 * additional information.
 *
 * @param {string} msgPrefix String to be prepended to error message.
 * @returns {function(object): Promise} Function which creates an Error from the
 * SwaggerClient result object and returns a rejected Promise with the error.
 * @private
 */
function makeClientErrorHandler(msgPrefix) {
  return (err) => {
    const res = err.response;

    err.message = msgPrefix + err.message;

    if (res && res.status) {
      err.message += ` (${res.status})`;
    }

    // Add server error in message property of response body JSON, if present
    const apiMessage = res && res.body && res.body.message;
    if (apiMessage) {
      err.message += `: ${apiMessage}`;
    }

    // Set SuperAgent-like Error properties for API backwards-compatibility
    if (res) {
      // Note:  .status and .statusCode set by SwaggerClient
      err.body = res.body;
      err.text = res.text;
      err.method = 'GET';
      const resUrl = new URL(res.url);
      err.path = resUrl.pathname + resUrl.query;
    }

    throw err;
  };
}

/** Options for {@link appveyorStatus} functions.
 *
 * @static
 * @typedef {{
 *   agent: module:http.Agent|undefined,
 *   appveyorClient: SwaggerClient|Promise<SwaggerClient>|undefined,
 *   branch: string|boolean|undefined,
 *   commit: string|undefined,
 *   err: module:stream.Writable|undefined,
 *   out: module:stream.Writable|undefined,
 *   project: string|undefined,
 *   repo: string|undefined,
 *   statusBadgeId: string|undefined,
 *   token: string|undefined,
 *   verbosity: number|undefined,
 *   wait: boolean|number|undefined,
 *   webhookId: string|undefined
 * }} AppveyorStatusOptions
 * @property {module:http.Agent=} agent Agent to use for HTTP requests (useful
 * for inter-call keep-alive and request sharing) (ignored if appveyorClient
 * is set).
 * @property {(SwaggerClient|Promise<SwaggerClient>)=} appveyorClient client
 * used to query the AppVeyor API.
 * @property {(string|boolean)=} branch query latest build for named branch,
 * or the current branch
 * @property {string=} commit require build to be for a specific commit.
 * Named commits are resolved in <code>options.repo</code> or current dir.
 * (requires token or project)
 * @property {module:stream.Writable=} err Stream to which errors (and
 * non-output status messages) are written.
 * (default: <code>process.stderr</code>)
 * @property {(string|appveyorSwagger.Project)=} project AppVeyor project to
 * query (default: auto-detect) (exclusive with repo, statusBadgeId, and
 * webhookId)
 * @property {string=} repo repository to query (as
 * {bitbucket,github}/$user/$proj) (default: auto-detect)
 * (exclusive with project, statusBadgeId, and webhookId)
 * @property {string=} statusBadgeId Status badge ID to query
 * (exclusive with project, repo, and webhookId)
 * @property {string=} token AppVeyor API access token.
 * @property {number=} verbosity Amount of diagnostic information to print
 * (0 is default, larger yields more output).
 * @property {number=} wait Length of time to wait (in milliseconds) for build
 * to complete.  If wait time is reached, incomplete build is returned.
 * (default: no polling)
 * @property {string=} webhookId *Deprecated* Webhook ID to query.  The
 * webhookId has been replaced by statusBadgeId as the path parameter in the
 * status badge URL.  This name is kept for backwards-compatibility only.
 * (default: auto-detect) (exclusive with project, statusBadgeId, and repo)
 */
// var AppveyorStatusOptions;

/** Checks and canonicalizes a caller-provided options object so that it
 * contains required information in the expected form then calls the API
 * function.
 *
 * @template T
 * @param {module:appveyor-status.AppveyorStatusOptions=} options
 * Caller-provided options.
 * @param {function(!module:appveyor-status.AppveyorStatusOptions): !Promise<T>}
 * apiFunc Function to
 * call with canonicalized <code>options</code>.
 * @returns {!Promise<T>} Return value from <code>apiFunc</code>.
 * @throws {Error} If <code>options</code> is invalid, inconsistent, or can not
 * be canonicalized.
 * @private
 */
function canonicalizeOptions(options, apiFunc) {
  if (options !== undefined && typeof options !== 'object') {
    throw new TypeError('options must be an object');
  }

  if (options) {
    const projectOpts = ['project', 'repo', 'statusBadgeId', 'webhookId']
      .filter((propName) => options[propName]);
    if (projectOpts.length > 1) {
      throw new Error(`${projectOpts.join(' and ')}`
                      + ' can not be specified together');
    }
  }

  options = { ...options };

  options.err = options.err || process.stderr;
  if (!options.err || typeof options.err.write !== 'function') {
    throw new TypeError('options.err must be a stream.Writable');
  }

  options.wait = options.wait === true ? Infinity : Number(options.wait || 0);
  if (Number.isNaN(options.wait)) {
    throw new TypeError('options.wait must be a number');
  }
  if (options.wait < 0) {
    throw new RangeError('options.wait must be non-negative');
  }

  if (typeof options.project === 'string') {
    options.project = appveyorUtils.projectFromString(options.project);
  } else if (options.project
             && (!options.project.accountName || !options.project.slug)) {
    throw new Error('options.project must have accountName and slug');
  }

  const gitOptions = {};
  if (options.repo && gitUtils.gitUrlIsLocalNotSsh(options.repo)) {
    gitOptions.cwd = options.repo;
  }

  // If project, repo, statusBadgeId, & webhookId are unspecified, use work dir
  if (!options.project
      && !options.repo
      && !options.statusBadgeId
      && !options.webhookId) {
    options.repo = '.';
  }

  const branchP = options.branch === true ? gitUtils.getBranch(gitOptions)
    : options.branch ? Promise.resolve(options.branch)
      : undefined;

  let remoteUrlP;
  if (options.repo && gitUtils.gitUrlIsLocalNotSsh(options.repo)) {
    // Use user-requested branch with default of current branch
    const branchForRemoteP = branchP || gitUtils.getBranch(gitOptions);
    remoteUrlP = branchForRemoteP
      .then((branch) => gitUtils.getRemote(branch, gitOptions))
      .catch((err) => {
        if (options.verbosity > 0) {
          options.err.write(`DEBUG: Unable to get remote: ${err}\n`
                            + 'DEBUG: Will try to use origin remote.\n');
        }
        return 'origin';
      })
      .then((remote) => gitUtils.getRemoteUrl(remote, gitOptions));
  }

  let appveyorClientP = options.appveyorClient;
  let newAgent;
  if (!appveyorClientP) {
    const appveyorClientOptions = {
      connectionAgent: options.agent,
      spec: appveyorSwagger,
    };

    // If unspecified by caller, use an HTTP Agent with keep-alive enabled for
    // requests to avoid reconnection overhead and reduce latency for multiple
    // API calls.
    if (options.agent === undefined || options.agent === null) {
      newAgent = new https.Agent({ keepAlive: true });
      appveyorClientOptions.connectionAgent = newAgent;
    }

    if (options.token) {
      appveyorClientOptions.authorizations = {
        apiToken: `Bearer ${options.token}`,
      };
    }
    // Note: The constructor returns a Promise for the SwaggerClient rather
    // than the SwaggerClient instance.
    appveyorClientP = new SwaggerClient(appveyorClientOptions);
  }

  let commitP;
  if (options.commit) {
    commitP = /^[0-9a-f]{40}$/i.test(options.commit)
      ? Promise.resolve(options.commit.toLowerCase())
      : gitUtils.resolveCommit(options.commit, gitOptions);
  }

  let resultP = Promise.all([
    appveyorClientP,
    branchP,
    commitP,
    remoteUrlP || options.repo,
  ])
    .then(([appveyorClient, branch, commit, repo]) => {
      options.appveyorClient = appveyorClient;
      options.branch = branch;
      options.commit = commit;
      options.repo = repo;

      return apiFunc(options);
    });

  if (newAgent) {
    // Avoid holding connections open when caller does not expect it.
    resultP = resultP.finally(() => { newAgent.destroy(); });
  }

  return resultP;
}

/** Wraps a function exposed as part of the module API with argument checking,
 * option canonicalization, and callback support.
 *
 * @template T
 * @param {function(module:appveyor-status.AppveyorStatusOptions=,
 * function(Error, T=)): Promise<T>} apiFunc API function to wrap.
 * @returns {function(module:appveyor-status.AppveyorStatusOptions=,
 * function(Error, T=)): Promise<T>} Function which calls
 * {@link canonicalizeOptions} with its argument and
 * <code>apiFunc</code>.
 * @throws {TypeError} If callback argument passed to wrapped function is not
 * a function.
 * @private
 */
function wrapApiFunc(apiFunc) {
  return function apiFunctionWrapper(options, callback) {
    if (!callback && typeof options === 'function') {
      callback = options;
      options = undefined;
    }

    if (callback && typeof callback !== 'function') {
      throw new TypeError('callback must be a function');
    }

    let resultP;
    try {
      resultP = canonicalizeOptions(options, apiFunc);
    } catch (err) {
      resultP = Promise.reject(err);
    }
    return nodeify(resultP, callback);
  };
}

/** Gets the last build and checks that the commit matches
 * <code>options.commit</code>, ignores <code>options.wait</code>.
 *
 * @param {!module:appveyor-status.AppveyorStatusOptions} options Options.
 * @returns {Promise<!appveyorSwagger.ProjectBuild>} The AppVeyor last build
 * or an error if the build can not be fetched or does not match
 * <code>options.commit</code>.
 * @private
 */
function getLastBuildNoWait(options) {
  let lastBuildP;
  const buildFromProject = options.project.builds && options.project.builds[0];
  if (options.useProjectBuilds
      && buildFromProject
      && (!options.branch || options.branch === buildFromProject.branch)) {
    lastBuildP = Promise.resolve({
      project: options.project,
      build: buildFromProject,
    });
  } else {
    const params = {
      accountName: options.project.accountName,
      projectSlug: options.project.slug,
    };

    const client = options.appveyorClient;
    let responseP;
    if (options.branch) {
      params.buildBranch = options.branch;
      responseP = client.apis.Project.getProjectLastBuildBranch(params);
    } else {
      responseP = client.apis.Project.getProjectLastBuild(params);
    }

    lastBuildP = responseP
      .catch(makeClientErrorHandler('Unable to get last project build: '))
      .then(getResponseJson);
  }

  let checkedLastBuildP;
  if (options.commit) {
    checkedLastBuildP = lastBuildP.then((projectBuild) => {
      if (projectBuild.build.commitId !== options.commit) {
        const err = new CommitMismatchError({
          actual: projectBuild.build.commitId,
          expected: options.commit,
        });
        err.build = projectBuild.build;
        err.project = projectBuild.project;
        throw err;
      }
      return projectBuild;
    });
  } else {
    checkedLastBuildP = lastBuildP;
  }

  return checkedLastBuildP;
}

/** Implements {@link getLastBuild} for options with non-null .project.
 *
 * @private
 * @param {!module:appveyor-status.AppveyorStatusOptions} options Options
 * object with non-null <code>.project</code>.
 * @returns {!Promise<!appveyorSwagger.ProjectBuild>} Last AppVeyor build for
 * project.
 */
function getLastBuildForProject(options) {
  if (!options.wait) {
    return getLastBuildNoWait(options);
  }

  const deadline = Date.now() + options.wait;

  function checkRetry(projectBuild, prevDelay) {
    const buildStatus = appveyorUtils.projectBuildToStatus(projectBuild);
    if (!['cancelling', 'queued', 'running'].includes(buildStatus)) {
      return projectBuild;
    }

    const remaining = deadline - Date.now();
    // Note:  If options.wait < RETRY_DELAY_MIN_MS, honor it
    if (remaining < Math.min(RETRY_DELAY_MIN_MS, options.wait)) {
      return projectBuild;
    }

    const delay = Math.min(
      prevDelay * RETRY_DELAY_FACTOR_MS,
      remaining,
      RETRY_DELAY_MAX_MS,
    );

    if (options.verbosity > 0) {
      options.err.write(
        'DEBUG: AppVeyor build queued.  '
        + `Waiting ${delay / 1000} seconds before retrying...\n`,
      );
    }

    return new Promise((resolve) => {
      setTimeout(() => {
        // Do not use options.project.builds after waiting
        delete options.useProjectBuilds;

        resolve(getLastBuildNoWait(options)
          .then((result) => checkRetry(result, delay)));
      }, delay);
    });
  }

  return getLastBuildNoWait(options)
    .then((result) => {
      const seedDelay = RETRY_DELAY_MIN_MS / RETRY_DELAY_FACTOR_MS;
      return checkRetry(result, seedDelay);
    });
}

/** Gets the AppVeyor project which matches the given options.
 *
 * @param {!object} options Options, which must include .repo.
 * @returns {!Promise<!appveyorSwagger.Project>} AppVeyor project with the
 * same repository or statusBadgeId as <code>options</code> or an Error if
 * there is no single project which matches or another error occurs.
 * @private
 */
function getMatchingProject(options) {
  // Parse early to avoid delay on error
  const avRepo = appveyorUtils.parseAppveyorRepoUrl(options.repo);

  return options.appveyorClient.apis.Project.getProjects()
    .catch(makeClientErrorHandler('Unable to get projects: '))
    .then(getResponseJson)
    .then((projects) => {
      const repoProjects = projects.filter(
        (project) => shallowStrictCommonEqual(avRepo, project),
      );

      if (repoProjects.length === 0) {
        throw new Error('No AppVeyor projects matching '
                        + `${JSON.stringify(avRepo)}`);
      } else if (repoProjects.length > 1) {
        // Callers may want to handle this error specially, so make it usable
        const repoProjectStrs = repoProjects.map(appveyorUtils.projectToString);
        throw new AmbiguousProjectError(
          `Multiple AppVeyor projects matching ${JSON.stringify(avRepo)}: ${
            repoProjectStrs.join(', ')}`,
          repoProjectStrs,
        );
      }

      return repoProjects[0];
    });
}

/** Implements {@link getLastBuild}.
 *
 * @param {!module:appveyor-status.AppveyorStatusOptions} options Options.
 * @returns {!Promise<!appveyorSwagger.ProjectBuild>} Last AppVeyor build for
 * project matching <code>options</code>.
 * @private
 */
function getLastBuildInternal(options) {
  if (options.project) {
    return getLastBuildForProject(options);
  }

  if (!options.repo) {
    throw new Error('project or repo is required');
  }

  return getMatchingProject(options)
    .then((project) => {
      const optionsWithProject = { ...options };
      optionsWithProject.project = project;
      optionsWithProject.useProjectBuilds = true;
      return getLastBuildForProject(optionsWithProject);
    });
}

/** Gets the last AppVeyor build for a repo/branch.
 *
 * Errors include {@link module:appveyor-status.AmbiguousProjectError} if an
 * AppVeyor project was not uniquely matched by <code>options</code> and
 * {@link module:appveyor-status.CommitMismatchError} if
 * <code>commitId</code> in the last build did not match the hash of
 * <code>options.commit</code>.
 *
 * @function
 * @param {?module:appveyor-status.AppveyorStatusOptions=} options Options.
 * @param {?function(Error, object=)=} callback Callback function called
 * with the last build from the AppVeyor API, or an <code>Error</code> if it
 * could not be retrieved.
 * @returns {!Promise<!appveyorSwagger.ProjectBuild>|undefined} If
 * <code>callback</code> is not given, a <code>Promise</code> with the current
 * build information from the AppVeyor API, or <code>Error</code> if it could
 * not be retrieved.  Otherwise <code>undefined</code>.
 */
exports.getLastBuild = wrapApiFunc(getLastBuildInternal);

/** Implements {@link getStatusBadge}.
 *
 * @param {!module:appveyor-status.AppveyorStatusOptions} options Options.
 * @returns {!Promise<string>} The current SVG status badge.
 * @private
 */
function getStatusBadgeInternal(options) {
  if (!options.repo && !options.statusBadgeId && !options.webhookId) {
    // Note:  Could resolve project to either using getLastBuild(), but the
    // overhead is enough that it's better for the caller to do that if it is
    // what they really want to do.
    throw new Error('options.repo, statusBadgeId, or webhookId is required');
  }

  const params = {
    // Match badge labels to API Status enumeration
    failingText: 'failed',
    passingText: 'success',
    pendingText: 'queued',
    svg: true,
  };

  const client = options.appveyorClient;
  let responseP;
  if (options.statusBadgeId || options.webhookId) {
    params.statusBadgeId = options.statusBadgeId || options.webhookId;

    if (options.branch) {
      params.buildBranch = options.branch;
      responseP = client.apis.Project.getProjectBranchStatusBadge(params);
    } else {
      responseP = client.apis.Project.getProjectStatusBadge(params);
    }
  } else {
    Object.assign(params, appveyorUtils.repoUrlToBadgeParams(options.repo));
    if (options.branch) {
      params.branch = options.branch;
    }
    responseP = client.apis.Project.getPublicProjectStatusBadge(params);
  }

  return responseP
    .catch(makeClientErrorHandler('Unable to get project status badge: '))
    .then(getResponseSvg);
}

/** Gets the AppVeyor status badge for a repo/branch.
 *
 * @function
 * @param {?module:appveyor-status.AppveyorStatusOptions=} options Options.
 * {@link module:appveyor-status.AppveyorStatusOptions.commit} and
 * {@link module:appveyor-status.AppveyorStatusOptions.project}} are not
 * supported by this function.
 * @param {?function(Error, string=)=} callback Callback function called
 * with the SVG status badge from the AppVeyor API, or an <code>Error</code> if
 * it could not be retrieved.
 * @returns {!Promise<string>|undefined} If <code>callback</code> is not given,
 * a <code>Promise</code> with the current SVG status badge (as a string) from
 * the AppVeyor API, or <code>Error</code> if it could not be retrieved.
 * Otherwise <code>undefined</code>.
 */
exports.getStatusBadge = wrapApiFunc(getStatusBadgeInternal);

/** Implements {@link getStatus}.
 *
 * @param {!module:appveyor-status.AppveyorStatusOptions} options Options.
 * @returns {!Promise<string>} The current build status.
 * @private
 */
function getStatusInternal(options) {
  if (options.commit || options.project) {
    // If AppVeyor project is known or commit checking is required, get build
    return getLastBuildInternal(options)
      .then(appveyorUtils.projectBuildToStatus);
  }

  // Otherwise get the status badge (which can resolve repo and statusBadgeId in
  // single request without authentication)
  return getStatusBadgeInternal(options)
    .then(appveyorUtils.badgeToStatus);
}

/** Gets the current AppVeyor status of a repo/branch.
 *
 * Errors include {@link module:appveyor-status.AmbiguousProjectError} if an
 * AppVeyor project was not uniquely matched by <code>options</code> and
 * {@link module:appveyor-status.CommitMismatchError} if
 * <code>commitId</code> in the last build did not match the hash of
 * <code>options.commit</code>.
 *
 * @function
 * @param {?module:appveyor-status.AppveyorStatusOptions=} options Options.
 * @param {?function(Error, string=)=} callback Callback function called
 * with the current build status from the AppVeyor API, or an
 * <code>Error</code> if it could not be retrieved.
 * @returns {!Promise<string>|undefined} If <code>callback</code> is not given,
 * a <code>Promise</code> with the current build status from the AppVeyor
 * API, or <code>Error</code> if it could not be retrieved.  Otherwise
 * <code>undefined</code>.
 */
exports.getStatus = wrapApiFunc(getStatusInternal);

exports.AmbiguousProjectError = AmbiguousProjectError;
exports.CommitMismatchError = CommitMismatchError;