18F/federalist

View on GitHub
api/services/GitHub.js

Summary

Maintainability
C
1 day
Test Coverage
A
95%
const { Octokit } = require('@octokit/rest');
const axios = require('axios');
const config = require('../../config');

const OAUTH_BASIC_AUTH = Buffer.from(`${config.passport.github.options.clientID}:${config.passport.github.options.clientSecret}`).toString('base64');

const createRepoForOrg = (github, options) => github.repos.createInOrg(options);

const createRepoForUser = (github, options) => github.repos.createForAuthenticatedUser(options);

const createWebhook = (github, options) => github.repos.createWebhook(options);

const deleteWebhook = (github, options) => github.repos.deleteWebhook(options);

const listWebhooks = (github, options) => github.repos.listWebhooks(options)
  .then(hooks => hooks.data);

const getOrganizations = github => github.orgs.listForAuthenticatedUser().then(orgs => orgs.data);

const getRepository = (github, options) => github.repos.get(options).then(repos => repos.data);

const getContent = (github, options) => github.repos.getContent(options);

const getBranch = (github, { owner, repo, branch }) => github.repos
  .getBranch({ owner, repo, branch })
  .then(branchInfo => branchInfo.data);

const githubClient = async accessToken => new Octokit({ auth: accessToken });

const parseGithubErrorMessage = (error) => {
  let githubError = 'Encountered an unexpected GitHub error';

  try {
    githubError = error.response.data.errors[0].message;
  } catch (e) {
    try {
      githubError = error.errors[0].message;
    } catch (e2) {
      try {
        githubError = error.message;
      } catch (e3) { /* noop */ }
    }
  }

  return githubError;
};

const handleCreateRepoError = (err) => {
  const error = err;

  const REPO_EXISTS_MESSAGE = 'name already exists on this account';

  const githubError = parseGithubErrorMessage(error);

  if (githubError === REPO_EXISTS_MESSAGE) {
    error.status = 400;
    error.message = 'A repo with that name already exists.';
  } else if (githubError && error.status === 403) {
    error.status = 400;
    error.message = githubError;
  }

  throw error;
};

const handleWebhookError = (err) => {
  const error = err;
  const HOOK_EXISTS_MESSAGE = 'Hook already exists on this repository';
  const NO_ACCESS_MESSAGE = 'Not Found';
  const NO_ADMIN_ACCESS_ERROR_MESSAGE = 'You do not have admin access to this repository';

  const githubError = parseGithubErrorMessage(error);

  if (githubError === HOOK_EXISTS_MESSAGE) {
    // noop
  } else if (githubError === NO_ACCESS_MESSAGE) {
    const adminAccessError = new Error(NO_ADMIN_ACCESS_ERROR_MESSAGE);
    adminAccessError.status = 400;
    throw adminAccessError;
  } else {
    throw error;
  }
};

const ignore404 = (error) => {
  if (error.status !== 404) {
    throw error;
  }
  return null;
};

const sendNextCreateGithubStatusRequest = (github, options) => github.repos.createCommitStatus(
  options
);

const sendCreateGithubStatusRequest = (github, options, attempt = 0) => {
  const maxTries = 5;
  return sendNextCreateGithubStatusRequest(github, options)
    .catch((err) => {
      attempt += 1; // eslint-disable-line no-param-reassign
      if (attempt < maxTries) {
        return sendCreateGithubStatusRequest(github, options, attempt);
      }
      throw err;
    });
};

const getOrganizationMembers = (github, org, role = 'all', page = 1) => github.orgs.listMembers({
  org, per_page: 100, page, role,
})
  .then(orgs => orgs.data);

function getNextOrganizationMembers(github, org, role = 'all', { page = 1, allMembers = [] } = {}) {
  return getOrganizationMembers(github, org, role, page)
    .then((members) => {
      if (members.length > 0) {
        allMembers = allMembers.concat(members); // eslint-disable-line no-param-reassign
        return getNextOrganizationMembers(github, org, role, { page: page + 1, allMembers });
      }
      return allMembers;
    });
}

/* eslint-disable camelcase */
const getTeamMembers = (github, org, team_slug, page = 1) => github.teams
  .listMembersInOrg({
    org, team_slug, per_page: 100, page,
  })
  .then(teams => teams.data);

function getNextTeamMembers(github, org, team_slug, page = 1, allMembers = []) {
  return getTeamMembers(github, org, team_slug, page)
    .then((members) => {
      if (members.length > 0) {
        allMembers = allMembers.concat(members); // eslint-disable-line no-param-reassign
        return getNextTeamMembers(github, org, team_slug, page + 1, allMembers);
      }
      return allMembers;
    });
}
/* eslint-enable camelcase */

const removeOrganizationMember = (github, org, username) => github.orgs
  .removeMember({ org, username });

const getRepositories = (github, page = 1) => github.repos.listForAuthenticatedUser({
  per_page: 100, page,
})
  .then(repos => repos.data);

const getNextRepositories = (github, page = 1, allRepos = []) => getRepositories(github, page)
  .then((repos) => {
    if (repos.length > 0) {
      allRepos = allRepos.concat(repos); // eslint-disable-line no-param-reassign
      return getNextRepositories(github, page + 1, allRepos);
    }
    return allRepos;
  });

const getCollaborators = (github, owner, repo, page = 1) => github.repos.listCollaborators({
  owner, repo, per_page: 100, page,
})
  .then(collabs => collabs.data);

function getNextCollaborators(github, owner, repo, { page = 1, allCollabs = [] } = {}) {
  return getCollaborators(github, owner, repo, page)
    .then((collabs) => {
      if (collabs.length > 0) {
        allCollabs = allCollabs.concat(collabs); // eslint-disable-line no-param-reassign
        return getNextCollaborators(github, owner, repo, { page: page + 1, allCollabs });
      }
      return allCollabs;
    });
}

async function findWebhookId(github, site) {
  const { owner, repository: repo } = site;

  const { appEnv } = config.app;

  // Hardcoded for staging and production since:
  // - only Federalist webhooks will be missing webhook Ids
  // - webhooks will only be present for production and staging environments
  // - this isn't configured by environment bc if this is Pages, we still want to remove the
  //   Federalist webhook and Pages webhooks will be stored in the site model so this function
  //   will not be called.
  const hookUrl = {
    production: 'https://federalistapp.18f.gov/webhook/github',
    staging: 'https://federalistapp-staging.18f.gov/webhook/github',
  }[appEnv] || config.webhook.endpoint;

  const hooks = (await listWebhooks(github, { owner, repo }).catch(ignore404)) || [];

  const federalistHook = hooks.find(hook => hook.config.url === hookUrl);

  return federalistHook?.config.id;
}

module.exports = {
  checkPermissions: async (user, owner, repo) => {
    const github = await githubClient(user.githubAccessToken);
    const repository = await getRepository(github, { owner, repo, username: user.username });
    return repository.permissions;
  },

  checkOrganizations: (user, orgName) => githubClient(user.githubAccessToken)
    .then(github => getOrganizations(github))
    .then(orgs => orgs.some(org => org.login.toLowerCase() === orgName)),

  createRepo: (user, owner, repository) => githubClient(user.githubAccessToken)
    .then((github) => {
      if (user.username.toLowerCase() === owner.toLowerCase()) {
        return createRepoForUser(github, {
          name: repository,
        });
      }

      return createRepoForOrg(github, {
        name: repository,
        org: owner,
      });
    })
    .catch(handleCreateRepoError),

  createRepoFromTemplate: (user, owner, name, template) => githubClient(user.githubAccessToken)
    .then((github) => {
      const params = {
        template_owner: template.owner,
        template_repo: template.repo,
        name,
      };

      if (user.username.toLowerCase() !== owner.toLowerCase()) {
        params.owner = owner;
      }

      return github.repos.createUsingTemplate(params);
    })
    .catch(handleCreateRepoError),

  getRepository: (user, owner, repo) => githubClient(user.githubAccessToken)
    .then(github => getRepository(github, { owner, repo }))
    .catch((err) => {
      if (err.status === 404) {
        return null;
      }
      throw err;
    }),

  getBranch: (user, owner, repo, branch) => githubClient(user.githubAccessToken)
    .then(github => getBranch(github, { owner, repo, branch }))
    .catch((err) => {
      if (err.status === 404) {
        return null;
      }
      throw err;
    }),

  revokeApplicationGrant: (user) => {
    const url = `https://api.github.com/applications/${config.passport.github.options.clientID}/grant`;
    const data = {
      access_token: user.githubAccessToken,
    };
    const headers = { authorization: `Basic ${OAUTH_BASIC_AUTH}` };

    return axios({
      method: 'delete',
      url,
      data,
      headers,
    }).catch((err) => {
      if (err.response.status !== 404) {
        throw err;
      }
    });
  },

  setWebhook: (site, githubAccessToken) => githubClient(githubAccessToken)
    .then(github => createWebhook(github, {
      owner: site.owner,
      repo: site.repository,
      name: 'web',
      active: true,
      config: {
        url: config.webhook.endpoint,
        secret: config.webhook.secret,
        content_type: 'json',
      },
    }))
    .catch(handleWebhookError),

  listSiteWebhooks: async (site, githubAccessToken) => {
    const github = await githubClient(githubAccessToken);
    const { owner, repository: repo } = site;

    return (await listWebhooks(github, { owner, repo }).catch(ignore404)) || [];
  },

  deleteWebhook: async (site, githubAccessToken) => {
    const github = await githubClient(githubAccessToken);

    const webhookId = site.webhookId || await findWebhookId(github, site);

    if (!webhookId) {
      return null;
    }

    const { owner, repository: repo } = site;

    return deleteWebhook(github, { owner, repo, hook_id: webhookId })
      .catch(ignore404);
  },

  validateUser: (accessToken, throwOnUnauthorized = true) => {
    const approvedOrgs = config.passport.github.organizations || [];

    return githubClient(accessToken)
      .then(github => getOrganizations(github))
      .then((organizations) => {
        const approvedOrg = organizations
          .find(organization => approvedOrgs.indexOf(organization.id) >= 0);

        if (!approvedOrg && throwOnUnauthorized) {
          throw new Error('Unauthorized');
        }

        return !!approvedOrg;
      });
  },

  ensureFederalistAdmin: (accessToken, username) => githubClient(accessToken)
    .then(github => github.teams.getMembershipForUserInOrg({
      org: config.admin.org,
      team_slug: config.admin.team,
      username,
    }))
    .then(({ data: { state, role } }) => {
      if (state !== 'active' || !['member', 'maintainer'].includes(role)) {
        throw new Error(`You are not a ${config.app.appName} admin.`);
      }
    }),

  sendCreateGithubStatusRequest: (accessToken, options) => githubClient(accessToken)
    .then(github => sendCreateGithubStatusRequest(github, options)),

  getOrganizationMembers: (accessToken, organization, role = 'all') => githubClient(accessToken)
    .then(github => getNextOrganizationMembers(github, organization, role)),

  getTeamMembers: (accessToken, org, teamSlug) => githubClient(accessToken)
    .then(github => getNextTeamMembers(github, org, teamSlug)),

  removeOrganizationMember: (accessToken, organization, member) => githubClient(accessToken)
    .then(github => removeOrganizationMember(github, organization, member))
    .catch((err) => {
      if (err.status === 404) {
        return null;
      }
      throw err;
    }),

  getRepositories: accessToken => githubClient(accessToken)
    .then(github => getNextRepositories(github)),

  getCollaborators: (accessToken, owner, repo) => githubClient(accessToken)
    .then(github => getNextCollaborators(github, owner, repo)),

  getContent: async (accessToken, owner, repo, path, ref = null) => {
    try {
      const github = await githubClient(accessToken);
      const options = { owner, repo, path };
      if (ref) {
        options.ref = ref;
      }
      const { data } = await getContent(github, options);
      if (data.type === 'file') { // return file body
        const { content, encoding } = data;
        return Buffer.from(content, encoding).toString('utf8');
      }
      return data; // return folder/files
    } catch (err) {
      if (err.status === 404) {
        return null;
      }
      throw err;
    }
  },
};