200ok-ch/organice

View on GitHub
src/sync_backend_clients/gitlab_sync_backend_client.js

Summary

Maintainability
A
1 hr
Test Coverage
/* global process */
import { OAuth2AuthCodePKCE } from '@bity/oauth2-auth-code-pkce';
import { orgFileExtensions } from '../lib/org_utils';
import { getPersistedField } from '../util/settings_persister';

import { fromJS, Map } from 'immutable';

export const createGitlabOAuth = () => {
  // Use promises as mutex to prevent concurrent token refresh attempts, which causes problems.
  // More info: https://github.com/BitySA/oauth2-auth-code-pkce/issues/29
  // TODO: remove this workaround if/when oauth2-auth-code-pkce fixes the issue.
  let expiryPromise;
  let invalidGrantPromise;
  return new OAuth2AuthCodePKCE({
    authorizationUrl: 'https://gitlab.com/oauth/authorize',
    tokenUrl: 'https://gitlab.com/oauth/token',
    clientId: process.env.REACT_APP_GITLAB_CLIENT_ID,
    redirectUrl: window.location.origin,
    scopes: ['api'],
    extraAuthorizationParams: {
      clientSecret: process.env.REACT_APP_GITLAB_SECRET,
    },
    onAccessTokenExpiry: async (refreshToken) => {
      if (!expiryPromise) {
        expiryPromise = refreshToken();
      }
      const result = await expiryPromise;
      expiryPromise = undefined;
      return result;
    },
    onInvalidGrant: async (refreshAuthCodeOrToken) => {
      if (!invalidGrantPromise) {
        invalidGrantPromise = refreshAuthCodeOrToken();
      }
      // This is a void promise, so don't need to return the result. Refer to the TypeScript source
      // of OAuth2AuthCodePKCE. Types are great.
      await invalidGrantPromise;
      invalidGrantPromise = undefined;
    },
  });
};

/**
 * Convert project URL to identifier for use with API, since explaining how to find the ID of a
 * project is unnecessarily confusing for users.
 *
 * @see https://docs.gitlab.com/ee/api/projects.html#get-single-project
 * @param {string} projectURL Full URL for project, such as gitlab.com/foo/bar/baz. Leading https:// is
 * optional.
 * @returns {string|undefined} Project ID if `projectURL` appears to be a gitlab.com project and
 * parsing succeeded, otherwise undefined.
 */
export const gitLabProjectIdFromURL = (projectURL) => {
  if (!projectURL) return;

  if (!projectURL.includes('://')) {
    // URL() class requires protocol.
    projectURL = `https://${projectURL}`;
  }
  try {
    const url = new URL(projectURL);
    const path = url.pathname.replace(/(^\/)|(\/$)/g, '');
    // Rough heuristic to check that url at least *potentially* refers
    // to a project. Reminder: a project path is not necessarily
    // /user/project because it may be under one or more groups such
    // as /user/group/subgroup/project.
    if (url.hostname === 'gitlab.com' && path.split('/').length > 1) {
      return encodeURIComponent(path);
    } else {
      return undefined;
    }
  } catch (e) {
    console.error('Error trying to get gitLab project_id from URL:');
    console.error(e);
    return undefined;
  }
};

/**
 * Parse 'link' pagination response header.
 *
 * @see https://docs.gitlab.com/ee/api/index.html#keyset-based-pagination
 * @param {string|null} links raw header value
 * @return {Object.<string, string>} Key-value mapping of link name to url. Empty object if none.
 */
export const parseLinkHeader = (links) => {
  if (!links) {
    return {};
  }
  // Based on https://stackoverflow.com/a/48109741
  return links.split(',').reduce((acc, link) => {
    const match = link.match(/<(.*)>; rel="(\w*)"/);
    const url = match[1];
    const rel = match[2];
    acc[rel] = url;
    return acc;
  }, {});
};

/**
 * Converts response from GitLab's list repo tree API into organice format.
 *
 * @see https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree
 */
export const treeToDirectoryListing = (tree) => {
  const isDirectory = (it) => it.type === 'tree';
  return fromJS(
    tree
      .filter((it) => isDirectory(it) || it.name.match(orgFileExtensions))
      .map((it) => ({
        id: it.id,
        name: it.name,
        // Organice requires a leading "/", whereas GitLab API doesn't
        // use one.
        path: `/${it.path}`,
        isDirectory: isDirectory(it),
      }))
      .sort((a, b) => {
        // Folders first.
        if (a.isDirectory && !b.isDirectory) {
          return -1;
        } else if (!a.isDirectory && b.isDirectory) {
          return 1;
        } else {
          // Can't have same name, so don't need to check if
          // equal/return 0.
          return a.name > b.name ? 1 : -1;
        }
      })
  );
};

const API_URL = 'https://gitlab.com/api/v4';

/**
 * GitLab sync backend, implemented using their REST API.
 *
 * @see https://docs.gitlab.com/ee/api/api_resources.html
 * @param {OAuth2AuthCodePKCE} oauthClient
 */
export default (oauthClient) => {
  const decoratedFetch = oauthClient.decorateFetchHTTPClient(fetch);

  const getProjectApi = () => `${API_URL}/projects/${getPersistedField('gitLabProject')}`;

  const isSignedIn = async () => {
    if (!oauthClient.isAuthorized()) {
      return false;
    }
    // Verify that we have an OAuth token (and refresh if needed).
    // Don't care about return value, because the library handles
    // persisting for us.
    try {
      await oauthClient.getAccessToken();
      return true;
    } catch (e) {
      console.error('Error trying to get OAuth access token.');
      console.error(e);
      return false;
    }
  };

  /**
   * Check that project exists and user is *probably* able to commit
   * to it. This doesn't take branch protection into consideration, so
   * it's not perfect... but who uses protected branches for the org
   * files?
   *
   * This is separate from `isSignedIn` to avoid the overhead of
   * multiple API calls every time the page is loaded.
   */
  const isProjectAccessible = async () => {
    // Check project exists and user is a member who can *probably*
    // commit.
    const [userResponse, membersResponse] = await Promise.all([
      // https://docs.gitlab.com/ee/api/users.html#list-current-user-for-normal-users
      decoratedFetch(`${API_URL}/user`),
      // https://docs.gitlab.com/ee/api/members.html#list-all-members-of-a-group-or-project
      decoratedFetch(`${getProjectApi()}/members`),
    ]);
    if (!userResponse.ok || !membersResponse.ok) {
      return false;
    }
    const [user, members] = await Promise.all([userResponse.json(), membersResponse.json()]);
    const matched = members.find((m) => m.id === user.id);
    // Access levels:
    // https://docs.gitlab.com/ee/api/members.html#valid-access-levels
    // Permissions:
    // https://docs.gitlab.com/ee/user/permissions.html#project-members-permissions
    // 30 is developer, which is the minimum to commit to a
    // non-protected branch. If branch is protected then they still
    // might be be able to commit, but this is good enough.
    return matched && matched.access_level >= 30;
  };

  let cachedDefaultBranch;
  const getDefaultBranch = async () => {
    if (!cachedDefaultBranch) {
      // https://docs.gitlab.com/ee/api/projects.html#get-single-project
      const response = await decoratedFetch(getProjectApi());
      if (!response.ok) {
        throw new Error(`Unexpected response from project API. Status code: ${response.status}`);
      }
      const body = await response.json();
      cachedDefaultBranch = body.default_branch;
    }
    return cachedDefaultBranch;
  };

  const fetchDirectory = async (url) => {
    const response = await decoratedFetch(url);
    if (!response.ok) {
      throw new Error(`Unexpected response from directory API. Status code: ${response.status}`);
    }
    const pages = parseLinkHeader(response.headers.get('link'));
    const data = await response.json();
    return {
      listing: treeToDirectoryListing(data),
      hasMore: !!pages.next,
      additionalSyncBackendState: Map({
        cursor: pages.next,
      }),
    };
  };

  const getDirectoryListing = async (path) => {
    const params = new URLSearchParams({
      pagination: 'keyset',
      ref: await getDefaultBranch(),
      // Organice requires a leading "/", whereas GitLab API requires
      // there *not* be one.
      path: path.replace(/^\//, ''),
      per_page: 100,
    });
    // https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree
    return await fetchDirectory(`${getProjectApi()}/repository/tree?${params}`);
  };

  const getMoreDirectoryListing = async (additionalSyncBackendState) =>
    await fetchDirectory(additionalSyncBackendState.get('cursor'));

  const getRawFile = async (path) => {
    const params = new URLSearchParams({
      ref: await getDefaultBranch(),
    });
    const encodedPath = encodeURIComponent(path.replace(/^\//, ''));
    // https://docs.gitlab.com/ee/api/repository_files.html#get-raw-file-from-repository
    const response = await decoratedFetch(
      `${getProjectApi()}/repository/files/${encodedPath}/raw?${params}`
    );
    if (!response.ok) {
      throw new Error(`Unexpected response from file API. Status code: ${response.status}`);
    }
    return {
      contents: await response.text(),
      commit: response.headers.get('x-gitlab-last-commit-id'),
    };
  };

  const getCommitDate = async (sha) => {
    // https://docs.gitlab.com/ee/api/commits.html#get-a-single-commit
    const response = await decoratedFetch(
      `${getProjectApi()}/repository/commits/${sha}?stats=false`
    );
    if (!response.ok) {
      throw new Error(`Unexpected response from commit API. Status code: ${response.status}`);
    }
    const body = await response.json();
    // Dates are ISO-8601. Note: while commit date *should* generally
    // be the same as or later than the author date, that isn't
    // guaranteed since history can be rewritten at will. So we pick
    // the newer of the two.
    const committed = new Date(body.committed_date);
    const authored = new Date(body.authored_date);
    // Use Date objects for comparison, but need to return as strings.
    return committed > authored ? committed.toISOString() : authored.toISOString();
  };

  const getFileContentsAndMetadata = async (path) => {
    const file = await getRawFile(path);
    return {
      contents: file.contents,
      lastModifiedAt: await getCommitDate(file.commit),
    };
  };

  // Parentheses are necessarily to await the actual promise. Yay,
  // foot-guns.
  const getFileContents = async (path) => (await getRawFile(path)).contents;

  const doCommit = async (action) => {
    const capitalizedAction = action.action.charAt(0).toUpperCase() + action.action.slice(1);
    // Two newlines because Git commits should have an empty line
    // between title and body.
    const message =
      `[organice] ${capitalizedAction} ${action.file_path}\n\n` +
      'Automatic commit from organice app.';
    // It's also possible to modify files using the files API instead
    // of commits API. For this use case they're about equal, but I
    // picked commits because it doesn't require non-standard encoding
    // for file paths, whereas the files API requires URI encoding
    // plus converting period characters to %2E. Also, the commits API
    // is more flexible in case of future changes, since it allows
    // modifying multiple files at a time.
    //
    // https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions
    // https://docs.gitlab.com/ee/api/repository_files.html
    await decoratedFetch(`${getProjectApi()}/repository/commits`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        branch: await getDefaultBranch(),
        commit_message: message,
        // Organice only modifies a single file at a time, so only one action.
        actions: [action],
        stats: false,
      }),
    });
  };

  const createFile = async (path, content) => {
    await doCommit({
      action: 'create',
      file_path: path.replace(/^\//, ''),
      content,
    });
  };

  const updateFile = async (path, content) => {
    await doCommit({
      action: 'update',
      file_path: path.replace(/^\//, ''),
      content,
    });
  };

  const deleteFile = async (path) => {
    await doCommit({
      action: 'delete',
      file_path: path.replace(/^\//, ''),
    });
  };

  return {
    type: 'GitLab',
    isSignedIn,
    isProjectAccessible,
    getDirectoryListing,
    getMoreDirectoryListing,
    updateFile,
    createFile,
    getFileContentsAndMetadata,
    getFileContents,
    deleteFile,
  };
};