UNPKG

@process-engine/ci_tools

Version:
336 lines (268 loc) 11.8 kB
import * as chalk from 'chalk'; import fetch from 'cross-fetch'; import * as moment from 'moment'; import * as yargsParser from 'yargs-parser'; import { PullRequest, getMergedPullRequests } from '../../github/pull_requests'; import { getCurrentApiBaseUrlWithAuth, getCurrentRepoNameWithOwner, getGitCommitListSince, getGitTagDate, getGitTagList, } from '../../git/git'; import { getNextVersion, getPrevVersionTag, getVersionTag } from '../../versions/git_helpers'; import { parseVersion } from '../../versions/parse_version'; type CommitFromApi = any; type IssueFromApi = any; type PreVersion = { tag: string; date: Date; }; type PullRequestGroup = { preVersion: PreVersion; pullRequests: PullRequest[]; }; const GITHUB_REPO = getCurrentRepoNameWithOwner(); const COMMIT_API_URI = getCurrentApiBaseUrlWithAuth('/commits/:commit_sha'); const ISSUES_API_URI = getCurrentApiBaseUrlWithAuth('/issues?state=closed&since=:since&page=:page'); const BADGE = '[create-changelog]\t'; const DEFAULT_MODE = 'node'; // A typical release cycle lasts for roughly half a year. A few Additional weeks are applied as a buffer. const CONSIDER_PULL_REQUESTS_WEEKS_BACK = 30; /** * Creates a changelog based on data available in Git and GitHub: * * - Git: latest commits and tags * - GitHub: PRs and Issues */ export async function run(...args): Promise<boolean> { const argv = yargsParser(args, { alias: { help: ['h'] }, default: { mode: DEFAULT_MODE } }); const mode = argv.mode; let startRef = args[0]; if (!startRef) { startRef = await getPrevVersionTag(mode); console.log(`${BADGE}No start ref given, using: "${startRef}"`); } const changelogText = await getChangelogText(mode, startRef); console.log(changelogText); return true; } export async function getChangelogText(mode: string, startRef: string): Promise<string> { if (startRef == null) { return ''; } const apiResponse = await getCommitFromApi(startRef); if (apiResponse.commit == null) { console.error(chalk.red(`${BADGE} API responded with: ${apiResponse.message}`)); process.exit(3); } const defaultStartDate = moment().subtract(CONSIDER_PULL_REQUESTS_WEEKS_BACK, 'weeks'); const startCommitDate = apiResponse.commit?.committer?.date ?? defaultStartDate; const startDate = moment(startCommitDate).subtract(CONSIDER_PULL_REQUESTS_WEEKS_BACK, 'weeks').toISOString(); const endRef = 'HEAD'; const nextVersion = await getNextVersion(mode); if (nextVersion == null) { console.error(chalk.red(`${BADGE}Could not determine nextVersion!`)); process.exit(3); } const nextVersionTag = getVersionTag(nextVersion); printInfo(startRef, startDate, endRef, nextVersion, nextVersionTag); const mergedPullRequestsSince = await getMergedPullRequests(startDate); const mergedPullRequests = filterPullRequestsForBranch(mergedPullRequestsSince, '', startRef, startDate); console.log(`${BADGE}Found ${mergedPullRequests.length} merged pull requests since the last stable release`); const closedIssuesSince = await getClosedIssuesFromApi(startDate); const issuesClosedByPullRequest = closedIssuesSince.filter((issue) => { const pullRequestClosingThisIssue = mergedPullRequests.find( (pr) => pr.closedIssueNumbers.indexOf(issue.number) !== -1 ); return pullRequestClosingThisIssue != null; }); console.log(`${BADGE}Found ${issuesClosedByPullRequest.length} closed issues since the last stable release`); const previousPreVersions = getPreviousPreVersionsInReleaseChannel(nextVersion); const currentPreVersion = { tag: nextVersionTag, date: new Date() }; const preVersions = [currentPreVersion].concat(previousPreVersions); const shouldShowPreviousPreVersions = previousPreVersions.length > 0; const groupedPullRequests = groupPullRequestsByPreVersion(mergedPullRequests, preVersions); const mergedPullRequestsText = groupedPullRequests .map((pullRequestGroup, pullRequestGroupIndex) => { const preVersion = pullRequestGroup.preVersion; let text = ''; if (shouldShowPreviousPreVersions) { const header = pullRequestGroupIndex === 0 ? `${preVersion.tag} (this release)` : `[${preVersion.tag}](https://github.com/${GITHUB_REPO}/releases/tag/${preVersion.tag})`; text += `**${header}**\n\n`; } for (const pr of pullRequestGroup.pullRequests) { const mergedAtDate = moment(pr.mergedAt); const mergedAtString = mergedAtDate.format('YYYY-MM-DD'); const title = ensureSpaceAfterLeadingEmoji(pr.title); let breakingPrefix = ''; if (pr.isBreakingChange && !title.toLowerCase().includes('breaking')) { breakingPrefix = `**BREAKING CHANGE:** `; } text += `- ${breakingPrefix}#${pr.number} ${title} (merged ${mergedAtString})\n`; } if (pullRequestGroup.pullRequests.length === 0) { text += '- none\n'; } return text; }) .join('\n'); const issuesClosedByPullRequestText = issuesClosedByPullRequest .map((issue: IssueFromApi): string => { const title = ensureSpaceAfterLeadingEmoji(issue.title); let breakingPrefix = ''; if (issue.isBreakingChange && !title.toLowerCase().includes('breaking')) { breakingPrefix = `**BREAKING CHANGE:** `; } return `- ${breakingPrefix}#${issue.number} ${title}`; }) .join('\n'); const now = moment(); const changelogText = ` # Changelog ${nextVersionTag} (${now.format('YYYY-MM-DD')}) This changelog covers the changes between [${startRef} and ${nextVersionTag}](https://github.com/${GITHUB_REPO}/compare/${startRef}...${nextVersionTag}). For further reference, please refer to the changelog of the previous version, [${startRef}](https://github.com/${GITHUB_REPO}/releases/tag/${startRef}). ## Merged Pull Requests ${mergedPullRequestsText || '- none'} ## Corresponding Issues ${issuesClosedByPullRequestText || '- none'} `.trim(); return changelogText; } function getPreviousPreVersionsInReleaseChannel(nextVersion: string): PreVersion[] { const nextVersionParsed = parseVersion(nextVersion); const releasechannel = nextVersionParsed.releaseChannelName; const addPreviousPreReleases = releasechannel === 'beta' || releasechannel === 'alpha'; if (!addPreviousPreReleases) { return []; } const nextVersionTag = getVersionTag(nextVersion); const versionWithoutReleaseChannelNumber = nextVersionTag.replace(/\d$/, ''); const gitTagList = getGitTagList(); return gitTagList .split('\n') .filter((line) => line.startsWith(versionWithoutReleaseChannelNumber)) .map((tag) => { return { tag: tag, date: moment(getGitTagDate(tag)).toDate(), }; }); } async function getCommitFromApi(ref: string): Promise<CommitFromApi> { const url = COMMIT_API_URI.replace(':commit_sha', ref); const response = await fetch(url); return response.json(); } async function getClosedIssuesFromApi(since: string, page: number = 1): Promise<IssueFromApi[]> { const url = ISSUES_API_URI.replace(':since', since).replace(':page', page.toString()); const response = await fetch(url); const issues = (await response.json()) as IssueFromApi[]; const relevantIssues = issues .filter((issue) => !issue.pull_request) .map((issue) => { const isBreakingChange = issue.labels.find((label) => label.name.toLowerCase().includes('breaking')); return { ...issue, isBreakingChange, }; }); if (relevantIssues.length > 0) { const nextPageIssues = await getClosedIssuesFromApi(since, page + 1); return [...relevantIssues].concat(nextPageIssues); } return relevantIssues; } function printInfo( startRef: string, startDate: string, endRef: string, nextVersion: string, nextVersionTag: string ): void { console.log(`${BADGE}startRef:`, startRef); console.log(`${BADGE}startDate:`, startDate); console.log(`${BADGE}endRef:`, endRef); console.log(`${BADGE}nextVersion:`, nextVersion); console.log(`${BADGE}nextVersionTag:`, nextVersionTag); console.log(''); } function ensureSpaceAfterLeadingEmoji(text: string): string { const emojiWithoutTrailingSpaceRegex = /([\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}])(\S)/gu; return text.replace( emojiWithoutTrailingSpaceRegex, (substring: string, emojiMatch: string, characterAfterEmojiMatch: string): string => { return `${emojiMatch} ${characterAfterEmojiMatch}`; } ); } function filterPullRequestsForBranch( prs: PullRequest[], branchName: string, startRef: string, since: string ): PullRequest[] { const allShaInCurrentBranch = getGitCommitListSince(branchName, since).split('\n'); const allShaInStartRef = getGitCommitListSince(startRef, since).split('\n'); const newShaInCurrentBranch = allShaInCurrentBranch.filter( (currentSha: string): boolean => allShaInStartRef.indexOf(currentSha) === -1 ); const filteredPrs = prs.filter( (pr: PullRequest): boolean => newShaInCurrentBranch.indexOf(pr.headSha) !== -1 || newShaInCurrentBranch.indexOf(pr.mergeCommitSha) !== -1 ); return filteredPrs; } function groupPullRequestsByPreVersion(pullRequests: PullRequest[], preVersions: PreVersion[]): PullRequestGroup[] { const groupedPullRequests: PullRequestGroup[] = []; const sortedPullRequests = sortPullRequestsByMergeDateDescending(pullRequests); const sortedPreVersions = sortPreVersionsByDateDescending(preVersions); let currentPreVersionIndex = 0; let currentPreVersion = sortedPreVersions[currentPreVersionIndex]; let nextPreVersion = sortedPreVersions[currentPreVersionIndex + 1]; let pullRequestsForCurrentPreVersion = []; let groupForCurrentPreVersionExists = false; for (const pullRequest of sortedPullRequests) { if (currentPreVersion == null) { break; } if (!groupForCurrentPreVersionExists) { const pullRequestGroup = { preVersion: currentPreVersion, pullRequests: pullRequestsForCurrentPreVersion, }; groupedPullRequests[currentPreVersionIndex] = pullRequestGroup; groupForCurrentPreVersionExists = true; } const mergedAtDate = moment(pullRequest.mergedAt); const introducedInCurrentVersion = nextPreVersion != null ? mergedAtDate.isBefore(currentPreVersion.date) && mergedAtDate.isAfter(nextPreVersion.date) : mergedAtDate.isBefore(currentPreVersion.date); if (!introducedInCurrentVersion) { currentPreVersionIndex++; currentPreVersion = sortedPreVersions[currentPreVersionIndex]; nextPreVersion = sortedPreVersions[currentPreVersionIndex + 1]; pullRequestsForCurrentPreVersion = []; groupForCurrentPreVersionExists = false; } pullRequestsForCurrentPreVersion.push(pullRequest); } return groupedPullRequests; } function sortPreVersionsByDateDescending(preVersions: PreVersion[]): PreVersion[] { const clonedPreVersions = [...preVersions]; return clonedPreVersions.sort((preVersion1, preVersion2) => { return preVersion2.date.getTime() - preVersion1.date.getTime(); }); } function sortPullRequestsByMergeDateDescending(pullRequests: PullRequest[]): PullRequest[] { const clonedPullRequests = [...pullRequests]; return clonedPullRequests.sort((pullRequest1, pullRequest2) => { return moment(pullRequest2.mergedAt).diff(moment(pullRequest1.mergedAt)); }); }