792 lines
		
	
	
	
		
			22 KiB
			
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			792 lines
		
	
	
	
		
			22 KiB
			
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| #!/usr/bin/env node
 | |
| 
 | |
| import { Octokit } from '@octokit/rest';
 | |
| import { GitProcess } from 'dugite';
 | |
| 
 | |
| import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
 | |
| import { resolve as _resolve } from 'node:path';
 | |
| 
 | |
| import { ELECTRON_DIR } from '../../lib/utils';
 | |
| import { createGitHubTokenStrategy } from '../github-token';
 | |
| import { ELECTRON_ORG, ELECTRON_REPO } from '../types';
 | |
| 
 | |
| const octokit = new Octokit({
 | |
|   authStrategy: createGitHubTokenStrategy(ELECTRON_REPO)
 | |
| });
 | |
| 
 | |
| const MAX_FAIL_COUNT = 3;
 | |
| const CHECK_INTERVAL = 5000;
 | |
| 
 | |
| const TROP_LOGIN = 'trop[bot]';
 | |
| 
 | |
| const NO_NOTES = 'No notes';
 | |
| 
 | |
| const docTypes = new Set(['doc', 'docs']);
 | |
| const featTypes = new Set(['feat', 'feature']);
 | |
| const fixTypes = new Set(['fix']);
 | |
| const otherTypes = new Set([
 | |
|   'spec',
 | |
|   'build',
 | |
|   'test',
 | |
|   'chore',
 | |
|   'deps',
 | |
|   'refactor',
 | |
|   'tools',
 | |
|   'perf',
 | |
|   'style',
 | |
|   'ci'
 | |
| ]);
 | |
| const knownTypes = new Set([
 | |
|   ...docTypes.keys(),
 | |
|   ...featTypes.keys(),
 | |
|   ...fixTypes.keys(),
 | |
|   ...otherTypes.keys()
 | |
| ]);
 | |
| 
 | |
| const getCacheDir = () =>
 | |
|   process.env.NOTES_CACHE_PATH || _resolve(__dirname, '.cache');
 | |
| 
 | |
| /**
 | |
|  ***
 | |
|  **/
 | |
| 
 | |
| type MinimalPR = {
 | |
|   title: string;
 | |
|   body: string | null;
 | |
|   number: number;
 | |
|   labels: {
 | |
|     name: string;
 | |
|   }[];
 | |
|   base: { repo: { name: string; owner: { login: string } } };
 | |
| };
 | |
| 
 | |
| // link to a GitHub item, e.g. an issue or pull request
 | |
| class GHKey {
 | |
|   // eslint-disable-next-line no-useless-constructor
 | |
|   constructor (
 | |
|     public readonly owner: string,
 | |
|     public readonly repo: string,
 | |
|     public readonly number: number
 | |
|   ) {}
 | |
| 
 | |
|   static NewFromPull (pull: MinimalPR) {
 | |
|     const owner = pull.base.repo.owner.login;
 | |
|     const repo = pull.base.repo.name;
 | |
|     const number = pull.number;
 | |
|     return new GHKey(owner, repo, number);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class Commit {
 | |
|   public isBreakingChange = false;
 | |
|   public note: string | null = null;
 | |
|   public trops = new Map<string, GHKey>();
 | |
|   public readonly prKeys = new Set<GHKey>();
 | |
|   public revertHash: string | null = null;
 | |
|   public semanticType: string | null = null;
 | |
|   public subject: string | null = null;
 | |
| 
 | |
|   // eslint-disable-next-line no-useless-constructor
 | |
|   constructor (
 | |
|     public readonly hash: string,
 | |
|     public readonly owner: string,
 | |
|     public readonly repo: string
 | |
|   ) {}
 | |
| }
 | |
| 
 | |
| class Pool {
 | |
|   public commits: Commit[] = [];
 | |
|   public processedHashes = new Set<string>();
 | |
|   public pulls: Record<number, MinimalPR> = Object.create(null);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  ***
 | |
|  **/
 | |
| 
 | |
| const runGit = async (dir: string, args: string[]) => {
 | |
|   const response = await GitProcess.exec(args, dir);
 | |
|   if (response.exitCode !== 0) {
 | |
|     throw new Error(response.stderr.trim());
 | |
|   }
 | |
|   return response.stdout.trim();
 | |
| };
 | |
| 
 | |
| const getCommonAncestor = async (
 | |
|   dir: string,
 | |
|   point1: string,
 | |
|   point2: string
 | |
| ) => {
 | |
|   return runGit(dir, ['merge-base', point1, point2]);
 | |
| };
 | |
| 
 | |
| const getNoteFromClerk = async (ghKey: GHKey) => {
 | |
|   const comments = await getComments(ghKey);
 | |
|   if (!comments || !comments.data) return;
 | |
| 
 | |
|   const CLERK_LOGIN = 'release-clerk[bot]';
 | |
|   const CLERK_NO_NOTES = '**No Release Notes**';
 | |
|   const PERSIST_LEAD = '**Release Notes Persisted**';
 | |
|   const QUOTE_LEAD = '> ';
 | |
| 
 | |
|   for (const comment of comments.data.reverse()) {
 | |
|     if (comment.user?.login !== CLERK_LOGIN) {
 | |
|       continue;
 | |
|     }
 | |
|     if (comment.body === CLERK_NO_NOTES) {
 | |
|       return NO_NOTES;
 | |
|     }
 | |
|     if (comment.body?.startsWith(PERSIST_LEAD)) {
 | |
|       let lines = comment.body
 | |
|         .slice(PERSIST_LEAD.length)
 | |
|         .trim() // remove PERSIST_LEAD
 | |
|         .split(/\r?\n/) // split into lines
 | |
|         .map((line) => line.trim())
 | |
|         .map((line) => line.replace('<', '<'))
 | |
|         .map((line) => line.replace('>', '>'))
 | |
|         .filter((line) => line.startsWith(QUOTE_LEAD)) // notes are quoted
 | |
|         .map((line) => line.slice(QUOTE_LEAD.length)); // unquote the lines
 | |
| 
 | |
|       const firstLine = lines.shift();
 | |
|       // indent anything after the first line to ensure that
 | |
|       // multiline notes with their own sub-lists don't get
 | |
|       // parsed in the markdown as part of the top-level list
 | |
|       // (example: https://github.com/electron/electron/pull/25216)
 | |
|       lines = lines.map((line) => '  ' + line);
 | |
|       return [firstLine, ...lines]
 | |
|         .join('\n') // join the lines
 | |
|         .trim();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   console.warn(`WARN: no notes found in ${buildPullURL(ghKey)}`);
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Looks for our project's conventions in the commit message:
 | |
|  *
 | |
|  * 'semantic: some description' -- sets semanticType, subject
 | |
|  * 'some description (#99999)' -- sets subject, pr
 | |
|  * 'Merge pull request #99999 from ${branchname}' -- sets pr
 | |
|  * 'This reverts commit ${sha}' -- sets revertHash
 | |
|  * line starting with 'BREAKING CHANGE' in body -- sets isBreakingChange
 | |
|  * 'Backport of #99999' -- sets pr
 | |
|  */
 | |
| const parseCommitMessage = (commitMessage: string, commit: Commit) => {
 | |
|   const { owner, repo } = commit;
 | |
| 
 | |
|   // split commitMessage into subject & body
 | |
|   let subject = commitMessage;
 | |
|   let body = '';
 | |
|   const pos = subject.indexOf('\n');
 | |
|   if (pos !== -1) {
 | |
|     body = subject.slice(pos).trim();
 | |
|     subject = subject.slice(0, pos).trim();
 | |
|   }
 | |
| 
 | |
|   // if the subject ends in ' (#dddd)', treat it as a pull request id
 | |
|   let match;
 | |
|   if ((match = subject.match(/^(.*)\s\(#(\d+)\)$/))) {
 | |
|     commit.prKeys.add(new GHKey(owner, repo, parseInt(match[2])));
 | |
|     subject = match[1];
 | |
|   }
 | |
| 
 | |
|   // if the subject begins with 'word:', treat it as a semantic commit
 | |
|   if ((match = subject.match(/^(\w+):\s(.*)$/))) {
 | |
|     const semanticType = match[1].toLocaleLowerCase();
 | |
|     if (knownTypes.has(semanticType)) {
 | |
|       commit.semanticType = semanticType;
 | |
|       subject = match[2];
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Check for GitHub commit message that indicates a PR
 | |
|   if ((match = subject.match(/^Merge pull request #(\d+) from (.*)$/))) {
 | |
|     commit.prKeys.add(new GHKey(owner, repo, parseInt(match[1])));
 | |
|   }
 | |
| 
 | |
|   // Check for a comment that indicates a PR
 | |
|   const backportPattern =
 | |
|     /(?:^|\n)(?:manual |manually )?backport.*(?:#(\d+)|\/pull\/(\d+))/im;
 | |
|   if ((match = commitMessage.match(backportPattern))) {
 | |
|     // This might be the first or second capture group depending on if it's a link or not.
 | |
|     const backportNumber = match[1]
 | |
|       ? parseInt(match[1], 10)
 | |
|       : parseInt(match[2], 10);
 | |
|     commit.prKeys.add(new GHKey(owner, repo, backportNumber));
 | |
|   }
 | |
| 
 | |
|   // https://help.github.com/articles/closing-issues-using-keywords/
 | |
|   if (
 | |
|     body.match(
 | |
|       /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|for)\s#(\d+)\b/i
 | |
|     )
 | |
|   ) {
 | |
|     commit.semanticType = commit.semanticType || 'fix';
 | |
|   }
 | |
| 
 | |
|   // https://www.conventionalcommits.org/en
 | |
|   if (
 | |
|     commitMessage
 | |
|       .split(/\r?\n/) // split into lines
 | |
|       .map((line) => line.trim())
 | |
|       .some((line) => line.startsWith('BREAKING CHANGE'))
 | |
|   ) {
 | |
|     commit.isBreakingChange = true;
 | |
|   }
 | |
| 
 | |
|   // Check for a reversion commit
 | |
|   if ((match = body.match(/This reverts commit ([a-f0-9]{40})\./))) {
 | |
|     commit.revertHash = match[1];
 | |
|   }
 | |
| 
 | |
|   commit.subject = subject.trim();
 | |
|   return commit;
 | |
| };
 | |
| 
 | |
| const parsePullText = (pull: MinimalPR, commit: Commit) =>
 | |
|   parseCommitMessage(`${pull.title}\n\n${pull.body}`, commit);
 | |
| 
 | |
| const getLocalCommitHashes = async (dir: string, ref: string) => {
 | |
|   const args = ['log', '--format=%H', ref];
 | |
|   return (await runGit(dir, args))
 | |
|     .split(/\r?\n/) // split into lines
 | |
|     .map((hash) => hash.trim());
 | |
| };
 | |
| 
 | |
| // return an array of Commits
 | |
| const getLocalCommits = async (
 | |
|   module: LocalRepo,
 | |
|   point1: string,
 | |
|   point2: string
 | |
| ) => {
 | |
|   const { owner, repo, dir } = module;
 | |
| 
 | |
|   const fieldSep = ',';
 | |
|   const format = ['%H', '%s'].join(fieldSep);
 | |
|   const args = [
 | |
|     'log',
 | |
|     '--cherry-pick',
 | |
|     '--right-only',
 | |
|     '--first-parent',
 | |
|     `--format=${format}`,
 | |
|     `${point1}..${point2}`
 | |
|   ];
 | |
|   const logs = (await runGit(dir, args))
 | |
|     .split(/\r?\n/) // split into lines
 | |
|     .map((field) => field.trim());
 | |
| 
 | |
|   const commits = [];
 | |
|   for (const log of logs) {
 | |
|     if (!log) {
 | |
|       continue;
 | |
|     }
 | |
|     const [hash, subject] = log.split(fieldSep, 2).map((field) => field.trim());
 | |
|     commits.push(parseCommitMessage(subject, new Commit(hash, owner, repo)));
 | |
|   }
 | |
|   return commits;
 | |
| };
 | |
| 
 | |
| const checkCache = async <T>(
 | |
|   name: string,
 | |
|   operation: () => Promise<T>
 | |
| ): Promise<T> => {
 | |
|   const filename = _resolve(getCacheDir(), name);
 | |
|   if (existsSync(filename)) {
 | |
|     return JSON.parse(readFileSync(filename, 'utf8'));
 | |
|   }
 | |
|   process.stdout.write('.');
 | |
|   const response = await operation();
 | |
|   if (response) {
 | |
|     writeFileSync(filename, JSON.stringify(response));
 | |
|   }
 | |
|   return response;
 | |
| };
 | |
| 
 | |
| // helper function to add some resiliency to volatile GH api endpoints
 | |
| async function runRetryable<T> (
 | |
|   fn: () => Promise<T>,
 | |
|   maxRetries: number
 | |
| ): Promise<T | null> {
 | |
|   let lastError: Error & { status?: number };
 | |
|   for (let i = 0; i < maxRetries; i++) {
 | |
|     try {
 | |
|       return await fn();
 | |
|     } catch (error) {
 | |
|       await new Promise((resolve) => setTimeout(resolve, CHECK_INTERVAL));
 | |
|       lastError = error as any;
 | |
|     }
 | |
|   }
 | |
|   // Silently eat 404s.
 | |
|   // Silently eat 422s, which come from "No commit found for SHA"
 | |
|   // eslint-disable-next-line no-throw-literal
 | |
|   if (lastError!.status !== 404 && lastError!.status !== 422) throw lastError!;
 | |
| 
 | |
|   return null;
 | |
| }
 | |
| 
 | |
| const getPullCacheFilename = (ghKey: GHKey) =>
 | |
|   `${ghKey.owner}-${ghKey.repo}-pull-${ghKey.number}`;
 | |
| 
 | |
| const getCommitPulls = async (owner: string, repo: string, hash: string) => {
 | |
|   const name = `${owner}-${repo}-commit-${hash}`;
 | |
|   const retryableFunc = async () => {
 | |
|     const { data } = await octokit.repos.listPullRequestsAssociatedWithCommit({
 | |
|       owner,
 | |
|       repo,
 | |
|       commit_sha: hash
 | |
|     });
 | |
|     return {
 | |
|       data
 | |
|     };
 | |
|   };
 | |
|   let ret = await checkCache(name, () =>
 | |
|     runRetryable(retryableFunc, MAX_FAIL_COUNT)
 | |
|   );
 | |
| 
 | |
|   // only merged pulls belong in release notes
 | |
|   if (ret && ret.data) {
 | |
|     ret.data = ret.data.filter((pull) => pull.merged_at);
 | |
|   }
 | |
| 
 | |
|   // cache the pulls
 | |
|   if (ret && ret.data) {
 | |
|     for (const pull of ret.data) {
 | |
|       const cachefile = getPullCacheFilename(GHKey.NewFromPull(pull));
 | |
|       const payload = { ...ret, data: pull };
 | |
|       await checkCache(cachefile, async () => payload);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // ensure the return value has the expected structure, even on failure
 | |
|   if (!ret || !ret.data) {
 | |
|     ret = { data: [] };
 | |
|   }
 | |
| 
 | |
|   return ret;
 | |
| };
 | |
| 
 | |
| const getPullRequest = async (ghKey: GHKey) => {
 | |
|   const { number, owner, repo } = ghKey;
 | |
|   const name = getPullCacheFilename(ghKey);
 | |
|   const retryableFunc = () =>
 | |
|     octokit.pulls.get({ pull_number: number, owner, repo });
 | |
|   return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
 | |
| };
 | |
| 
 | |
| const getComments = async (ghKey: GHKey) => {
 | |
|   const { number, owner, repo } = ghKey;
 | |
|   const name = `${owner}-${repo}-issue-${number}-comments`;
 | |
|   const retryableFunc = () =>
 | |
|     octokit.issues.listComments({
 | |
|       issue_number: number,
 | |
|       owner,
 | |
|       repo,
 | |
|       per_page: 100
 | |
|     });
 | |
|   return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
 | |
| };
 | |
| 
 | |
| type LocalRepo = {
 | |
|   owner: string;
 | |
|   repo: string;
 | |
|   dir: string;
 | |
| };
 | |
| 
 | |
| const addRepoToPool = async (
 | |
|   pool: Pool,
 | |
|   repo: LocalRepo,
 | |
|   from: string,
 | |
|   to: string
 | |
| ) => {
 | |
|   const commonAncestor = await getCommonAncestor(repo.dir, from, to);
 | |
| 
 | |
|   // mark the old branch's commits as old news
 | |
|   for (const oldHash of await getLocalCommitHashes(repo.dir, from)) {
 | |
|     pool.processedHashes.add(oldHash);
 | |
|   }
 | |
| 
 | |
|   // get the new branch's commits and the pulls associated with them
 | |
|   const commits = await getLocalCommits(repo, commonAncestor, to);
 | |
|   for (const commit of commits) {
 | |
|     const { owner, repo, hash } = commit;
 | |
|     for (const pull of (await getCommitPulls(owner, repo, hash)).data) {
 | |
|       commit.prKeys.add(GHKey.NewFromPull(pull));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   pool.commits.push(...commits);
 | |
| 
 | |
|   // add the pulls
 | |
|   for (const commit of commits) {
 | |
|     let prKey;
 | |
|     for (prKey of commit.prKeys.values()) {
 | |
|       const pull = await getPullRequest(prKey);
 | |
|       if (!pull || !pull.data) continue; // couldn't get it
 | |
|       pool.pulls[prKey.number] = pull.data;
 | |
|       parsePullText(pull.data, commit);
 | |
|     }
 | |
|   }
 | |
| };
 | |
| 
 | |
| type MinimalComment = {
 | |
|   user: {
 | |
|     login: string;
 | |
|   } | null;
 | |
|   body?: string;
 | |
| };
 | |
| 
 | |
| // @return Map<string,GHKey>
 | |
| //   where the key is a branch name (e.g. '7-1-x' or '8-x-y')
 | |
| //   and the value is a GHKey to the PR
 | |
| async function getMergedTrops (commit: Commit, pool: Pool) {
 | |
|   const branches = new Map();
 | |
| 
 | |
|   for (const prKey of commit.prKeys.values()) {
 | |
|     const pull = pool.pulls[prKey.number];
 | |
|     const mergedBranches = new Set(
 | |
|       (pull && pull && pull.labels ? pull.labels : [])
 | |
|         .map((label) =>
 | |
|           (label && label.name ? label.name : '').match(
 | |
|             /merged\/([0-9]+-[x0-9]-[xy0-9])/
 | |
|           )
 | |
|         )
 | |
|         .filter((match) => !!match)
 | |
|         .map((match) => match[1])
 | |
|     );
 | |
| 
 | |
|     if (mergedBranches.size > 0) {
 | |
|       const isTropComment = (comment: MinimalComment | null) =>
 | |
|         comment && comment.user && comment.user.login === TROP_LOGIN;
 | |
| 
 | |
|       const ghKey = GHKey.NewFromPull(pull);
 | |
|       const backportRegex =
 | |
|         /backported this PR to "(.*)",\s+please check out #(\d+)/;
 | |
|       const getBranchNameAndPullKey = (comment: MinimalComment) => {
 | |
|         const match = (comment && comment.body ? comment.body : '').match(
 | |
|           backportRegex
 | |
|         );
 | |
|         return match
 | |
|           ? <const>[match[1], new GHKey(ghKey.owner, ghKey.repo, parseInt(match[2]))]
 | |
|           : null;
 | |
|       };
 | |
| 
 | |
|       const comments = await getComments(ghKey);
 | |
|       (comments && comments.data ? comments.data : [])
 | |
|         .filter(isTropComment)
 | |
|         .map(getBranchNameAndPullKey)
 | |
|         .filter((pair) => !!pair)
 | |
|         .filter(([branch]) => mergedBranches.has(branch))
 | |
|         .forEach(([branch, key]) => branches.set(branch, key));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return branches;
 | |
| }
 | |
| 
 | |
| // @return the shorthand name of the branch that `ref` is on,
 | |
| //   e.g. a ref of '10.0.0-beta.1' will return '10-x-y'
 | |
| async function getBranchNameOfRef (ref: string, dir: string) {
 | |
|   const result = await runGit(dir, [
 | |
|     'branch',
 | |
|     '--all',
 | |
|     '--contains',
 | |
|     ref,
 | |
|     '--sort',
 | |
|     'version:refname'
 | |
|   ]);
 | |
|   return result
 | |
|     .split(/\r?\n/) // split into lines
 | |
|     .shift()! // we sorted by refname and want the first result
 | |
|     .match(/(?:\s?\*\s){0,1}(.*)/)![1] // if present, remove leading '* ' in case we're currently in that branch
 | |
|     .match(/(?:.*\/)?(.*)/)![1] // 'remote/origins/10-x-y' -> '10-x-y'
 | |
|     .trim();
 | |
| }
 | |
| 
 | |
| /***
 | |
|  ****  Main
 | |
|  ***/
 | |
| 
 | |
| const getNotes = async (fromRef: string, toRef: string, newVersion: string) => {
 | |
|   const cacheDir = getCacheDir();
 | |
|   if (!existsSync(cacheDir)) {
 | |
|     mkdirSync(cacheDir);
 | |
|   }
 | |
| 
 | |
|   const pool = new Pool();
 | |
|   const toBranch = await getBranchNameOfRef(toRef, ELECTRON_DIR);
 | |
| 
 | |
|   console.log(
 | |
|     `Generating release notes between '${fromRef}' and '${toRef}' for version '${newVersion}' in branch '${toBranch}'`
 | |
|   );
 | |
| 
 | |
|   // get the electron/electron commits
 | |
|   const electron = { owner: ELECTRON_ORG, repo: ELECTRON_REPO, dir: ELECTRON_DIR };
 | |
|   await addRepoToPool(pool, electron, fromRef, toRef);
 | |
| 
 | |
|   // remove any old commits
 | |
|   pool.commits = pool.commits.filter(
 | |
|     (commit) => !pool.processedHashes.has(commit.hash)
 | |
|   );
 | |
| 
 | |
|   // if a commit _and_ revert occurred in the unprocessed set, skip them both
 | |
|   for (const commit of pool.commits) {
 | |
|     const revertHash = commit.revertHash;
 | |
|     if (!revertHash) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     const revert = pool.commits.find((commit) => commit.hash === revertHash);
 | |
|     if (!revert) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     commit.note = NO_NOTES;
 | |
|     revert.note = NO_NOTES;
 | |
|     pool.processedHashes.add(commit.hash);
 | |
|     pool.processedHashes.add(revertHash);
 | |
|   }
 | |
| 
 | |
|   // ensure the commit has a note
 | |
|   for (const commit of pool.commits) {
 | |
|     for (const prKey of commit.prKeys.values()) {
 | |
|       if (commit.note) {
 | |
|         break;
 | |
|       }
 | |
|       commit.note = await getNoteFromClerk(prKey) || null;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // remove non-user-facing commits
 | |
|   pool.commits = pool.commits
 | |
|     .filter((commit) => commit && commit.note)
 | |
|     .filter((commit) => commit.note !== NO_NOTES)
 | |
|     .filter((commit) => commit.note!.match(/^[Bb]ump v\d+\.\d+\.\d+/) === null);
 | |
| 
 | |
|   for (const commit of pool.commits) {
 | |
|     commit.trops = await getMergedTrops(commit, pool);
 | |
|   }
 | |
| 
 | |
|   pool.commits = removeSupercededStackUpdates(pool.commits);
 | |
| 
 | |
|   const notes = {
 | |
|     breaking: [] as Commit[],
 | |
|     docs: [] as Commit[],
 | |
|     feat: [] as Commit[],
 | |
|     fix: [] as Commit[],
 | |
|     other: [] as Commit[],
 | |
|     unknown: [] as Commit[],
 | |
|     name: newVersion,
 | |
|     toBranch
 | |
|   };
 | |
| 
 | |
|   for (const commit of pool.commits) {
 | |
|     const str = commit.semanticType;
 | |
|     if (commit.isBreakingChange) {
 | |
|       notes.breaking.push(commit);
 | |
|     } else if (!str) {
 | |
|       notes.unknown.push(commit);
 | |
|     } else if (docTypes.has(str)) {
 | |
|       notes.docs.push(commit);
 | |
|     } else if (featTypes.has(str)) {
 | |
|       notes.feat.push(commit);
 | |
|     } else if (fixTypes.has(str)) {
 | |
|       notes.fix.push(commit);
 | |
|     } else if (otherTypes.has(str)) {
 | |
|       notes.other.push(commit);
 | |
|     } else {
 | |
|       notes.unknown.push(commit);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return notes;
 | |
| };
 | |
| 
 | |
| const compareVersions = (v1: string, v2: string) => {
 | |
|   const [split1, split2] = [v1.split('.'), v2.split('.')];
 | |
| 
 | |
|   if (split1.length !== split2.length) {
 | |
|     throw new Error(
 | |
|       `Expected version strings to have same number of sections: ${split1} and ${split2}`
 | |
|     );
 | |
|   }
 | |
|   for (let i = 0; i < split1.length; i++) {
 | |
|     const p1 = parseInt(split1[i], 10);
 | |
|     const p2 = parseInt(split2[i], 10);
 | |
| 
 | |
|     if (p1 > p2) return 1;
 | |
|     else if (p1 < p2) return -1;
 | |
|     // Continue checking the value if this portion is equal
 | |
|   }
 | |
| 
 | |
|   return 0;
 | |
| };
 | |
| 
 | |
| const removeSupercededStackUpdates = (commits: Commit[]) => {
 | |
|   const updateRegex = /^Updated ([a-zA-Z.]+) to v?([\d.]+)/;
 | |
|   const notupdates = [];
 | |
| 
 | |
|   const newest: Record<string, { commit: Commit; version: string }> = Object.create(null);
 | |
|   for (const commit of commits) {
 | |
|     const match = (commit.note || commit.subject)?.match(updateRegex);
 | |
|     if (!match) {
 | |
|       notupdates.push(commit);
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     const [, dep, version] = match;
 | |
|     if (!newest[dep] || compareVersions(version, newest[dep].version) > 0) {
 | |
|       newest[dep] = { commit, version };
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return [...notupdates, ...Object.values(newest).map((o) => o.commit)];
 | |
| };
 | |
| 
 | |
| /***
 | |
|  ****  Render
 | |
|  ***/
 | |
| 
 | |
| // @return the pull request's GitHub URL
 | |
| const buildPullURL = (ghKey: GHKey) =>
 | |
|   `https://github.com/${ghKey.owner}/${ghKey.repo}/pull/${ghKey.number}`;
 | |
| 
 | |
| const renderPull = (ghKey: GHKey) =>
 | |
|   `[#${ghKey.number}](${buildPullURL(ghKey)})`;
 | |
| 
 | |
| // @return the commit's GitHub URL
 | |
| const buildCommitURL = (commit: Commit) =>
 | |
|   `https://github.com/${commit.owner}/${commit.repo}/commit/${commit.hash}`;
 | |
| 
 | |
| const renderCommit = (commit: Commit) =>
 | |
|   `[${commit.hash.slice(0, 8)}](${buildCommitURL(commit)})`;
 | |
| 
 | |
| // @return a markdown link to the PR if available; otherwise, the git commit
 | |
| function renderLink (commit: Commit) {
 | |
|   const maybePull = commit.prKeys.values().next();
 | |
|   return maybePull.value ? renderPull(maybePull.value) : renderCommit(commit);
 | |
| }
 | |
| 
 | |
| // @return a terser branch name,
 | |
| //   e.g. '7-2-x' -> '7.2' and '8-x-y' -> '8'
 | |
| const renderBranchName = (name: string) =>
 | |
|   name.replace(/-[a-zA-Z]/g, '').replace('-', '.');
 | |
| 
 | |
| const renderTrop = (branch: string, ghKey: GHKey) =>
 | |
|   `[${renderBranchName(branch)}](${buildPullURL(ghKey)})`;
 | |
| 
 | |
| // @return markdown-formatted links to other branches' trops,
 | |
| //   e.g. "(Also in 7.2, 8, 9)"
 | |
| function renderTrops (commit: Commit, excludeBranch: string) {
 | |
|   const body = [...commit.trops.entries()]
 | |
|     .filter(([branch]) => branch !== excludeBranch)
 | |
|     .sort(([branchA], [branchB]) => parseInt(branchA) - parseInt(branchB)) // sort by semver major
 | |
|     .map(([branch, key]) => renderTrop(branch, key))
 | |
|     .join(', ');
 | |
|   return body
 | |
|     ? `<span style="font-size:small;">(Also in ${body})</span>`
 | |
|     : body;
 | |
| }
 | |
| 
 | |
| // @return a slightly cleaned-up human-readable change description
 | |
| function renderDescription (commit: Commit) {
 | |
|   let note = commit.note || commit.subject || '';
 | |
|   note = note.trim();
 | |
| 
 | |
|   // release notes bullet point every change, so if the note author
 | |
|   // manually started the content with a bullet point, that will confuse
 | |
|   // the markdown renderer -- remove the redundant bullet point
 | |
|   // (example: https://github.com/electron/electron/pull/25216)
 | |
|   if (note.startsWith('*')) {
 | |
|     note = note.slice(1).trim();
 | |
|   }
 | |
| 
 | |
|   if (note.length !== 0) {
 | |
|     note = note[0].toUpperCase() + note.substr(1);
 | |
| 
 | |
|     if (!note.endsWith('.')) {
 | |
|       note = note + '.';
 | |
|     }
 | |
| 
 | |
|     const commonVerbs = {
 | |
|       Added: ['Add'],
 | |
|       Backported: ['Backport'],
 | |
|       Cleaned: ['Clean'],
 | |
|       Disabled: ['Disable'],
 | |
|       Ensured: ['Ensure'],
 | |
|       Exported: ['Export'],
 | |
|       Fixed: ['Fix', 'Fixes'],
 | |
|       Handled: ['Handle'],
 | |
|       Improved: ['Improve'],
 | |
|       Made: ['Make'],
 | |
|       Removed: ['Remove'],
 | |
|       Repaired: ['Repair'],
 | |
|       Reverted: ['Revert'],
 | |
|       Stopped: ['Stop'],
 | |
|       Updated: ['Update'],
 | |
|       Upgraded: ['Upgrade']
 | |
|     };
 | |
|     for (const [key, values] of Object.entries(commonVerbs)) {
 | |
|       for (const value of values) {
 | |
|         const start = `${value} `;
 | |
|         if (note.startsWith(start)) {
 | |
|           note = `${key} ${note.slice(start.length)}`;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return note;
 | |
| }
 | |
| 
 | |
| // @return markdown-formatted release note line item,
 | |
| //   e.g. '* Fixed a foo. #12345 (Also in 7.2, 8, 9)'
 | |
| const renderNote = (commit: Commit, excludeBranch: string) =>
 | |
|   `* ${renderDescription(commit)} ${renderLink(commit)} ${renderTrops(
 | |
|     commit,
 | |
|     excludeBranch
 | |
|   )}\n`;
 | |
| 
 | |
| const renderNotes = (notes: Awaited<ReturnType<typeof getNotes>>, unique = false) => {
 | |
|   const rendered = [`# Release Notes for ${notes.name}\n\n`];
 | |
| 
 | |
|   const renderSection = (title: string, commits: Commit[], unique: boolean) => {
 | |
|     if (unique) {
 | |
|       // omit changes that also landed in other branches
 | |
|       commits = commits.filter(
 | |
|         (commit) => renderTrops(commit, notes.toBranch).length === 0
 | |
|       );
 | |
|     }
 | |
|     if (commits.length > 0) {
 | |
|       rendered.push(
 | |
|         `## ${title}\n\n`,
 | |
|         ...commits.map((commit) => renderNote(commit, notes.toBranch)).sort()
 | |
|       );
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   renderSection('Breaking Changes', notes.breaking, unique);
 | |
|   renderSection('Features', notes.feat, unique);
 | |
|   renderSection('Fixes', notes.fix, unique);
 | |
|   renderSection('Other Changes', notes.other, unique);
 | |
| 
 | |
|   if (notes.docs.length) {
 | |
|     const docs = notes.docs.map((commit) => renderLink(commit)).sort();
 | |
|     rendered.push(
 | |
|       '## Documentation\n\n',
 | |
|       ` * Documentation changes: ${docs.join(', ')}\n`,
 | |
|       '\n'
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   renderSection('Unknown', notes.unknown, unique);
 | |
| 
 | |
|   return rendered.join('');
 | |
| };
 | |
| 
 | |
| /***
 | |
|  ****  Module
 | |
|  ***/
 | |
| 
 | |
| export const get = getNotes;
 | |
| export const render = renderNotes;
 | 
