UNPKG

release-please

Version:

generate release PRs based on the conventionalcommits.org spec

387 lines 14.8 kB
"use strict"; // Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. Object.defineProperty(exports, "__esModule", { value: true }); exports.parseConventionalCommits = void 0; // eslint-disable-next-line @typescript-eslint/no-var-requires const visit = require('unist-util-visit'); // eslint-disable-next-line @typescript-eslint/no-var-requires const visitWithAncestors = require('unist-util-visit-parents'); const NUMBER_REGEX = /^[0-9]+$/; const logger_1 = require("./util/logger"); const parser = require("@conventional-commits/parser"); // eslint-disable-next-line @typescript-eslint/no-var-requires const conventionalCommitsFilter = require('conventional-commits-filter'); function getBlankConventionalCommit() { return { body: '', subject: '', type: '', scope: null, notes: [], references: [], mentions: [], merge: null, revert: null, header: '', footer: null, }; } // Converts conventional commit AST into conventional-changelog's // output format, see: https://www.npmjs.com/package/conventional-commits-parser function toConventionalChangelogFormat(ast) { const commits = []; const headerCommit = getBlankConventionalCommit(); // Separate the body and summary nodes, this simplifies the subsequent // tree walking logic: let body; let summary; visit(ast, ['body', 'summary'], (node) => { switch (node.type) { case 'body': body = node; break; case 'summary': summary = node; break; } }); // <type>, "(", <scope>, ")", ["!"], ":", <whitespace>*, <text> visit(summary, (node) => { switch (node.type) { case 'type': headerCommit.type = node.value; headerCommit.header += node.value; break; case 'scope': headerCommit.scope = node.value; headerCommit.header += `(${node.value})`; break; case 'breaking-change': headerCommit.header += '!'; break; case 'text': headerCommit.subject = node.value; headerCommit.header += `: ${node.value}`; break; default: break; } }); // [<any body-text except pre-footer>] if (body) { visit(body, ['text', 'newline'], (node) => { headerCommit.body += node.value; }); } // Extract BREAKING CHANGE notes, regardless of whether they fall in // summary, body, or footer: const breaking = { title: 'BREAKING CHANGE', text: '', // "text" will be populated if a BREAKING CHANGE token is parsed. }; visitWithAncestors(ast, ['breaking-change'], (node, ancestors) => { let hitBreakingMarker = false; let parent = ancestors.pop(); if (!parent) { return; } switch (parent.type) { case 'summary': breaking.text = headerCommit.subject; break; case 'body': breaking.text = ''; // We treat text from the BREAKING CHANGE marker forward as // the breaking change notes: visit(parent, ['breaking-change', 'text', 'newline'], (node) => { if (node.type === 'breaking-change') { hitBreakingMarker = true; return; } if (!hitBreakingMarker) return; breaking.text += node.value; }); break; case 'token': // If the '!' breaking change marker is used, the breaking change // will be identified when the footer is parsed as a commit: if (!node.value.includes('BREAKING')) return; parent = ancestors.pop(); visit(parent, ['text', 'newline'], (node) => { breaking.text = node.value; }); break; } }); // Add additional breaking change detection from commit body if (body) { const bodyString = String(body); const breakingChangeMatch = bodyString.match(/BREAKING-CHANGE:\s*(.*)/); if (breakingChangeMatch && breakingChangeMatch[1]) { if (breaking.text) { breaking.text += '\n'; } breaking.text += breakingChangeMatch[1].trim(); } } if (breaking.text !== '') headerCommit.notes.push(breaking); // Populates references array from footers: // references: [{ // action: 'Closes', // owner: null, // repository: null, // issue: '1', raw: '#1', // prefix: '#' // }] visit(ast, ['footer'], (node) => { const reference = { prefix: '#', action: '', issue: '', }; let hasRefSepartor = false; visit(node, ['type', 'separator', 'text'], (node) => { switch (node.type) { case 'type': // refs, closes, etc: // TODO(@bcoe): conventional-changelog does not currently use // "reference.action" in its templates: reference.action = node.value; break; case 'separator': // Footer of the form "Refs #99": if (node.value.includes('#')) hasRefSepartor = true; break; case 'text': // Footer of the form "Refs: #99" if (node.value.charAt(0) === '#') { hasRefSepartor = true; reference.issue = node.value.substring(1); // TODO(@bcoe): what about references like "Refs: #99, #102"? } else { reference.issue = node.value; } break; } }); // TODO(@bcoe): how should references like "Refs: v8:8940" work. if (hasRefSepartor && reference.issue.match(NUMBER_REGEX)) { headerCommit.references.push(reference); } }); /* * Split footers that resemble commits into additional commits, e.g., * chore: multiple commits * chore(recaptchaenterprise): migrate recaptchaenterprise to the Java microgenerator * Committer: @miraleung * PiperOrigin-RevId: 345559154 * ... */ visitWithAncestors(ast, ['type'], (node, ancestors) => { let parent = ancestors.pop(); if (!parent) { return; } if (parent.type === 'token') { parent = ancestors.pop(); let footerText = ''; const semanticFooter = node.value.toLowerCase() === 'release-as'; visit(parent, ['type', 'scope', 'breaking-change', 'separator', 'text', 'newline'], (node) => { switch (node.type) { case 'scope': footerText += `(${node.value})`; break; case 'separator': // Footers of the form Fixes #99, should not be parsed. if (node.value.includes('#')) return; footerText += `${node.value} `; break; default: footerText += node.value; break; } }); // Any footers that carry semantic meaning, e.g., Release-As, should // be added to the footer field, for the benefits of post-processing: if (semanticFooter) { let releaseAs = ''; visit(parent, ['text'], (node) => { releaseAs = node.value; }); // record Release-As footer as a note headerCommit.notes.push({ title: 'RELEASE AS', text: releaseAs, }); if (!headerCommit.footer) headerCommit.footer = ''; headerCommit.footer += `\n${footerText.toLowerCase()}`.trimStart(); } try { for (const commit of toConventionalChangelogFormat(parser.parser(footerText))) { commits.push(commit); } } catch (err) { // Footer does not appear to be an additional commit. } } }); commits.push(headerCommit); return commits; } // TODO(@bcoe): now that we walk the actual AST of conventional commits // we should be able to move post processing into // to-conventional-changelog.ts. function postProcessCommits(commit) { var _a; commit.notes.forEach(note => { let text = ''; let i = 0; let extendedContext = false; for (const chunk of note.text.split(/\r?\n/)) { if (i > 0 && hasExtendedContext(chunk) && !extendedContext) { text = `${text.trim()}\n`; extendedContext = true; } if (chunk === '') break; else if (extendedContext) { text += ` ${chunk}\n`; } else { text += `${chunk} `; } i++; } note.text = text.trim(); }); const breakingChangeMatch = (_a = commit.body) === null || _a === void 0 ? void 0 : _a.match(/BREAKING-CHANGE:\s*(.*)/); if (breakingChangeMatch && breakingChangeMatch[1]) { const existingNote = commit.notes.find(note => note.title === 'BREAKING CHANGE'); if (existingNote) { existingNote.text += `\n${breakingChangeMatch[1].trim()}`; } else { commit.notes.push({ title: 'BREAKING CHANGE', text: breakingChangeMatch[1].trim(), }); } } return commit; } // If someone wishes to include additional contextual information for a // BREAKING CHANGE using markdown, they can do so by starting the line after the initial // breaking change description with either: // // 1. a fourth-level header. // 2. a bulleted list (using either '*' or '-'). // // BREAKING CHANGE: there were breaking changes // #### Deleted Endpoints // - endpoint 1 // - endpoint 2 function hasExtendedContext(line) { if (line.match(/^#### |^[*-] /)) return true; return false; } function parseCommits(message) { return conventionalCommitsFilter(toConventionalChangelogFormat(parser.parser(message))).map(postProcessCommits); } /** * Splits a commit message into multiple messages based on conventional commit format and nested commit blocks. * This function is capable of: * 1. Separating conventional commits (feat, fix, docs, etc.) within the main message. * 2. Extracting nested commits enclosed in BEGIN_NESTED_COMMIT/END_NESTED_COMMIT blocks. * 3. Preserving the original message structure outside of nested commit blocks. * 4. Handling multiple nested commits and conventional commits in a single message. * * @param message The input commit message string * @returns An array of individual commit messages */ function splitMessages(message) { const parts = message.split('BEGIN_NESTED_COMMIT'); const messages = [parts.shift()]; for (const part of parts) { const [newMessage, ...rest] = part.split('END_NESTED_COMMIT'); messages.push(newMessage); messages[0] = messages[0] + rest.join('END_NESTED_COMMIT'); } const conventionalCommits = messages[0] .split(/\r?\n\r?\n(?=(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.*?\))?: )/) .filter(Boolean); return [...conventionalCommits, ...messages.slice(1)]; } /** * Given a list of raw commits, parse and expand into conventional commits. * * @param commits {Commit[]} The input commits * * @returns {ConventionalCommit[]} Parsed and expanded commits. There may be * more commits returned as a single raw commit may contain multiple release * messages. */ function parseConventionalCommits(commits, logger = logger_1.logger) { const conventionalCommits = []; for (const commit of commits) { for (const commitMessage of splitMessages(preprocessCommitMessage(commit))) { try { for (const parsedCommit of parseCommits(commitMessage)) { const breaking = parsedCommit.notes.filter(note => note.title === 'BREAKING CHANGE') .length > 0; conventionalCommits.push({ sha: commit.sha, message: parsedCommit.header, files: commit.files, pullRequest: commit.pullRequest, type: parsedCommit.type, scope: parsedCommit.scope, bareMessage: parsedCommit.subject, notes: parsedCommit.notes, references: parsedCommit.references, breaking, }); } } catch (_err) { logger.debug(`commit could not be parsed: ${commit.sha} ${commit.message.split('\n')[0]}`); logger.debug(`error message: ${_err}`); } } } return conventionalCommits; } exports.parseConventionalCommits = parseConventionalCommits; function preprocessCommitMessage(commit) { // look for 'BEGIN_COMMIT_OVERRIDE' section of pull request body if (commit.pullRequest) { const overrideMessage = (commit.pullRequest.body.split('BEGIN_COMMIT_OVERRIDE')[1] || '') .split('END_COMMIT_OVERRIDE')[0] .trim(); if (overrideMessage) { return overrideMessage; } } return commit.message; } //# sourceMappingURL=commit.js.map