const handlebars = require('handlebars'); const changelogHelper = require('./changelog'); const showdown = require('showdown'); const converter = new showdown.Converter(); converter.setFlavor('github'); module.exports = { ensurePr, checkAutoMerge, }; // Ensures that PR exists with matching title/body async function ensurePr(prConfig) { const config = { ...prConfig }; logger.trace({ config }, 'ensurePr'); // If there is a group, it will use the config of the first upgrade in the array const { branchName, upgrades } = config; // Check if existing PR exists const existingPr = await platform.getBranchPr(branchName); config.upgrades = []; const branchStatus = await platform.getBranchStatus( branchName, config.requiredStatusChecks ); // Only create a PR if a branch automerge has failed if (config.automerge === true && config.automergeType.startsWith('branch')) { logger.debug( `Branch is configured for branch automerge, branchStatus is: ${branchStatus}` ); if (config.forcePr || branchStatus === 'failure') { logger.debug(`Branch tests failed, so will create PR`); } else { return null; } } if (config.prCreation === 'status-success') { logger.debug('Checking branch combined status'); if (branchStatus !== 'success') { logger.debug(`Branch status is "${branchStatus}" - not creating PR`); return null; } logger.debug('Branch status success'); } else if (config.prCreation === 'not-pending' && !existingPr) { logger.debug('Checking branch combined status'); if (branchStatus === 'pending' || branchStatus === 'running') { logger.debug(`Branch status is "${branchStatus}" - checking timeout`); const lastCommitTime = await platform.getBranchLastCommitTime(branchName); const currentTime = new Date(); const millisecondsPerHour = 1000 * 60 * 60; const elapsedHours = Math.round( (currentTime.getTime() - lastCommitTime.getTime()) / millisecondsPerHour ); if (elapsedHours < config.prNotPendingHours) { logger.debug( `Branch is ${elapsedHours} hours old - skipping PR creation` ); return null; } logger.debug( `prNotPendingHours=${ config.prNotPendingHours } threshold hit - creating PR` ); } logger.debug('Branch status success'); } const processedUpgrades = []; const issueRe = /([\s(])#(\d+)([)\s]?)/g; const commitRepos = []; // Get changelog and then generate template strings for (const upgrade of upgrades) { const upgradeKey = `${upgrade.depName}-${upgrade.changeLogFromVersion}-${ upgrade.changeLogToVersion }`; if (processedUpgrades.indexOf(upgradeKey) !== -1) { continue; // eslint-disable-line no-continue } processedUpgrades.push(upgradeKey); const logJSON = await changelogHelper.getChangeLogJSON( upgrade.depName, upgrade.changeLogFromVersion, upgrade.changeLogToVersion ); if (logJSON) { upgrade.githubName = logJSON.project.github; upgrade.releases = []; if (!commitRepos.includes(upgrade.githubName)) { commitRepos.push(upgrade.githubName); logJSON.versions.forEach(version => { const release = { ...version }; release.commits = []; if (release.changes) { release.changes.forEach(change => { const commit = { ...change }; delete commit.date; commit.shortSha = change.sha.slice(0, 7); commit.url = `${logJSON.project.repository}/commit/${change.sha}`; if (change.message) { [commit.message] = change.message.split('\n'); if (!config.isGitHub || config.privateRepo === true) { commit.message = commit.message.replace( issueRe, `$1[#$2](${upgrade.repositoryUrl}/issues/$2)$3` ); } } release.commits.push(commit); }); } upgrade.releases.push(release); }); } } config.upgrades.push(upgrade); } // Update the config object Object.assign(config, upgrades[0]); if (config.errors && config.errors.length) { config.hasErrors = true; } if (config.warnings && config.warnings.length) { config.hasWarnings = true; } const prTitle = handlebars.compile(config.prTitle)(config); let prBody = handlebars.compile(config.prBody)(config); // Put a zero width space after every @ symbol to prevent unintended hyperlinking prBody = prBody.replace(/@/g, '@​'); // Public GitHub repos need links prevented - see #489 prBody = prBody.replace(issueRe, '$1#​$2$3'); // convert escaped backticks back to ` const backTickRe = /`([^/]*?)`/g; prBody = prBody.replace(backTickRe, '`$1`'); // It would be nice to abstract this to the platform layer but made difficult due to our create/update check if (config.isGitLab) { // Convert to HTML using GitHub-flavoured markdown as it is more feature-rich than GitLab's flavour prBody = converter .makeHtml(prBody) .replace(/<\/?h4[^>]*>/g, '**') // See #954 .replace(/Pull Request/g, 'Merge Request') .replace(/PR/g, 'MR'); } else if (config.isVsts) { // Remove any HTML we use prBody = prBody .replace('', '**') .replace('', '**') .replace('
', '') .replace('
', ''); } try { if (existingPr) { if (config.automerge && branchStatus === 'failure') { logger.debug(`Setting assignees and reviewers as status checks failed`); await addAssigneesReviewers(config, existingPr); } // Check if existing PR needs updating if (existingPr.title === prTitle && existingPr.body === prBody) { logger.info(`${existingPr.displayNumber} does not need updating`); return existingPr; } // PR must need updating if (existingPr.title !== prTitle) { logger.debug( { oldPrTitle: existingPr.title, newPrTitle: prTitle, }, 'PR title changed' ); } else { logger.debug( { prTitle, oldPrBody: existingPr.body, newPrBody: prBody, }, 'PR body changed' ); } await platform.updatePr(existingPr.number, prTitle, prBody); logger.info({ pr: existingPr.displayNumber }, `Updated PR`); return existingPr; } logger.info({ branchName, prTitle }, `Creating PR`); let pr; try { pr = await platform.createPr( branchName, prTitle, prBody, config.labels, false, config.statusCheckVerify ); } catch (err) { logger.warn({ err }, `Failed to create PR`); return null; } // Skip assign and review if automerging PR if ( config.automerge && config.automergeType === 'pr' && branchStatus !== 'failure' ) { logger.debug( `Skipping assignees and reviewers as automerge=${config.automerge}` ); } else { await addAssigneesReviewers(config, pr); } logger.info(`Created ${pr.displayNumber}`); return pr; } catch (err) { logger.error({ err }, 'Failed to ensure PR:', err); } return null; } async function addAssigneesReviewers(config, pr) { if (config.assignees.length > 0) { try { const assignees = config.assignees.map( assignee => assignee.length && assignee[0] === '@' ? assignee.slice(1) : assignee ); await platform.addAssignees(pr.number, assignees); logger.info({ assignees: config.assignees }, 'Added assignees'); } catch (err) { logger.info( { assignees: config.assignees, err }, 'Failed to add assignees' ); } } if (config.reviewers.length > 0) { try { const reviewers = config.reviewers.map( reviewer => reviewer.length && reviewer[0] === '@' ? reviewer.slice(1) : reviewer ); await platform.addReviewers(pr.number, reviewers); logger.info({ reviewers: config.reviewers }, 'Added reviewers'); } catch (err) { logger.info( { assignees: config.assignees, err }, 'Failed to add reviewers' ); } } } async function checkAutoMerge(pr, config) { logger.trace({ config }, 'checkAutoMerge'); logger.debug(`Checking #${pr.number} for automerge`); if (config.automerge === true && config.automergeType === 'pr') { logger.info('PR is configured for automerge'); // Return if PR not ready for automerge if (pr.mergeable !== true) { logger.info('PR is not mergeable'); logger.debug({ pr }); return false; } if (config.requiredStatusChecks && pr.mergeable_state === 'unstable') { logger.info('PR mergeable state is unstable'); return false; } // Check branch status const branchStatus = await platform.getBranchStatus( pr.head.ref, config.requiredStatusChecks ); logger.debug(`branchStatus=${branchStatus}`); if (branchStatus !== 'success') { logger.info('Branch status is not "success"'); return false; } // Check if it's been touched if (!pr.canRebase) { logger.info('PR is ready for automerge but has been modified'); return false; } // Let's merge this logger.info(`Automerging #${pr.number}`); return platform.mergePr(pr.number, config.branchName); } logger.debug('No automerge'); return false; }