Skip to content
Snippets Groups Projects
Select Git revision
  • f510a1999d392cc4622db36776c8a1769dfce634
  • master default
2 results

index.js

Blame
  • index.js 22.55 KiB
    const is = require('@sindresorhus/is');
    const addrs = require('email-addresses');
    const showdown = require('showdown');
    
    const get = require('./gl-got-wrapper');
    const endpoints = require('../../util/endpoints');
    
    const converter = new showdown.Converter();
    converter.setFlavor('github');
    
    let config = {};
    
    module.exports = {
      getRepos,
      cleanRepo,
      initRepo,
      getRepoForceRebase,
      setBaseBranch,
      // Search
      getFileList,
      // Branch
      branchExists,
      getAllRenovateBranches,
      isBranchStale,
      getBranchPr,
      getBranchStatus,
      getBranchStatusCheck,
      setBranchStatus,
      deleteBranch,
      mergeBranch,
      getBranchLastCommitTime,
      // issue
      ensureIssue,
      ensureIssueClosing,
      addAssignees,
      addReviewers,
      // Comments
      ensureComment,
      ensureCommentRemoval,
      // PR
      getPrList,
      findPr,
      createPr,
      getPr,
      getPrFiles,
      updatePr,
      mergePr,
      getPrBody,
      // file
      commitFilesToBranch,
      getFile,
      // commits
      getCommitMessages,
      // vulnerability alerts
      getVulnerabilityAlerts,
    };
    
    // Get all repositories that the user has access to
    async function getRepos(token, endpoint) {
      logger.info('Autodiscovering GitLab repositories');
      logger.debug('getRepos(token, endpoint)');
      const opts = endpoints.find({ platform: 'gitlab' }, { token, endpoint });
      if (!opts.token) {
        throw new Error('No token found for getRepos');
      }
      endpoints.update({ ...opts, platform: 'gitlab', default: true });
      try {
        const url = `projects?membership=true&per_page=100`;
        const res = await get(url, { paginate: true });
        logger.info(`Discovered ${res.body.length} project(s)`);
        return res.body.map(repo => repo.path_with_namespace);
      } catch (err) {
        logger.error({ err }, `GitLab getRepos error`);
        throw err;
      }
    }
    
    function urlEscape(str) {
      return str ? str.replace(/\//g, '%2F') : str;
    }
    
    function cleanRepo() {
      // In theory most of this isn't necessary. In practice..
      get.reset();
      config = {};
    }
    
    // Initialize GitLab by getting base branch
    async function initRepo({ repository, token, endpoint, gitAuthor }) {
      const opts = endpoints.find({ platform: 'gitlab' }, { token, endpoint });
      if (!opts.token) {
        throw new Error(`No token found for GitLab repository ${repository}`);
      }
      endpoints.update({ ...opts, platform: 'gitlab', default: true });
      config = {};
      get.reset();
      config.repository = urlEscape(repository);
      if (gitAuthor) {
        try {
          config.gitAuthor = addrs.parseOneAddress(gitAuthor);
        } catch (err) /* istanbul ignore next */ {
          logger.error(
            { gitAuthor, err, message: err.message },
            'Invalid gitAuthor'
          );
          throw new Error('Invalid gitAuthor');
        }
      }
      try {
        const res = await get(`projects/${config.repository}`);
        config.defaultBranch = res.body.default_branch;
        config.baseBranch = config.defaultBranch;
        logger.debug(`${repository} default branch = ${config.baseBranch}`);
        // Discover our user email
        config.email = (await get(`user`)).body.email;
        delete config.prList;
        delete config.fileList;
        await Promise.all([getPrList(), getFileList()]);
      } catch (err) {
        logger.error({ err }, `GitLab init error`);
        throw err;
      }
      return {};
    }
    
    function getRepoForceRebase() {
      return false;
    }
    
    async function setBaseBranch(branchName) {
      if (branchName) {
        logger.debug(`Setting baseBranch to ${branchName}`);
        config.baseBranch = branchName;
        delete config.fileList;
        await getFileList(branchName);
      }
    }
    
    // Search
    
    // Get full file list
    async function getFileList(branchName = config.baseBranch) {
      if (config.fileList) {
        return config.fileList;
      }
      try {
        let url = `projects/${
          config.repository
        }/repository/tree?ref=${branchName}&per_page=100`;
        if (!(process.env.RENOVATE_DISABLE_FILE_RECURSION === 'true')) {
          url += '&recursive=true';
        }
        const res = await get(url, { paginate: true });
        config.fileList = res.body
          .filter(item => item.type === 'blob' && item.mode !== '120000')
          .map(item => item.path)
          .sort();
        logger.debug(`Retrieved fileList with length ${config.fileList.length}`);
      } catch (err) {
        logger.info('Error retrieving git tree - no files detected');
        config.fileList = [];
      }
      return config.fileList;
    }
    
    // Branch
    
    // Returns true if branch exists, otherwise false
    async function branchExists(branchName) {
      logger.debug(`Checking if branch exists: ${branchName}`);
      try {
        const url = `projects/${config.repository}/repository/branches/${urlEscape(
          branchName
        )}`;
        const res = await get(url);
        if (res.statusCode === 200) {
          logger.debug('Branch exists');
          return true;
        }
        // This probably shouldn't happen
        logger.debug("Branch doesn't exist");
        return false;
      } catch (error) {
        if (error.statusCode === 404) {
          // If file not found, then return false
          logger.debug("Branch doesn't exist");
          return false;
        }
        // Propagate if it's any other error
        throw error;
      }
    }
    
    async function getAllRenovateBranches(branchPrefix) {
      logger.debug(`getAllRenovateBranches(${branchPrefix})`);
      const allBranches = await get(
        `projects/${config.repository}/repository/branches`
      );
      return allBranches.body.reduce((arr, branch) => {
        if (branch.name.startsWith(branchPrefix)) {
          arr.push(branch.name);
        }
        return arr;
      }, []);
    }
    
    async function isBranchStale(branchName) {
      logger.debug(`isBranchStale(${branchName})`);
      const branchDetails = await getBranchDetails(branchName);
      logger.trace({ branchDetails }, 'branchDetails');
      const parentSha = branchDetails.body.commit.parent_ids[0];
      logger.debug(`parentSha=${parentSha}`);
      const baseCommitSHA = await getBaseCommitSHA();
      logger.debug(`baseCommitSHA=${baseCommitSHA}`);
      // Return true if the SHAs don't match
      return parentSha !== baseCommitSHA;
    }
    
    // Returns the Pull Request for a branch. Null if not exists.
    async function getBranchPr(branchName) {
      logger.debug(`getBranchPr(${branchName})`);
      if (!(await branchExists(branchName))) {
        return null;
      }
      const urlString = `projects/${
        config.repository
      }/merge_requests?state=opened&per_page=100`;
      const res = await get(urlString, { paginate: true });
      logger.debug(`Got res with ${res.body.length} results`);
      let pr = null;
      res.body.forEach(result => {
        if (result.source_branch === branchName) {
          pr = result;
        }
      });
      if (!pr) {
        return null;
      }
      return getPr(pr.iid);
    }
    
    // Returns the combined status for a branch.
    async function getBranchStatus(branchName, requiredStatusChecks) {
      logger.debug(`getBranchStatus(${branchName})`);
      if (!requiredStatusChecks) {
        // null means disable status checks, so it always succeeds
        return 'success';
      }
      if (requiredStatusChecks.length) {
        // This is Unsupported
        logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`);
        return 'failed';
      }
      // First, get the branch to find the commit SHA
      let url = `projects/${config.repository}/repository/branches/${urlEscape(
        branchName
      )}`;
      let res = await get(url);
      const branchSha = res.body.commit.id;
      // Now, check the statuses for that commit
      url = `projects/${
        config.repository
      }/repository/commits/${branchSha}/statuses`;
      res = await get(url);
      logger.debug(`Got res with ${res.body.length} results`);
      if (res.body.length === 0) {
        // Return 'pending' if we have no status checks
        return 'pending';
      }
      let status = 'success';
      // Return 'success' if all are success
      res.body.forEach(check => {
        // If one is failed then don't overwrite that
        if (status !== 'failure') {
          if (!check.allow_failure) {
            if (check.status === 'failed') {
              status = 'failure';
            } else if (check.status !== 'success') {
              ({ status } = check);
            }
          }
        }
      });
      return status;
    }
    
    async function getBranchStatusCheck(branchName, context) {
      // First, get the branch to find the commit SHA
      let url = `projects/${config.repository}/repository/branches/${urlEscape(
        branchName
      )}`;
      let res = await get(url);
      const branchSha = res.body.commit.id;
      // Now, check the statuses for that commit
      url = `projects/${
        config.repository
      }/repository/commits/${branchSha}/statuses`;
      res = await get(url);
      logger.debug(`Got res with ${res.body.length} results`);
      for (const check of res.body) {
        if (check.name === context) {
          return check.state;
        }
      }
      return null;
    }
    
    async function setBranchStatus(
      branchName,
      context,
      description,
      state,
      targetUrl
    ) {
      // First, get the branch to find the commit SHA
      let url = `projects/${config.repository}/repository/branches/${urlEscape(
        branchName
      )}`;
      const res = await get(url);
      const branchSha = res.body.commit.id;
      // Now, check the statuses for that commit
      url = `projects/${config.repository}/statuses/${branchSha}`;
      const options = {
        state,
        description,
        context,
      };
      if (targetUrl) {
        options.target_url = targetUrl;
      }
      await get.post(url, { body: options });
    }
    
    async function deleteBranch(branchName, closePr = false) {
      if (closePr) {
        logger.debug('Closing PR');
        const pr = await getBranchPr(branchName);
        // istanbul ignore if
        if (pr) {
          await get.put(
            `projects/${config.repository}/merge_requests/${pr.number}`,
            {
              body: {
                state_event: 'close',
              },
            }
          );
        }
      }
      await get.delete(
        `projects/${config.repository}/repository/branches/${urlEscape(branchName)}`
      );
    }
    
    async function mergeBranch(branchName) {
      logger.debug(`mergeBranch(${branchName}`);
      const branchURI = encodeURIComponent(branchName);
      try {
        await get.post(
          `projects/${
            config.repository
          }/repository/commits/${branchURI}/cherry_pick?branch=${config.baseBranch}`
        );
      } catch (err) {
        logger.info(
          expandError(err),
          `Error pushing branch merge for ${branchName}`
        );
        throw new Error('Branch automerge failed');
      }
      // Update base commit
      config.baseCommitSHA = null;
      // Delete branch
      await deleteBranch(branchName);
    }
    
    async function getBranchLastCommitTime(branchName) {
      try {
        const res = await get(
          `projects/${config.repository}/repository/commits?ref_name=${urlEscape(
            branchName
          )}`
        );
        return new Date(res.body[0].committed_date);
      } catch (err) {
        logger.error({ err }, `getBranchLastCommitTime error`);
        return new Date();
      }
    }
    
    // Issue
    
    async function getIssueList() {
      if (!config.issueList) {
        const res = await get(`projects/${config.repository}/issues?state=opened`, {
          useCache: false,
        });
        // istanbul ignore if
        if (!is.array(res.body)) {
          logger.warn({ responseBody: res.body }, 'Could not retrieve issue list');
          return [];
        }
        config.issueList = res.body.map(i => ({
          iid: i.iid,
          title: i.title,
        }));
      }
      return config.issueList;
    }
    
    async function ensureIssue(title, body) {
      logger.debug(`ensureIssue()`);
      try {
        const issueList = await getIssueList();
        const issue = issueList.find(i => i.title === title);
        if (issue) {
          const issueBody = (await get(
            `projects/${config.repository}/issues/${issue.iid}`
          )).body.body;
          if (issueBody !== body) {
            logger.debug('Updating issue body');
            await get.put(`projects/${config.repository}/issues/${issue.iid}`, {
              body: { description: body },
            });
            return 'updated';
          }
        } else {
          await get.post(`projects/${config.repository}/issues`, {
            body: {
              title,
              description: body,
            },
          });
          // delete issueList so that it will be refetched as necessary
          delete config.issueList;
          return 'created';
        }
      } catch (err) /* istanbul ignore next */ {
        if (err.message.startsWith('Issues are disabled for this repo')) {
          logger.info(`Could not create issue: ${err.message}`);
        } else {
          logger.warn(expandError(err), 'Could not ensure issue');
        }
      }
      return null;
    }
    
    async function ensureIssueClosing(title) {
      logger.debug(`ensureIssueClosing()`);
      const issueList = await getIssueList();
      for (const issue of issueList) {
        if (issue.title === title) {
          logger.info({ issue }, 'Closing issue');
          await get.put(`projects/${config.repository}/issues/${issue.iid}`, {
            body: { state_event: 'close' },
          });
        }
      }
    }
    
    async function addAssignees(iid, assignees) {
      logger.debug(`Adding assignees ${assignees} to #${iid}`);
      if (assignees.length > 1) {
        logger.warn('Cannot assign more than one assignee to Merge Requests');
      }
      try {
        const assigneeId = (await get(`users?username=${assignees[0]}`)).body[0].id;
        let url = `projects/${config.repository}/merge_requests/${iid}`;
        url += `?assignee_id=${assigneeId}`;
        await get.put(url);
      } catch (err) {
        logger.error({ iid, assignees }, 'Failed to add assignees');
      }
    }
    
    function addReviewers(iid, reviewers) {
      logger.debug(`addReviewers('${iid}, '${reviewers})`);
      logger.warn('Unimplemented in GitLab: approvals');
    }
    
    async function getComments(issueNo) {
      // GET projects/:owner/:repo/merge_requests/:number/notes
      logger.debug(`Getting comments for #${issueNo}`);
      const url = `projects/${config.repository}/merge_requests/${issueNo}/notes`;
      const comments = (await get(url, { paginate: true })).body;
      logger.debug(`Found ${comments.length} comments`);
      return comments;
    }
    
    async function addComment(issueNo, body) {
      // POST projects/:owner/:repo/merge_requests/:number/notes
      await get.post(
        `projects/${config.repository}/merge_requests/${issueNo}/notes`,
        {
          body: { body },
        }
      );
    }
    
    async function editComment(issueNo, commentId, body) {
      // PATCH projects/:owner/:repo/merge_requests/:number/notes/:id
      await get.patch(
        `projects/${
          config.repository
        }/merge_requests/${issueNo}/notes/${commentId}`,
        {
          body: { body },
        }
      );
    }
    
    async function deleteComment(issueNo, commentId) {
      // DELETE projects/:owner/:repo/merge_requests/:number/notes/:id
      await get.delete(
        `projects/${config.repository}/merge_requests/${issueNo}/notes/${commentId}`
      );
    }
    
    async function ensureComment(issueNo, topic, content) {
      const comments = await getComments(issueNo);
      let body;
      let commentId;
      let commentNeedsUpdating;
      if (topic) {
        logger.debug(`Ensuring comment "${topic}" in #${issueNo}`);
        body = `### ${topic}\n\n${content}`;
        comments.forEach(comment => {
          if (comment.body.startsWith(`### ${topic}\n\n`)) {
            commentId = comment.id;
            commentNeedsUpdating = comment.body !== body;
          }
        });
      } else {
        logger.debug(`Ensuring content-only comment in #${issueNo}`);
        body = `${content}`;
        comments.forEach(comment => {
          if (comment.body === body) {
            commentId = comment.id;
            commentNeedsUpdating = false;
          }
        });
      }
      if (!commentId) {
        await addComment(issueNo, body);
        logger.info({ repository: config.repository, issueNo }, 'Added comment');
      } else if (commentNeedsUpdating) {
        await editComment(issueNo, commentId, body);
        logger.info({ repository: config.repository, issueNo }, 'Updated comment');
      } else {
        logger.debug('Comment is already update-to-date');
      }
    }
    
    async function ensureCommentRemoval(issueNo, topic) {
      logger.debug(`Ensuring comment "${topic}" in #${issueNo} is removed`);
      const comments = await getComments(issueNo);
      let commentId;
      comments.forEach(comment => {
        if (comment.body.startsWith(`### ${topic}\n\n`)) {
          commentId = comment.id;
        }
      });
      if (commentId) {
        await deleteComment(issueNo, commentId);
      }
    }
    
    async function getPrList() {
      if (!config.prList) {
        const urlString = `projects/${
          config.repository
        }/merge_requests?per_page=100`;
        const res = await get(urlString, { paginate: true });
        config.prList = res.body.map(pr => ({
          number: pr.iid,
          branchName: pr.source_branch,
          title: pr.title,
          state: pr.state === 'opened' ? 'open' : pr.state,
          createdAt: pr.created_at,
        }));
      }
      return config.prList;
    }
    
    function matchesState(state, desiredState) {
      if (desiredState === 'all') {
        return true;
      }
      if (desiredState[0] === '!') {
        return state !== desiredState.substring(1);
      }
      return state === desiredState;
    }
    
    async function findPr(branchName, prTitle, state = 'all') {
      logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`);
      const prList = await getPrList();
      return prList.find(
        p =>
          p.branchName === branchName &&
          (!prTitle || p.title === prTitle) &&
          matchesState(p.state, state)
      );
    }
    
    // Pull Request
    
    async function createPr(
      branchName,
      title,
      description,
      labels,
      useDefaultBranch
    ) {
      const targetBranch = useDefaultBranch
        ? config.defaultBranch
        : config.baseBranch;
      logger.debug(`Creating Merge Request: ${title}`);
      const res = await get.post(`projects/${config.repository}/merge_requests`, {
        body: {
          source_branch: branchName,
          target_branch: targetBranch,
          remove_source_branch: true,
          title,
          description,
          labels: is.array(labels) ? labels.join(',') : null,
        },
      });
      const pr = res.body;
      pr.number = pr.iid;
      pr.branchName = branchName;
      pr.displayNumber = `Merge Request #${pr.iid}`;
      return pr;
    }
    
    async function getPr(iid) {
      logger.debug(`getPr(${iid})`);
      const url = `projects/${config.repository}/merge_requests/${iid}`;
      const pr = (await get(url)).body;
      // Harmonize fields with GitHub
      pr.branchName = pr.source_branch;
      pr.number = pr.iid;
      pr.displayNumber = `Merge Request #${pr.iid}`;
      pr.body = pr.description;
      pr.state = pr.state === 'opened' ? 'open' : pr.state;
      if (pr.merge_status === 'cannot_be_merged') {
        logger.debug('pr cannot be merged');
        pr.canMerge = false;
        pr.isUnmergeable = true;
      } else {
        // Actually.. we can't be sure
        pr.canMerge = true;
      }
      // Check if the most recent branch commit is by us
      // If not then we don't allow it to be rebased, in case someone's changes would be lost
      const branchUrl = `projects/${
        config.repository
      }/repository/branches/${urlEscape(pr.source_branch)}`;
      try {
        const branch = (await get(branchUrl)).body;
        if (
          branch &&
          branch.commit &&
          branch.commit.author_email === config.email
        ) {
          pr.canRebase = true;
        }
      } catch (err) {
        logger.warn({ err }, 'Error getting PR branch');
        pr.isUnmergeable = true;
      }
      return pr;
    }
    
    // Return a list of all modified files in a PR
    async function getPrFiles(mrNo) {
      logger.debug({ mrNo }, 'getPrFiles');
      if (!mrNo) {
        return [];
      }
      const files = (await get(
        `projects/${config.repository}/merge_requests/${mrNo}/changes`
      )).body;
      return files.map(f => f.filename);
    }
    
    // istanbul ignore next
    async function reopenPr(iid) {
      await get.put(`projects/${config.repository}/merge_requests/${iid}`, {
        body: {
          state_event: 'reopen',
        },
      });
    }
    
    async function updatePr(iid, title, description) {
      await get.put(`projects/${config.repository}/merge_requests/${iid}`, {
        body: {
          title,
          description,
        },
      });
    }
    
    async function mergePr(iid) {
      await get.put(`projects/${config.repository}/merge_requests/${iid}/merge`, {
        body: {
          should_remove_source_branch: true,
        },
      });
      return true;
    }
    
    function getPrBody(input) {
      // Convert to HTML using GitHub-flavoured markdown as it is more feature-rich than GitLab's flavour
      return converter
        .makeHtml(input)
        .replace(/Pull Request/g, 'Merge Request')
        .replace(/PR/g, 'MR')
        .replace(
          `<p><details><br />\n<summary>Release Notes</summary></p>`,
          '\n<details>\n\n<summary>Release Notes</summary>\n\n'
        )
        .replace('<p></details></p>', '\n</details>\n');
      // TODO: set maximum length
    }
    
    // Generic File operations
    
    async function getFile(filePath, branchName) {
      logger.debug(`getFile(filePath=${filePath}, branchName=${branchName})`);
      if (!branchName || branchName === config.baseBranch) {
        if (config.fileList && !config.fileList.includes(filePath)) {
          return null;
        }
      }
      try {
        const url = `projects/${config.repository}/repository/files/${urlEscape(
          filePath
        )}?ref=${branchName || config.baseBranch}`;
        const res = await get(url);
        return Buffer.from(res.body.content, 'base64').toString();
      } catch (error) {
        if (error.statusCode === 404) {
          // If file not found, then return null JSON
          return null;
        }
        // Propagate if it's any other error
        throw error;
      }
    }
    
    // Add a new commit, create branch if not existing
    async function commitFilesToBranch(
      branchName,
      files,
      message,
      parentBranch = config.baseBranch
    ) {
      logger.debug(
        `commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`
      );
      const opts = {
        body: {
          branch: branchName,
          commit_message: message,
          start_branch: parentBranch,
          actions: [],
        },
      };
      // istanbul ignore if
      if (config.gitAuthor) {
        opts.body.author_name = config.gitAuthor.name;
        opts.body.author_email = config.gitAuthor.address;
      }
      for (const file of files) {
        const action = {
          file_path: file.name,
          content: Buffer.from(file.contents).toString('base64'),
          encoding: 'base64',
        };
        action.action = (await getFile(file.name)) ? 'update' : 'create';
        opts.body.actions.push(action);
      }
      let res = 'created';
      try {
        if (await branchExists(branchName)) {
          logger.debug('Deleting existing branch');
          await deleteBranch(branchName);
          res = 'updated';
        }
      } catch (err) {
        // istanbul ignore next
        logger.info(`Ignoring branch deletion failure`);
      }
      logger.debug('Adding commits');
      await get.post(`projects/${config.repository}/repository/commits`, opts);
      // Reopen PR if it previousluy existed and was closed by GitLab when we deleted branch
      const pr = await getBranchPr(branchName);
      // istanbul ignore if
      if (pr) {
        logger.debug('Reopening PR');
        await reopenPr(pr.number);
      }
      return res;
    }
    
    // GET /projects/:id/repository/commits
    async function getCommitMessages() {
      logger.debug('getCommitMessages');
      const res = await get(`projects/${config.repository}/repository/commits`);
      return res.body.map(commit => commit.title);
    }
    
    function getBranchDetails(branchName) {
      const url = `/projects/${config.repository}/repository/branches/${urlEscape(
        branchName
      )}`;
      return get(url);
    }
    
    async function getBaseCommitSHA() {
      if (!config.baseCommitSHA) {
        const branchDetails = await getBranchDetails(config.baseBranch);
        config.baseCommitSHA = branchDetails.body.commit.id;
      }
      return config.baseCommitSHA;
    }
    
    // istanbul ignore next
    function expandError(err) {
      return {
        err,
        message: err.message,
        body: err.response ? err.response.body : undefined,
      };
    }
    
    function getVulnerabilityAlerts() {
      return [];
    }