-
Rhys Arkins authored
Delete cached issueList after creating any new issue, so that we don’t end up with “old” state and potentially create duplicate issues.
Rhys Arkins authoredDelete cached issueList after creating any new issue, so that we don’t end up with “old” state and potentially create duplicate issues.
index.js 40.33 KiB
const is = require('@sindresorhus/is');
const get = require('./gh-got-wrapper');
const addrs = require('email-addresses');
const moment = require('moment');
const openpgp = require('openpgp');
const delay = require('delay');
const path = require('path');
let config = {};
module.exports = {
// Initialization
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,
// file
commitFilesToBranch,
getFile,
// Commits
getCommitMessages,
};
// Get all repositories that the user has access to
async function getRepos(token, endpoint) {
logger.info('Autodiscovering GitHub repositories');
logger.debug('getRepos(token, endpoint)');
if (token) {
process.env.GITHUB_TOKEN = token;
} else if (!process.env.GITHUB_TOKEN) {
throw new Error('No token found for getRepos');
}
if (endpoint) {
process.env.GITHUB_ENDPOINT = endpoint;
}
try {
const res = await get('user/repos', { paginate: true });
return res.body.map(repo => repo.full_name);
} catch (err) /* istanbul ignore next */ {
logger.error(expandError(err), `GitHub getRepos error`);
throw err;
}
}
function cleanRepo() {
// In theory most of this isn't necessary. In practice..
get.reset();
config = null;
config = {};
delete config.repository;
delete config.owner;
delete config.defaultBranch;
delete config.baseBranch;
delete config.issueList;
delete config.prList;
delete config.fileList;
delete config.branchList;
delete config.forkToken;
}
// Initialize GitHub by getting base branch and SHA
async function initRepo({
repository,
token,
endpoint,
forkMode,
forkToken,
mirrorMode,
gitAuthor,
gitPrivateKey,
}) {
logger.debug(`initRepo("${repository}")`);
if (token) {
logger.debug('Setting token in env for use by gh-got');
process.env.GITHUB_TOKEN = token;
} else if (!process.env.GITHUB_TOKEN) {
throw new Error(`No token found for GitHub repository ${repository}`);
}
if (endpoint) {
logger.debug('Setting endpoint in env for use by gh-got');
process.env.GITHUB_ENDPOINT = endpoint;
}
logger.debug('Resetting platform config');
// config is used by the platform api itself, not necessary for the app layer to know
cleanRepo();
config.repository = 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');
}
}
config.gitPrivateKey = gitPrivateKey;
// platformConfig is passed back to the app layer and contains info about the platform they require
const platformConfig = {};
let res;
try {
res = await get(`repos/${repository}`);
logger.trace({ repositoryDetails: res.body }, 'Repository details');
// istanbul ignore if
if (res.body.full_name && res.body.full_name !== repository) {
logger.info(
{ repository, this_repository: res.body.full_name },
'Repository has been renamed'
);
throw new Error('renamed');
}
if (res.body.archived) {
logger.info(
'Repository is archived - throwing error to abort renovation'
);
throw new Error('archived');
}
platformConfig.privateRepo = res.body.private === true;
platformConfig.isFork = res.body.fork === true;
config.owner = res.body.owner.login;
logger.debug(`${repository} owner = ${config.owner}`);
// Use default branch as PR target unless later overridden.
config.defaultBranch = res.body.default_branch;
// Base branch may be configured but defaultBranch is always fixed
config.baseBranch = config.defaultBranch;
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
getBranchCommit(config.baseBranch); // warm the cache
}
logger.debug(`${repository} default branch = ${config.baseBranch}`);
// GitHub allows administrators to block certain types of merge, so we need to check it
if (res.body.allow_rebase_merge) {
config.mergeMethod = 'rebase';
} else if (res.body.allow_squash_merge) {
config.mergeMethod = 'squash';
} else if (res.body.allow_merge_commit) {
config.mergeMethod = 'merge';
} else {
// This happens if we don't have Administrator read access, it is not a critical error
logger.info('Could not find allowed merge methods for repo');
}
} catch (err) /* istanbul ignore next */ {
logger.debug('Caught initRepo error');
if (err.message === 'archived' || err.message === 'renamed') {
throw err;
}
if (err.statusCode === 403) {
throw new Error('forbidden');
}
if (err.statusCode === 404) {
throw new Error('not-found');
}
if (err.message.startsWith('Repository access blocked')) {
throw new Error('blocked');
}
logger.info(
{ err, message: err.message, body: res ? res.body : undefined },
'Unknown GitHub initRepo error'
);
throw err;
}
// This shouldn't be necessary, but occasional strange errors happened until it was added
config.issueList = null;
config.prList = null;
config.fileList = null;
config.branchList = null;
logger.debug('Prefetching prList and fileList');
await Promise.all([getPrList(), getFileList()]);
if (forkMode) {
logger.info('Renovate is in forkMode');
config.forkToken = forkToken;
// Save parent SHA then delete
config.parentSha = await getBaseCommitSHA();
config.baseCommitSHA = null;
// save parent name then delete
config.parentRepo = config.repository;
config.repository = null;
// Get list of existing repos
const existingRepos = (await get('user/repos?per_page=100', {
token: forkToken || process.env.GITHUB_TOKEN,
paginate: true,
})).body.map(r => r.full_name);
config.repository = (await get.post(`repos/${repository}/forks`, {
token: forkToken || process.env.GITHUB_TOKEN,
})).body.full_name;
if (existingRepos.includes(config.repository)) {
logger.info(
{ repository_fork: config.repository },
'Found existing fork'
);
// Need to update base branch
logger.debug(
{ baseBranch: config.baseBranch, parentSha: config.parentSha },
'Setting baseBranch ref in fork'
);
// This is a lovely "hack" by GitHub that lets us force update our fork's master
// with the base commit from the parent repository
await get.patch(
`repos/${config.repository}/git/refs/heads/${config.baseBranch}`,
{
body: {
sha: config.parentSha,
},
token: forkToken || process.env.GITHUB_TOKEN,
}
);
} else {
logger.info({ repository_fork: config.repository }, 'Created fork');
// Wait an arbitrary 30s to hopefully give GitHub enough time for forking to complete
await delay(30000);
}
}
// istanbul ignore if
if (mirrorMode) {
logger.info('Renovate is in mirrorMode');
config.mirrorMode = true;
const parentRepo = res.body.parent.full_name;
logger.debug('Parent repo is ' + parentRepo);
const parentDefaultBranch = (await get(`repos/${repository}`)).body
.default_branch;
logger.debug('Parent default branch is ' + parentDefaultBranch);
const parentSha = (await get(
`repos/${parentRepo}/git/refs/heads/${parentDefaultBranch}`
)).body.object.sha;
logger.debug('Parent sha is ' + parentSha);
// This is a lovely "hack" by GitHub that lets us force update our fork's master
// with the base commit from the parent repository
config.baseCommitSha = await getBaseCommitSHA();
if (parentSha !== config.baseCommitSHA) {
logger.info('Updating fork default branch');
await get.patch(
`repos/${config.repository}/git/refs/heads/${config.baseBranch}`,
{
body: {
sha: parentSha,
},
}
);
}
if (!(await branchExists('renovate-config'))) {
await createBranch('renovate-config', config.baseCommitSHA);
}
}
return platformConfig;
}
async function getRepoForceRebase() {
if (config.repoForceRebase === undefined) {
try {
config.repoForceRebase = false;
const branchProtection = await getBranchProtection(config.baseBranch);
logger.info('Found branch protection');
if (branchProtection.required_pull_request_reviews) {
logger.info(
'Branch protection: PR Reviews are required before merging'
);
config.prReviewsRequired = true;
}
if (branchProtection.required_status_checks) {
if (branchProtection.required_status_checks.strict) {
logger.info(
'Branch protection: PRs must be up-to-date before merging'
);
config.repoForceRebase = true;
}
}
if (branchProtection.restrictions) {
logger.info(
{
users: branchProtection.restrictions.users,
teams: branchProtection.restrictions.teams,
},
'Branch protection: Pushing to branch is restricted'
);
config.pushProtection = true;
}
} catch (err) {
if (err.statusCode === 404) {
logger.info(`No branch protection found`);
} else if (err.statusCode === 403) {
logger.info(
'Branch protection: Do not have permissions to detect branch protection'
);
} else {
throw err;
}
}
}
return config.repoForceRebase;
}
async function getBaseCommitSHA() {
if (!config.baseCommitSHA) {
config.baseCommitSHA = await getBranchCommit(config.baseBranch);
}
return config.baseCommitSHA;
}
async function getBranchProtection(branchName) {
// istanbul ignore if
if (config.parentRepo) {
return {};
}
const res = await get(
`repos/${config.repository}/branches/${branchName}/protection`
);
return res.body;
}
async function setBaseBranch(branchName) {
if (branchName) {
logger.debug(`Setting baseBranch to ${branchName}`);
config.baseBranch = branchName;
config.baseCommitSHA = null;
config.fileList = null;
await getFileList(branchName);
}
}
// Search
// Get full file list
async function getFileList(branchName = config.baseBranch) {
if (config.fileList) {
return config.fileList;
}
try {
const res = await get(
`repos/${config.repository}/git/trees/${branchName}?recursive=true`
);
if (res.body.truncated) {
logger.warn(
{ repository: config.repository },
'repository tree is truncated'
);
}
config.fileList = res.body.tree
.filter(item => item.type === 'blob' && item.mode !== '120000')
.map(item => item.path)
.sort();
logger.debug(`Retrieved fileList with length ${config.fileList.length}`);
} catch (err) /* istanbul ignore next */ {
if (err.statusCode === 409) {
logger.debug('Repository is not initiated');
throw new Error('uninitiated');
}
logger.info(
{ repository: config.repository },
'Error retrieving git tree - no files detected'
);
config.fileList = [];
}
return config.fileList;
}
// Branch
// Returns true if branch exists, otherwise false
async function branchExists(branchName) {
if (!config.branchList) {
logger.debug('Retrieving branchList');
config.branchList = (await get(
`repos/${config.repository}/branches?per_page=100`,
{
paginate: true,
}
)).body.map(branch => branch.name);
logger.debug({ branchList: config.branchList }, 'Retrieved branchList');
}
const res = config.branchList.includes(branchName);
logger.debug(`branchExists(${branchName})=${res}`);
return res;
}
async function getAllRenovateBranches(branchPrefix) {
logger.trace('getAllRenovateBranches');
try {
const allBranches = (await get(
`repos/${config.repository}/git/refs/heads/${branchPrefix}`,
{
paginate: true,
}
)).body;
return allBranches.reduce((arr, branch) => {
if (branch.ref.startsWith(`refs/heads/${branchPrefix}`)) {
arr.push(branch.ref.substring('refs/heads/'.length));
}
if (
branchPrefix.endsWith('/') &&
branch.ref === `refs/heads/${branchPrefix.slice(0, -1)}`
) {
logger.warn(
`Pruning branch "${branchPrefix.slice(
0,
-1
)}" so that it does not block PRs`
);
arr.push(branch.ref.substring('refs/heads/'.length));
}
return arr;
}, []);
} catch (err) /* istanbul ignore next */ {
return [];
}
}
async function isBranchStale(branchName) {
// Check if branch's parent SHA = master SHA
logger.debug(`isBranchStale(${branchName})`);
const branchCommit = await getBranchCommit(branchName);
logger.debug(`branchCommit=${branchCommit}`);
const commitDetails = await getCommitDetails(branchCommit);
logger.trace({ commitDetails }, `commitDetails`);
const parentSha = commitDetails.parents[0].sha;
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})`);
const existingPr = await findPr(branchName, null, 'open');
return existingPr ? getPr(existingPr.number) : null;
}
// 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
logger.debug('Status checks disabled = returning "success"');
return 'success';
}
if (requiredStatusChecks.length) {
// This is Unsupported
logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`);
return 'failed';
}
const gotString = `repos/${config.repository}/commits/${branchName}/status`;
const res = await get(gotString);
logger.debug(
{ state: res.body.state, statuses: res.body.statuses },
'branch status check result'
);
return res.body.state;
}
async function getBranchStatusCheck(branchName, context) {
const branchCommit = await getBranchCommit(branchName);
const url = `repos/${config.repository}/commits/${branchCommit}/statuses`;
const res = await get(url);
for (const check of res.body) {
if (check.context === context) {
return check.state;
}
}
return null;
}
async function setBranchStatus(
branchName,
context,
description,
state,
targetUrl
) {
// istanbul ignore if
if (config.parentRepo) {
logger.info('Cannot set branch status when in forking mode');
return;
}
const existingStatus = await getBranchStatusCheck(branchName, context);
if (existingStatus === state) {
return;
}
logger.info({ branchName, context, state }, 'Setting branch status');
const branchCommit = await getBranchCommit(branchName);
const url = `repos/${config.repository}/statuses/${branchCommit}`;
const options = {
state,
description,
context,
};
if (targetUrl) {
options.target_url = targetUrl;
}
await get.post(url, { body: options });
}
async function deleteBranch(branchName) {
const options = config.forkToken ? { token: config.forkToken } : undefined;
try {
await get.delete(
`repos/${config.repository}/git/refs/heads/${branchName}`,
options
);
} catch (err) /* istanbul ignore next */ {
if (err.message.startsWith('Reference does not exist')) {
logger.info({ branchName }, 'Branch to delete does not exist');
} else {
logger.warn(
{ err, body: err.response.body, branchName },
'Error deleting branch'
);
}
}
}
async function mergeBranch(branchName) {
logger.debug(`mergeBranch(${branchName})`);
// istanbul ignore if
if (config.pushProtection) {
logger.info(
{ branchName },
'Branch protection: Attempting to merge branch when push protection is enabled'
);
}
const url = `repos/${config.repository}/git/refs/heads/${config.baseBranch}`;
const options = {
body: {
sha: await getBranchCommit(branchName),
},
};
try {
await get.patch(url, options);
} 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(
`repos/${config.repository}/commits?sha=${branchName}`
);
return new Date(res.body[0].commit.committer.date);
} catch (err) {
logger.error(expandError(err), `getBranchLastCommitTime error`);
return new Date();
}
}
// Issue
async function getIssueList() {
if (!config.issueList) {
const res = await get(
`repos/${config.parentRepo ||
config.repository}/issues?filter=created&state=open`,
{ 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 => ({
number: i.number,
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(
`repos/${config.parentRepo || config.repository}/issues/${issue.number}`
)).body.body;
if (issueBody !== body) {
logger.debug('Updating issue body');
await get.patch(
`repos/${config.parentRepo || config.repository}/issues/${
issue.number
}`,
{
body: { body },
}
);
return 'updated';
}
} else {
await get.post(`repos/${config.parentRepo || config.repository}/issues`, {
body: {
title,
body,
},
});
// reset issueList so that it will be fetched again as-needed
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.patch(
`repos/${config.parentRepo || config.repository}/issues/${
issue.number
}`,
{
body: { state: 'closed' },
}
);
}
}
}
async function addAssignees(issueNo, assignees) {
logger.debug(`Adding assignees ${assignees} to #${issueNo}`);
const repository = config.parentRepo || config.repository;
await get.post(`repos/${repository}/issues/${issueNo}/assignees`, {
body: {
assignees,
},
});
}
async function addReviewers(prNo, reviewers) {
logger.debug(`Adding reviewers ${reviewers} to #${prNo}`);
const res = await get.post(
`repos/${config.parentRepo ||
config.repository}/pulls/${prNo}/requested_reviewers`,
{
body: {
reviewers,
},
}
);
logger.debug({ body: res.body }, 'Added reviewers');
}
async function addLabels(issueNo, labels) {
logger.debug(`Adding labels ${labels} to #${issueNo}`);
const repository = config.parentRepo || config.repository;
if (is.array(labels) && labels.length) {
await get.post(`repos/${repository}/issues/${issueNo}/labels`, {
body: labels,
});
}
}
async function getComments(issueNo) {
// GET /repos/:owner/:repo/issues/:number/comments
logger.debug(`Getting comments for #${issueNo}`);
const url = `repos/${config.parentRepo ||
config.repository}/issues/${issueNo}/comments?per_page=100`;
const comments = (await get(url, { paginate: true })).body;
logger.debug(`Found ${comments.length} comments`);
return comments;
}
async function addComment(issueNo, body) {
// POST /repos/:owner/:repo/issues/:number/comments
await get.post(
`repos/${config.parentRepo ||
config.repository}/issues/${issueNo}/comments`,
{
body: { body },
}
);
}
async function editComment(commentId, body) {
// PATCH /repos/:owner/:repo/issues/comments/:id
await get.patch(
`repos/${config.parentRepo ||
config.repository}/issues/comments/${commentId}`,
{
body: { body },
}
);
}
async function deleteComment(commentId) {
// DELETE /repos/:owner/:repo/issues/comments/:id
await get.delete(
`repos/${config.parentRepo ||
config.repository}/issues/comments/${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(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(commentId);
}
}
// Pull Request
async function getPrList() {
logger.trace('getPrList()');
if (!config.prList) {
logger.debug('Retrieving PR list');
const res = await get(
`repos/${config.parentRepo ||
config.repository}/pulls?per_page=100&state=all`,
{ paginate: true }
);
config.prList = res.body.map(pr => ({
number: pr.number,
branchName: pr.head.ref,
sha: pr.head.sha,
title: pr.title,
state:
pr.state === 'closed' && pr.merged_at && pr.merged_at.length
? 'merged'
: pr.state,
createdAt: pr.created_at,
closed_at: pr.closed_at,
sourceRepo: pr.head && pr.head.repo ? pr.head.repo.full_name : undefined,
}));
logger.debug(`Retrieved ${config.prList.length} Pull Requests`);
}
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();
const pr = prList.find(
p =>
p.branchName === branchName &&
(!prTitle || p.title === prTitle) &&
matchesState(p.state, state)
);
if (pr) {
logger.debug(`Found PR #${pr.number}`);
}
return pr;
}
// Creates PR and returns PR number
async function createPr(
branchName,
title,
body,
labels,
useDefaultBranch,
statusCheckVerify
) {
let base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
// istanbul ignore if
if (config.mirrorMode && branchName === 'renovate/configure') {
logger.debug('Using renovate-config as base branch for mirror config');
base = 'renovate-config';
}
// Include the repository owner to handle forkMode and regular mode
const head = `${config.repository.split('/')[0]}:${branchName}`;
const options = {
body: {
title,
head,
base,
body,
},
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
logger.debug({ title, head, base }, 'Creating PR');
const pr = (await get.post(
`repos/${config.parentRepo || config.repository}/pulls`,
options
)).body;
pr.displayNumber = `Pull Request #${pr.number}`;
pr.branchName = branchName;
await addLabels(pr.number, labels);
if (statusCheckVerify) {
logger.debug('Setting statusCheckVerify');
await setBranchStatus(
branchName,
'renovate/verify',
'Renovate verified pull request',
'success',
'https://renovatebot.com'
);
}
return pr;
}
// Gets details for a PR
async function getPr(prNo) {
if (!prNo) {
return null;
}
const pr = (await get(
`repos/${config.parentRepo || config.repository}/pulls/${prNo}`
)).body;
if (!pr) {
return null;
}
// Harmonise PR values
pr.displayNumber = `Pull Request #${pr.number}`;
if (pr.state === 'open') {
pr.branchName = pr.head ? pr.head.ref : undefined;
pr.sha = pr.head ? pr.head.sha : undefined;
if (pr.mergeable === true) {
pr.canMerge = true;
}
if (pr.mergeable_state === 'dirty') {
logger.debug({ prNo }, 'PR state is dirty so unmergeable');
pr.isUnmergeable = true;
}
if (pr.commits === 1) {
if (config.gitAuthor) {
// Check against gitAuthor
logger.debug('Checking all commits');
try {
const commitAuthorEmail = (await get(
`repos/${config.parentRepo ||
config.repository}/pulls/${prNo}/commits`
)).body[0].commit.author.email;
if (commitAuthorEmail === config.gitAuthor.address) {
logger.debug(
{ prNo },
'1 commit matches configured gitAuthor so can rebase'
);
pr.canRebase = true;
} else {
logger.debug(
{ prNo },
'1 commit and not by configured gitAuthor so cannot rebase'
);
pr.canRebase = false;
}
} catch (err) {
logger.warn({ prNo }, 'Error checking branch commits for rebase');
pr.canRebase = false;
}
} else {
logger.debug(
{ prNo },
'1 commit and no configured gitAuthor so can rebase'
);
pr.canRebase = true;
}
} else {
// Check if only one author of all commits
logger.debug({ prNo }, 'Checking all commits');
const prCommits = (await get(
`repos/${config.parentRepo || config.repository}/pulls/${prNo}/commits`
)).body;
// Filter out "Update branch" presses
const remainingCommits = prCommits.filter(commit => {
const isWebflow =
commit.committer && commit.committer.login === 'web-flow';
if (!isWebflow) {
// Not a web UI commit, so keep it
return true;
}
const isUpdateBranch =
commit.commit &&
commit.commit.message &&
commit.commit.message.startsWith("Merge branch 'master' into");
if (isUpdateBranch) {
// They just clicked the button
return false;
}
// They must have done some other edit through the web UI
return true;
});
if (remainingCommits.length <= 1) {
pr.canRebase = true;
}
}
const baseCommitSHA = await getBaseCommitSHA();
if (!pr.base || pr.base.sha !== baseCommitSHA) {
pr.isStale = true;
}
}
return pr;
}
// Return a list of all modified files in a PR
async function getPrFiles(prNo) {
logger.debug({ prNo }, 'getPrFiles');
if (!prNo) {
return [];
}
const files = (await get(
`repos/${config.parentRepo || config.repository}/pulls/${prNo}/files`
)).body;
return files.map(f => f.filename);
}
async function updatePr(prNo, title, body) {
logger.debug(`updatePr(${prNo}, ${title}, body)`);
const patchBody = { title };
if (body) {
patchBody.body = body;
}
const options = {
body: patchBody,
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
await get.patch(
`repos/${config.parentRepo || config.repository}/pulls/${prNo}`,
options
);
}
async function mergePr(prNo, branchName) {
logger.debug(`mergePr(${prNo}, ${branchName})`);
// istanbul ignore if
if (config.pushProtection) {
logger.info(
{ branchName, prNo },
'Branch protection: Cannot automerge PR when push protection is enabled'
);
return false;
}
// istanbul ignore if
if (config.prReviewsRequired) {
logger.debug(
{ branchName, prNo },
'Branch protection: Attempting to merge PR when PR reviews are enabled'
);
const repository = config.parentRepo || config.repository;
const reviews = await get(`repos/${repository}/pulls/${prNo}/reviews`);
const isApproved = reviews.body.some(review => review.state === 'APPROVED');
if (!isApproved) {
logger.info(
{ branchName, prNo },
'Branch protection: Cannot automerge PR until there is an approving review'
);
return false;
}
logger.debug('Found approving reviews');
}
const url = `repos/${config.parentRepo ||
config.repository}/pulls/${prNo}/merge`;
const options = {
body: {},
};
let automerged = false;
if (config.mergeMethod) {
// This path is taken if we have auto-detected the allowed merge types from the repo
options.body.merge_method = config.mergeMethod;
try {
logger.debug({ options, url }, `mergePr`);
await get.put(url, options);
automerged = true;
} catch (err) {
if (err.statusCode === 405) {
// istanbul ignore next
logger.info(
{ response: err.response ? err.response.body : undefined },
'GitHub blocking PR merge -- will keep trying'
);
} else {
logger.warn(
expandError(err),
`Failed to ${options.body.merge_method} PR`
);
return false;
}
}
}
if (!automerged) {
// We need to guess the merge method and try squash -> rebase -> merge
options.body.merge_method = 'rebase';
try {
logger.debug({ options, url }, `mergePr`);
await get.put(url, options);
} catch (err1) {
logger.debug({ err: err1 }, `Failed to ${options.body.merge_method} PR`);
try {
options.body.merge_method = 'squash';
logger.debug({ options, url }, `mergePr`);
await get.put(url, options);
} catch (err2) {
logger.debug(
{ err: err2 },
`Failed to ${options.body.merge_method} PR`
);
try {
options.body.merge_method = 'merge';
logger.debug({ options, url }, `mergePr`);
await get.put(url, options);
} catch (err3) {
logger.debug(
{ err: err3 },
`Failed to ${options.body.merge_method} PR`
);
logger.info({ pr: prNo }, 'All merge attempts failed');
return false;
}
}
}
}
logger.info('Automerging succeeded');
// Update base branch SHA
config.baseCommitSHA = null;
// Delete branch
await deleteBranch(branchName);
return true;
}
// Generic File operations
async function getFile(filePath, branchName) {
logger.trace(`getFile(filePath=${filePath}, branchName=${branchName})`);
if (!branchName || branchName === config.baseBranch) {
if (!config.fileList.includes(filePath)) {
return null;
}
}
let res;
try {
res = await get(
`repos/${config.repository}/contents/${encodeURI(
filePath
)}?ref=${branchName || config.baseBranch}`
);
} catch (error) {
if (error.statusCode === 404) {
// If file not found, then return null JSON
logger.info({ filePath, branchName }, 'getFile 404');
return null;
} else if (
error.statusCode === 403 &&
error.message &&
error.message.startsWith('This API returns blobs up to 1 MB in size')
) {
logger.info('Large file');
// istanbul ignore if
if (branchName && branchName !== config.baseBranch) {
logger.info('Cannot retrieve large files from non-master branch');
return null;
}
let treeUrl = `repos/${config.repository}/git/trees/${config.baseBranch}`;
const parentPath = path.dirname(filePath);
if (parentPath !== '.') {
treeUrl += `/${parentPath}`;
}
const baseName = path.basename(filePath);
let fileSha;
(await get(treeUrl)).body.tree.forEach(file => {
if (file.path === baseName) {
fileSha = file.sha;
}
});
if (!fileSha) {
logger.warn('Could not locate file blob');
throw error;
}
res = await get(`repos/${config.repository}/git/blobs/${fileSha}`);
} else {
// Propagate if it's any other error
throw error;
}
}
if (res.body.content) {
return Buffer.from(res.body.content, 'base64').toString();
}
return null;
}
// 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 parentCommit = await getBranchCommit(parentBranch);
const parentTree = await getCommitTree(parentCommit);
const fileBlobs = [];
// Create blobs
for (const file of files) {
const blob = await createBlob(file.contents);
fileBlobs.push({
name: file.name,
blob,
});
}
// Create tree
const tree = await createTree(parentTree, fileBlobs);
const commit = await createCommit(parentCommit, tree, message);
const isBranchExisting = await branchExists(branchName);
try {
if (isBranchExisting) {
await updateBranch(branchName, commit);
} else {
await createBranch(branchName, commit);
}
} catch (err) /* istanbul ignore next */ {
logger.debug({
files: files.filter(
file =>
!file.name.endsWith('package-lock.json') &&
!file.name.endsWith('npm-shrinkwrap.json') &&
!file.name.endsWith('yarn.lock')
),
});
throw err;
}
}
// Internal branch operations
// Creates a new branch with provided commit
async function createBranch(branchName, sha) {
logger.debug(`createBranch(${branchName})`);
const options = {
body: {
ref: `refs/heads/${branchName}`,
sha,
},
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
try {
// istanbul ignore if
if (branchName.includes('/')) {
const [blockingBranch] = branchName.split('/');
if (await branchExists(blockingBranch)) {
logger.warn({ blockingBranch }, 'Deleting blocking branch');
await deleteBranch(blockingBranch);
}
}
logger.debug({ options, branchName }, 'Creating branch');
await get.post(`repos/${config.repository}/git/refs`, options);
config.branchList.push(branchName);
logger.debug('Created branch');
} catch (err) /* istanbul ignore next */ {
const headers = err.response.req.getHeaders();
delete headers.token;
logger.warn(
{
err,
message: err.message,
responseBody: err.response.body,
headers,
options,
},
'Error creating branch'
);
if (err.statusCode === 422) {
throw new Error('repository-changed');
}
throw err;
}
}
// Internal: Updates an existing branch to new commit sha
async function updateBranch(branchName, commit) {
logger.debug(`Updating branch ${branchName} with commit ${commit}`);
const options = {
body: {
sha: commit,
force: true,
},
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
try {
await get.patch(
`repos/${config.repository}/git/refs/heads/${branchName}`,
options
);
} catch (err) /* istanbul ignore next */ {
if (err.statusCode === 422) {
logger.info(expandError(err), 'Branch no longer exists - exiting');
throw new Error('repository-changed');
}
throw err;
}
}
// Low-level commit operations
// Create a blob with fileContents and return sha
async function createBlob(fileContents) {
logger.debug('Creating blob');
const options = {
body: {
encoding: 'base64',
content: Buffer.from(fileContents).toString('base64'),
},
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
return (await get.post(`repos/${config.repository}/git/blobs`, options)).body
.sha;
}
// Return the commit SHA for a branch
async function getBranchCommit(branchName) {
try {
const res = await get(
`repos/${config.repository}/git/refs/heads/${branchName}`
);
return res.body.object.sha;
} catch (err) /* istanbul ignore next */ {
return null;
}
}
async function getCommitDetails(commit) {
logger.debug(`getCommitDetails(${commit})`);
const results = await get(`repos/${config.repository}/git/commits/${commit}`);
return results.body;
}
// Return the tree SHA for a commit
async function getCommitTree(commit) {
logger.debug(`getCommitTree(${commit})`);
return (await get(`repos/${config.repository}/git/commits/${commit}`)).body
.tree.sha;
}
// Create a tree and return SHA
async function createTree(baseTree, files) {
logger.debug(`createTree(${baseTree}, files)`);
const body = {
base_tree: baseTree,
tree: [],
};
files.forEach(file => {
body.tree.push({
path: file.name,
mode: '100644',
type: 'blob',
sha: file.blob,
});
});
logger.trace({ body }, 'createTree body');
const options = { body };
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
return (await get.post(`repos/${config.repository}/git/trees`, options)).body
.sha;
}
// Create a commit and return commit SHA
async function createCommit(parent, tree, message) {
logger.debug(`createCommit(${parent}, ${tree}, ${message})`);
const { gitAuthor, gitPrivateKey } = config;
const now = moment();
let author;
if (gitAuthor) {
logger.trace('Setting gitAuthor');
author = {
name: gitAuthor.name,
email: gitAuthor.address,
date: now.format(),
};
}
const body = {
message,
parents: [parent],
tree,
};
if (author) {
body.author = author;
// istanbul ignore if
if (gitPrivateKey) {
logger.debug('Found gitPrivateKey');
const privKeyObj = openpgp.key.readArmored(gitPrivateKey).keys[0];
const commit = `tree ${tree}\nparent ${parent}\nauthor ${author.name} <${
author.email
}> ${now.format('X ZZ')}\ncommitter ${author.name} <${
author.email
}> ${now.format('X ZZ')}\n\n${message}`;
const { signature } = await openpgp.sign({
data: openpgp.util.str2Uint8Array(commit),
privateKeys: privKeyObj,
detached: true,
armor: true,
});
body.signature = signature;
}
}
const options = {
body,
};
// istanbul ignore if
if (config.forkToken) {
options.token = config.forkToken;
}
return (await get.post(`repos/${config.repository}/git/commits`, options))
.body.sha;
}
async function getCommitMessages() {
logger.debug('getCommitMessages');
try {
const res = await get(`repos/${config.repository}/commits`);
return res.body.map(commit => commit.commit.message);
} catch (err) {
// istanbul ignore if
if (err.message.includes('Bad credentials')) {
throw err;
}
logger.error(expandError(err), `getCommitMessages error`);
return [];
}
}
function expandError(err) {
return {
err,
message: err.message,
body: err.response ? err.response.body : undefined,
};
}