942 lines
26 KiB
JavaScript
942 lines
26 KiB
JavaScript
|
/*
|
|||
|
This (very The Lounge-custom) script is a helper to generate changelog entries.
|
|||
|
|
|||
|
Note that it is not meant to produce fully-automated changelogs like other tools
|
|||
|
do, but merely prepare a changelog entry without risks of mistyping a URL or
|
|||
|
missing a contribution: changelogs are meant for humans, and therefore must be
|
|||
|
manually curated as such, with ❤️.
|
|||
|
|
|||
|
## Set up:
|
|||
|
|
|||
|
- Create a personal access token with `public_repo` at
|
|||
|
https://github.com/settings/tokens. Make sure to write it down as you will not
|
|||
|
be able to display it again.
|
|||
|
|
|||
|
- Use Node.js v8+:
|
|||
|
|
|||
|
```sh
|
|||
|
nvm install 8
|
|||
|
```
|
|||
|
|
|||
|
## Usage
|
|||
|
|
|||
|
npm v5 removes packages not listed in package.json when running `npm install` so
|
|||
|
it is very likely you will have to run all those each time:
|
|||
|
|
|||
|
```sh
|
|||
|
export CHANGELOG_TOKEN=<The personal access token created on GitHub above>
|
|||
|
node scripts/changelog <version>
|
|||
|
```
|
|||
|
|
|||
|
`<version>` must be either:
|
|||
|
|
|||
|
- A keyword among: major, minor, patch, prerelease, pre
|
|||
|
- An explicit version of either format:
|
|||
|
- `MAJOR.MINOR.PATCH` for a stable release, for example `2.5.0`
|
|||
|
- `MAJOR.MINOR.PATCH-(pre|rc).N` for a pre-release, for example `2.5.0-rc.1`
|
|||
|
|
|||
|
## TODOs:
|
|||
|
|
|||
|
- Use better labels for better categorization
|
|||
|
- Add some stats to the git commit (how many LOCs total / in this release, etc.)
|
|||
|
- This script requires Node v8, but `npm version` currently fails with Node v8
|
|||
|
as we gitignore package-lock.json (how is that even a thing?!).
|
|||
|
*/
|
|||
|
|
|||
|
"use strict";
|
|||
|
|
|||
|
const _ = require("lodash");
|
|||
|
const colors = require("chalk");
|
|||
|
const fs = require("fs");
|
|||
|
const path = require("path");
|
|||
|
const got = require("got");
|
|||
|
const dayjs = require("dayjs");
|
|||
|
const semver = require("semver");
|
|||
|
const util = require("util");
|
|||
|
const log = require("../src/log");
|
|||
|
const packageJson = require("../package.json");
|
|||
|
let token = process.env.CHANGELOG_TOKEN;
|
|||
|
|
|||
|
const readFile = util.promisify(fs.readFile);
|
|||
|
const writeFile = util.promisify(fs.writeFile);
|
|||
|
|
|||
|
const changelogPath = path.resolve(__dirname, "..", "CHANGELOG.md");
|
|||
|
|
|||
|
// CLI argument validations
|
|||
|
|
|||
|
if (token === undefined) {
|
|||
|
try {
|
|||
|
token = fs.readFileSync(path.resolve(__dirname, "./github_token.txt")).toString().trim();
|
|||
|
} catch (e) {
|
|||
|
log.error(`Environment variable ${colors.bold("CHANGELOG_TOKEN")} must be set.`);
|
|||
|
log.error(`Alternative create ${colors.bold("scripts/github_token.txt")} file.`);
|
|||
|
process.exit(1);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (process.argv[2] === undefined) {
|
|||
|
log.error(`Argument ${colors.bold("version")} is missing`);
|
|||
|
process.exit(1);
|
|||
|
}
|
|||
|
|
|||
|
// If version is not a valid X.Y.Z, it may be something like "pre".
|
|||
|
let version = semver.valid(process.argv[2]);
|
|||
|
|
|||
|
if (!version) {
|
|||
|
version = semver.inc(packageJson.version, process.argv[2]);
|
|||
|
}
|
|||
|
|
|||
|
function isValidVersion(str) {
|
|||
|
return /^[0-9]+\.[0-9]+\.[0-9]+(-(pre|rc)+\.[0-9]+)?$/.test(str);
|
|||
|
}
|
|||
|
|
|||
|
if (!isValidVersion(version)) {
|
|||
|
log.error(`Argument ${colors.bold("version")} is incorrect It must be either:`);
|
|||
|
log.error(
|
|||
|
`- A keyword among: ${colors.green("major")}, ${colors.green("minor")}, ${colors.green(
|
|||
|
"patch"
|
|||
|
)}, ${colors.green("prerelease")}, ${colors.green("pre")}`
|
|||
|
);
|
|||
|
log.error(
|
|||
|
`- An explicit version of format ${colors.green("x.y.z")} (stable) or ${colors.green(
|
|||
|
"x.y.z-(pre|rc).n"
|
|||
|
)} (pre-release).`
|
|||
|
);
|
|||
|
process.exit(1);
|
|||
|
}
|
|||
|
|
|||
|
// Templates
|
|||
|
|
|||
|
function prereleaseTemplate(items) {
|
|||
|
return `
|
|||
|
## v${items.version} - ${items.date} [Pre-release]
|
|||
|
|
|||
|
[See the full changelog](${items.fullChangelogUrl})
|
|||
|
|
|||
|
${
|
|||
|
prereleaseType(items.version) === "rc"
|
|||
|
? `This is a release candidate (RC) for v${stableVersion(
|
|||
|
items.version
|
|||
|
)} to ensure maximum stability for public release.
|
|||
|
Bugs may be fixed, but no further features will be added until the next stable version.`
|
|||
|
: `This is a pre-release for v${stableVersion(
|
|||
|
items.version
|
|||
|
)} to offer latest changes without having to wait for a stable release.
|
|||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.`
|
|||
|
}
|
|||
|
|
|||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
|||
|
|
|||
|
As with all pre-releases, this version requires explicit use of the \`next\` tag to be installed:
|
|||
|
|
|||
|
\`\`\`sh
|
|||
|
yarn global add thelounge@next
|
|||
|
\`\`\`
|
|||
|
`;
|
|||
|
}
|
|||
|
|
|||
|
// Check if the object is empty, or if all array values within this object are
|
|||
|
// empty
|
|||
|
function isEmpty(list) {
|
|||
|
const values = Object.values(list);
|
|||
|
return values.length === 0 || values.every((entries) => entries.length === 0);
|
|||
|
}
|
|||
|
|
|||
|
function stableTemplate(items) {
|
|||
|
return `
|
|||
|
## v${items.version} - ${items.date}
|
|||
|
|
|||
|
For more details, [see the full changelog](${items.fullChangelogUrl}) and [milestone](${
|
|||
|
items.milestone.url
|
|||
|
}?closed=1).
|
|||
|
|
|||
|
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|||
|
@@ DESCRIPTION, ANNOUNCEMENT, ETC. @@
|
|||
|
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|||
|
|
|||
|
### Added
|
|||
|
|
|||
|
### Changed
|
|||
|
|
|||
|
${
|
|||
|
isEmpty(items.dependencies)
|
|||
|
? ""
|
|||
|
: `- Update production dependencies to their latest versions:
|
|||
|
${printDependencyList(items.dependencies)}`
|
|||
|
}
|
|||
|
|
|||
|
### Deprecated
|
|||
|
|
|||
|
${printList(items.deprecations)}
|
|||
|
|
|||
|
### Removed
|
|||
|
|
|||
|
### Fixed
|
|||
|
|
|||
|
### Security
|
|||
|
|
|||
|
${printList(items.security)}
|
|||
|
|
|||
|
### Documentation
|
|||
|
|
|||
|
${
|
|||
|
items.documentation.length === 0
|
|||
|
? ""
|
|||
|
: `In the main repository:
|
|||
|
|
|||
|
${printList(items.documentation)}`
|
|||
|
}
|
|||
|
|
|||
|
${
|
|||
|
items.websiteDocumentation.length === 0
|
|||
|
? ""
|
|||
|
: `On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
|||
|
|
|||
|
${printList(items.websiteDocumentation)}`
|
|||
|
}
|
|||
|
|
|||
|
### Internals
|
|||
|
|
|||
|
${printList(items.internals)}${
|
|||
|
isEmpty(items.devDependencies)
|
|||
|
? ""
|
|||
|
: `
|
|||
|
- Update development dependencies to their latest versions:
|
|||
|
${printDependencyList(items.devDependencies)}`
|
|||
|
}
|
|||
|
|
|||
|
@@@@@@@@@@@@@@@@@@@
|
|||
|
@@ UNCATEGORIZED @@
|
|||
|
@@@@@@@@@@@@@@@@@@@
|
|||
|
${printUncategorizedList(items.uncategorized)}`;
|
|||
|
}
|
|||
|
|
|||
|
// Returns true if the given version is a pre-release (i.e. 2.0.0-pre.3,
|
|||
|
// 2.5.0-rc.1, etc.), or false otherwise
|
|||
|
function isPrerelease(v) {
|
|||
|
return v.includes("-");
|
|||
|
}
|
|||
|
|
|||
|
// Given a version of `x.y.z-abc.n`, returns `abc`, i.e. the type of pre-release
|
|||
|
function prereleaseType(v) {
|
|||
|
return semver.prerelease(v)[0];
|
|||
|
}
|
|||
|
|
|||
|
// Returns the stable version that this pre-release version is targeting. For
|
|||
|
// example, if new version is 2.5.0-rc.2, next stable version will be 2.5.0.
|
|||
|
function stableVersion(prereleaseVersion) {
|
|||
|
return prereleaseVersion.substr(0, prereleaseVersion.indexOf("-"));
|
|||
|
}
|
|||
|
|
|||
|
// Generates a compare-view URL between 2 versions of The Lounge
|
|||
|
function fullChangelogUrl(v1, v2) {
|
|||
|
return `https://github.com/thelounge/thelounge/compare/v${v1}...v${v2}`;
|
|||
|
}
|
|||
|
|
|||
|
// This class is a facade to fetching details about commits / PRs / tags / etc.
|
|||
|
// for a given repository of our organization.
|
|||
|
class RepositoryFetcher {
|
|||
|
// Holds a Github token and repository name
|
|||
|
constructor(githubToken, repositoryName) {
|
|||
|
this.githubToken = githubToken;
|
|||
|
this.repositoryName = repositoryName;
|
|||
|
}
|
|||
|
|
|||
|
// Base function that actually makes the GraphQL API call
|
|||
|
async fetch(query, variables = {}) {
|
|||
|
const response = await got
|
|||
|
.post("https://api.github.com/graphql", {
|
|||
|
json: {
|
|||
|
query: query,
|
|||
|
variables: Object.assign(variables, {repositoryName: this.repositoryName}),
|
|||
|
},
|
|||
|
headers: {
|
|||
|
Authorization: `Bearer ${this.githubToken}`,
|
|||
|
},
|
|||
|
})
|
|||
|
.json();
|
|||
|
|
|||
|
if (!response.errors && response.data) {
|
|||
|
return response.data;
|
|||
|
}
|
|||
|
|
|||
|
throw new Error(`GraphQL request returned no data: ${JSON.stringify(response)}`);
|
|||
|
}
|
|||
|
|
|||
|
// Returns the git commit that is attached to a given tag
|
|||
|
async fetchTaggedCommit(tag) {
|
|||
|
const tagQuery = `query fetchTaggedCommit($repositoryName: String!, $tag: String!) {
|
|||
|
repository(owner: "thelounge", name: $repositoryName) {
|
|||
|
ref(qualifiedName: $tag) {
|
|||
|
tag: target {
|
|||
|
oid
|
|||
|
... on Tag {
|
|||
|
commit: target {
|
|||
|
oid
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}`;
|
|||
|
const data = await this.fetch(tagQuery, {tag});
|
|||
|
return data.repository.ref.tag.commit || data.repository.ref.tag;
|
|||
|
}
|
|||
|
|
|||
|
// Returns an array of annotated commits that have been made on the master
|
|||
|
// branch since a given version. Each commit is an object that can optionally
|
|||
|
// have a `pullRequestId` if this is a merge-PR commit.
|
|||
|
async fetchCommitsSince(stopCommit) {
|
|||
|
const commitsQuery = `query fetchCommits($repositoryName: String!, $afterCursor: String) {
|
|||
|
repository(owner: "thelounge", name: $repositoryName) {
|
|||
|
ref(qualifiedName: "master") {
|
|||
|
target {
|
|||
|
... on Commit {
|
|||
|
history(first: 100, after: $afterCursor) {
|
|||
|
pageInfo {
|
|||
|
hasNextPage
|
|||
|
endCursor
|
|||
|
}
|
|||
|
commits: nodes {
|
|||
|
__typename
|
|||
|
oid
|
|||
|
abbreviatedOid
|
|||
|
messageHeadline
|
|||
|
url
|
|||
|
author {
|
|||
|
user {
|
|||
|
login
|
|||
|
url
|
|||
|
}
|
|||
|
}
|
|||
|
comments(first: 100) {
|
|||
|
nodes {
|
|||
|
body
|
|||
|
authorAssociation
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}`;
|
|||
|
|
|||
|
// Recursive function that retrieves commits page after page until the last
|
|||
|
// page or a given commit are reached.
|
|||
|
const fetchPaginatedCommits = async (afterCursor = null) => {
|
|||
|
const data = await this.fetch(commitsQuery, {afterCursor});
|
|||
|
const {commits, pageInfo} = data.repository.ref.target.history;
|
|||
|
|
|||
|
if (commits.map(({oid}) => oid).includes(stopCommit.oid)) {
|
|||
|
return _.takeWhile(commits, ({oid}) => oid !== stopCommit.oid);
|
|||
|
} else if (pageInfo.hasNextPage) {
|
|||
|
return commits.concat(await fetchPaginatedCommits(pageInfo.endCursor));
|
|||
|
}
|
|||
|
|
|||
|
return commits;
|
|||
|
};
|
|||
|
|
|||
|
const commits = await fetchPaginatedCommits();
|
|||
|
|
|||
|
commits.forEach((commit) => {
|
|||
|
const resultPR = /^Merge pull request #([0-9]+) .+/.exec(commit.messageHeadline);
|
|||
|
|
|||
|
if (resultPR) {
|
|||
|
commit.pullRequestId = parseInt(resultPR[1], 10);
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
return commits.reverse();
|
|||
|
}
|
|||
|
|
|||
|
// Returns the last version prior to this new one. If new version is stable,
|
|||
|
// the previous one will be stable as well (all pre-release versions will be
|
|||
|
// skipped).
|
|||
|
async fetchPreviousVersion(newVersion) {
|
|||
|
const lastTagsQuery = `query fetchPreviousVersion($repositoryName: String!) {
|
|||
|
repository(owner: "thelounge", name: $repositoryName) {
|
|||
|
refs(refPrefix: "refs/tags/", first: 20, direction: DESC) {
|
|||
|
tags: nodes {
|
|||
|
name
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}`;
|
|||
|
const data = await this.fetch(lastTagsQuery);
|
|||
|
const tags = data.repository.refs.tags;
|
|||
|
let tag;
|
|||
|
|
|||
|
if (isPrerelease(newVersion)) {
|
|||
|
tag = tags[0];
|
|||
|
} else {
|
|||
|
tag = tags.find(({name}) => !isPrerelease(name));
|
|||
|
}
|
|||
|
|
|||
|
return tag.name.substr(1);
|
|||
|
}
|
|||
|
|
|||
|
// Returns information on a milestone associated to a version (i.e. not a
|
|||
|
// tag!) of the repository
|
|||
|
async fetchMilestone(targetVersion) {
|
|||
|
const milestonesQuery = `query fetchMilestone($repositoryName: String!) {
|
|||
|
repository(owner: "thelounge", name: $repositoryName) {
|
|||
|
milestones(last: 20) {
|
|||
|
nodes {
|
|||
|
title
|
|||
|
url
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}`;
|
|||
|
const data = await this.fetch(milestonesQuery);
|
|||
|
return data.repository.milestones.nodes.find(({title}) => title === targetVersion);
|
|||
|
}
|
|||
|
|
|||
|
async fetchChunkedPullRequests(numbers) {
|
|||
|
const chunks = _.chunk(numbers, 100);
|
|||
|
let result = {};
|
|||
|
|
|||
|
for (const chunk of chunks) {
|
|||
|
const data = await this.fetchPullRequests(chunk);
|
|||
|
result = _.merge(result, data);
|
|||
|
}
|
|||
|
|
|||
|
return result;
|
|||
|
}
|
|||
|
|
|||
|
// Given a list of PR numbers, retrieve information for all those PRs. They
|
|||
|
// are returned as a hash whose keys are `PR<number>`.
|
|||
|
// This is a bit wonky (generating a dynamic GraphQL query) but the GitHub API
|
|||
|
// does not have a way to retrieve multiple PRs given a list of IDs.
|
|||
|
async fetchPullRequests(numbers) {
|
|||
|
if (numbers.length === 0) {
|
|||
|
return {};
|
|||
|
}
|
|||
|
|
|||
|
const prQuery = `query fetchPullRequests($repositoryName: String!) {
|
|||
|
repository(owner: "thelounge", name: $repositoryName) {
|
|||
|
${numbers
|
|||
|
.map(
|
|||
|
(number) => `
|
|||
|
PR${number}: pullRequest(number: ${number}) {
|
|||
|
__typename
|
|||
|
title
|
|||
|
body
|
|||
|
url
|
|||
|
author {
|
|||
|
__typename
|
|||
|
login
|
|||
|
url
|
|||
|
}
|
|||
|
labels(first: 20) {
|
|||
|
nodes {
|
|||
|
name
|
|||
|
}
|
|||
|
}
|
|||
|
commits(first: 100) {
|
|||
|
nodes {
|
|||
|
commit {
|
|||
|
oid
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
`
|
|||
|
)
|
|||
|
.join("")}
|
|||
|
}
|
|||
|
}`;
|
|||
|
const data = await this.fetch(prQuery);
|
|||
|
return data.repository;
|
|||
|
}
|
|||
|
|
|||
|
// Chain several of the functions above together. Essentially, returns an
|
|||
|
// array composed of PRs, and commits that belong to no PRs, existing between
|
|||
|
// a given tag and master.
|
|||
|
async fetchCommitsAndPullRequestsSince(tag) {
|
|||
|
const taggedCommit = await this.fetchTaggedCommit(tag);
|
|||
|
const commits = await this.fetchCommitsSince(taggedCommit);
|
|||
|
const pullRequestIds = pullRequestNumbersInCommits(commits);
|
|||
|
const pullRequests = await this.fetchChunkedPullRequests(pullRequestIds);
|
|||
|
return combine(commits, pullRequests);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// Given an array of annotated commits, returns an array of PR numbers, integers
|
|||
|
function pullRequestNumbersInCommits(commits) {
|
|||
|
return commits.reduce((array, {pullRequestId}) => {
|
|||
|
if (pullRequestId) {
|
|||
|
array.push(pullRequestId);
|
|||
|
}
|
|||
|
|
|||
|
return array;
|
|||
|
}, []);
|
|||
|
}
|
|||
|
|
|||
|
// Given 2 arrays of annotated commits and pull requests, replace merge commits
|
|||
|
// with the pull request information, and remove commits that are already part
|
|||
|
// of a pull request.
|
|||
|
// The goal of this function is to return an array consisting only of pull
|
|||
|
// requests + commits that have been made to `master` directly.
|
|||
|
function combine(allCommits, allPullRequests) {
|
|||
|
const commitsFromPRs = _.flatMap(allPullRequests, ({commits}) =>
|
|||
|
commits.nodes.map(({commit}) => commit.oid)
|
|||
|
);
|
|||
|
|
|||
|
return allCommits.reduce((array, commit) => {
|
|||
|
if (commit.pullRequestId) {
|
|||
|
const pullRequest = allPullRequests[`PR${commit.pullRequestId}`];
|
|||
|
pullRequest.number = commit.pullRequestId;
|
|||
|
array.push(pullRequest);
|
|||
|
} else if (!commitsFromPRs.includes(commit.oid)) {
|
|||
|
array.push(commit);
|
|||
|
}
|
|||
|
|
|||
|
return array;
|
|||
|
}, []);
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown link for a given author object
|
|||
|
function printAuthorLink({login, url}) {
|
|||
|
return `by [@${login}](${url})`;
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown link for a given pull request or commit object
|
|||
|
function printEntryLink(entry) {
|
|||
|
const label =
|
|||
|
entry.__typename === "PullRequest" ? `#${entry.number}` : `\`${entry.abbreviatedOid}\``;
|
|||
|
|
|||
|
return `[${label}](${entry.url})`;
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown entry list item depending on its type
|
|||
|
function printLine(entry) {
|
|||
|
if (entry.__typename === "PullRequest") {
|
|||
|
return printPullRequest(entry);
|
|||
|
}
|
|||
|
|
|||
|
return printCommit(entry);
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown list item for a given pull request
|
|||
|
function printPullRequest(pullRequest) {
|
|||
|
return `- ${pullRequest.title} (${printEntryLink(pullRequest)} ${printAuthorLink(
|
|||
|
pullRequest.author
|
|||
|
)})`;
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown list item for a commit made directly in `master`
|
|||
|
function printCommit(commit) {
|
|||
|
return `- ${commit.messageHeadline} (${printEntryLink(commit)} ${printAuthorLink(
|
|||
|
commit.author.user
|
|||
|
)})`;
|
|||
|
}
|
|||
|
|
|||
|
// Builds a Markdown list of all given items
|
|||
|
function printList(items) {
|
|||
|
return items.map((item) => printLine(item)).join("\n");
|
|||
|
}
|
|||
|
|
|||
|
// Given a "dependencies object" (i.e. keys are package names, values are arrays
|
|||
|
// of pull request numbers), builds a Markdown list of URLs
|
|||
|
function printDependencyList(dependencies) {
|
|||
|
const list = [];
|
|||
|
|
|||
|
Object.entries(dependencies).forEach(([name, entries]) => {
|
|||
|
if (entries.length > 0) {
|
|||
|
list.push(` - \`${name}\` (${entries.map(printEntryLink).join(", ")})`);
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
return list.join("\n");
|
|||
|
}
|
|||
|
|
|||
|
function printUncategorizedList(uncategorized) {
|
|||
|
return Object.entries(uncategorized).reduce((memo, [label, items]) => {
|
|||
|
if (items.length === 0) {
|
|||
|
return memo;
|
|||
|
}
|
|||
|
|
|||
|
memo += `
|
|||
|
@@@@@ ${label.toUpperCase()}
|
|||
|
|
|||
|
${printList(items)}
|
|||
|
`;
|
|||
|
|
|||
|
return memo;
|
|||
|
}, "");
|
|||
|
}
|
|||
|
|
|||
|
const dependencies = Object.keys(packageJson.dependencies);
|
|||
|
const devDependencies = Object.keys(packageJson.devDependencies);
|
|||
|
const optionalDependencies = Object.keys(packageJson.optionalDependencies);
|
|||
|
|
|||
|
// Returns the package.json section in which that package exists, or undefined
|
|||
|
// if that package is not listed there.
|
|||
|
function whichDependencyType(packageName) {
|
|||
|
if (dependencies.includes(packageName) || optionalDependencies.includes(packageName)) {
|
|||
|
return "dependencies";
|
|||
|
} else if (devDependencies.includes(packageName)) {
|
|||
|
return "devDependencies";
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
function hasLabelOrAnnotatedComment({labels, comments}, expected) {
|
|||
|
return hasLabel(labels, expected) || hasAnnotatedComment(comments, expected);
|
|||
|
}
|
|||
|
|
|||
|
// Returns true if a label exists amongst a list of labels
|
|||
|
function hasLabel(labels, expected) {
|
|||
|
return labels && labels.nodes.some(({name}) => name === expected);
|
|||
|
}
|
|||
|
|
|||
|
function hasAnnotatedComment(comments, expected) {
|
|||
|
return (
|
|||
|
comments &&
|
|||
|
comments.nodes.some(
|
|||
|
({authorAssociation, body}) =>
|
|||
|
["OWNER", "MEMBER"].includes(authorAssociation) &&
|
|||
|
body.split("\r\n").includes(`[${expected}]`)
|
|||
|
)
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
function isSkipped(entry) {
|
|||
|
return (
|
|||
|
(entry.__typename === "Commit" &&
|
|||
|
// Version bump commits created by `yarn version`
|
|||
|
(isValidVersion(entry.messageHeadline) ||
|
|||
|
// Commit message suggested by this script
|
|||
|
entry.messageHeadline.startsWith("Add changelog entry for v"))) ||
|
|||
|
hasLabelOrAnnotatedComment(entry, "Meta: Skip Changelog")
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
// Dependency update PRs are listed in a special, more concise way in the changelog.
|
|||
|
function isDependency({labels}) {
|
|||
|
return hasLabel(labels, "Type: Dependencies");
|
|||
|
}
|
|||
|
|
|||
|
function isDocumentation({labels}) {
|
|||
|
return hasLabel(labels, "Type: Documentation");
|
|||
|
}
|
|||
|
|
|||
|
function isSecurity({labels}) {
|
|||
|
return hasLabel(labels, "Type: Security");
|
|||
|
}
|
|||
|
|
|||
|
function isDeprecation({labels}) {
|
|||
|
return hasLabel(labels, "Type: Deprecation");
|
|||
|
}
|
|||
|
|
|||
|
function isInternal(entry) {
|
|||
|
return hasLabelOrAnnotatedComment(entry, "Meta: Internal");
|
|||
|
}
|
|||
|
|
|||
|
function isBug({labels}) {
|
|||
|
return hasLabel(labels, "Type: Bug");
|
|||
|
}
|
|||
|
|
|||
|
function isFeature({labels}) {
|
|||
|
return hasLabel(labels, "Type: Feature");
|
|||
|
}
|
|||
|
|
|||
|
// Examples:
|
|||
|
// Update webpack to the latest version
|
|||
|
// Update `stylelint` to v1.2.3
|
|||
|
// Update `express` and `ua-parser-js` to latest versions
|
|||
|
// Update `express`, `chai`, and `ua-parser-js` to ...
|
|||
|
// Update @fortawesome/fontawesome-free-webfonts to the latest version
|
|||
|
// Update dependency request to v2.87.0
|
|||
|
// chore(deps): update dependency mini-css-extract-plugin to v0.4.3
|
|||
|
// fix(deps): update dependency web-push to v3.3.3
|
|||
|
// chore(deps): update babel monorepo to v7.1.0
|
|||
|
function extractPackages({title, body, url}) {
|
|||
|
// Extract updated packages from renovate-bot's pull request body
|
|||
|
let list = /^This PR contains the following updates:\n\n(?:[\s\S]+?)---\|$\n([\s\S]+?)\n\n---/m.exec(
|
|||
|
body
|
|||
|
);
|
|||
|
|
|||
|
if (list) {
|
|||
|
const packages = [];
|
|||
|
list = list[1].split("\n");
|
|||
|
|
|||
|
for (let line of list) {
|
|||
|
line = line
|
|||
|
.split("|")[1] // Split the table and take the first column
|
|||
|
.trim()
|
|||
|
.split(" ")[0]; // Remove any spaces and take the first word (skip source link, etc)
|
|||
|
|
|||
|
const pkgName = /([\w-, ./@]+)/.exec(line);
|
|||
|
|
|||
|
if (!pkgName) {
|
|||
|
log.warn(`Failed to extract package name from: ${url}`);
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
packages.push(pkgName[1]);
|
|||
|
}
|
|||
|
|
|||
|
if (packages.length > 0) {
|
|||
|
return packages;
|
|||
|
}
|
|||
|
|
|||
|
log.warn(`Failed to extract package from: ${url}`);
|
|||
|
}
|
|||
|
|
|||
|
// Fallback to extracting package from title
|
|||
|
const extracted = /(?:U|u)pdate(?: dependency)? ([\w-,` ./@]+?) (?:packages |monorepo )?to /.exec(
|
|||
|
title
|
|||
|
);
|
|||
|
|
|||
|
if (!extracted) {
|
|||
|
log.warn(`Failed to extract package from: ${title} ${colors.gray(url)}`);
|
|||
|
return [];
|
|||
|
}
|
|||
|
|
|||
|
return extracted[1].replace(/`/g, "").split(/, and |, | and /);
|
|||
|
}
|
|||
|
|
|||
|
// Given an array of entries (PRs or commits), separates them into sections,
|
|||
|
// based on different information that describes them.
|
|||
|
function parse(entries) {
|
|||
|
return entries.reduce(
|
|||
|
(result, entry) => {
|
|||
|
let deps;
|
|||
|
|
|||
|
if (isSkipped(entry)) {
|
|||
|
result.skipped.push(entry);
|
|||
|
} else if (isDependency(entry) && (deps = extractPackages(entry))) {
|
|||
|
deps.forEach((packageName) => {
|
|||
|
const dependencyType = whichDependencyType(packageName);
|
|||
|
|
|||
|
if (dependencyType) {
|
|||
|
if (!result[dependencyType][packageName]) {
|
|||
|
result[dependencyType][packageName] = [];
|
|||
|
}
|
|||
|
|
|||
|
result[dependencyType][packageName].push(entry);
|
|||
|
} else {
|
|||
|
log.info(
|
|||
|
`${colors.bold(packageName)} was updated in ${colors.green(
|
|||
|
"#" + entry.number
|
|||
|
)} then removed since last release. Skipping. ${colors.gray(
|
|||
|
entry.url
|
|||
|
)}`
|
|||
|
);
|
|||
|
}
|
|||
|
});
|
|||
|
} else if (isDocumentation(entry)) {
|
|||
|
result.documentation.push(entry);
|
|||
|
} else if (isDeprecation(entry)) {
|
|||
|
result.deprecations.push(entry);
|
|||
|
} else if (isSecurity(entry)) {
|
|||
|
result.security.push(entry);
|
|||
|
} else if (isInternal(entry)) {
|
|||
|
result.internals.push(entry);
|
|||
|
} else {
|
|||
|
if (isFeature(entry)) {
|
|||
|
result.uncategorized.feature.push(entry);
|
|||
|
} else if (isBug(entry)) {
|
|||
|
result.uncategorized.bug.push(entry);
|
|||
|
} else {
|
|||
|
result.uncategorized.other.push(entry);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return result;
|
|||
|
},
|
|||
|
{
|
|||
|
skipped: [],
|
|||
|
dependencies: {},
|
|||
|
devDependencies: {},
|
|||
|
deprecations: [],
|
|||
|
documentation: [],
|
|||
|
internals: [],
|
|||
|
security: [],
|
|||
|
uncategorized: {
|
|||
|
feature: [],
|
|||
|
bug: [],
|
|||
|
other: [],
|
|||
|
},
|
|||
|
unknownDependencies: new Set(),
|
|||
|
}
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
function dedupeEntries(changelog, items) {
|
|||
|
const dedupe = (entries) =>
|
|||
|
entries.filter((entry) => !changelog.includes(printEntryLink(entry)));
|
|||
|
|
|||
|
["deprecations", "documentation", "websiteDocumentation", "internals", "security"].forEach(
|
|||
|
(type) => {
|
|||
|
items[type] = dedupe(items[type]);
|
|||
|
}
|
|||
|
);
|
|||
|
|
|||
|
["dependencies", "devDependencies", "uncategorized"].forEach((type) => {
|
|||
|
Object.entries(items[type]).forEach(([name, entries]) => {
|
|||
|
items[type][name] = dedupe(entries);
|
|||
|
});
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
// Given a list of entries (pull requests, commits), retrieves GitHub usernames
|
|||
|
// (with format `@username`) of everyone who contributed to this version.
|
|||
|
function extractContributors(entries) {
|
|||
|
const set = Object.values(entries).reduce((memo, {__typename, author}) => {
|
|||
|
if (__typename === "PullRequest" && author.__typename !== "Bot") {
|
|||
|
memo.add("@" + author.login);
|
|||
|
// Commit authors are *always* of type "User", so have to discriminate some
|
|||
|
// other way. Making the assumption of a suffix for now, see how that goes.
|
|||
|
} else if (__typename === "Commit" && !author.user.login.endsWith("-bot")) {
|
|||
|
memo.add("@" + author.user.login);
|
|||
|
}
|
|||
|
|
|||
|
return memo;
|
|||
|
}, new Set());
|
|||
|
|
|||
|
return Array.from(set).sort((a, b) => a.localeCompare(b, "en", {sensitivity: "base"}));
|
|||
|
}
|
|||
|
|
|||
|
// Main function. Given a version string (i.e. not a tag!), returns a changelog
|
|||
|
// entry and the list of contributors, for both pre-releases and stable
|
|||
|
// releases. Templates are located at the top of this file.
|
|||
|
async function generateChangelogEntry(changelog, targetVersion) {
|
|||
|
let items = {};
|
|||
|
let template;
|
|||
|
let contributors = [];
|
|||
|
|
|||
|
const codeRepo = new RepositoryFetcher(token, "thelounge");
|
|||
|
const previousVersion = await codeRepo.fetchPreviousVersion(targetVersion);
|
|||
|
|
|||
|
if (isPrerelease(targetVersion)) {
|
|||
|
template = prereleaseTemplate;
|
|||
|
} else {
|
|||
|
template = stableTemplate;
|
|||
|
|
|||
|
const codeCommitsAndPullRequests = await codeRepo.fetchCommitsAndPullRequestsSince(
|
|||
|
"v" + previousVersion
|
|||
|
);
|
|||
|
items = parse(codeCommitsAndPullRequests);
|
|||
|
items.milestone = await codeRepo.fetchMilestone(targetVersion);
|
|||
|
|
|||
|
const websiteRepo = new RepositoryFetcher(token, "thelounge.github.io");
|
|||
|
const previousWebsiteVersion = await websiteRepo.fetchPreviousVersion(targetVersion);
|
|||
|
const websiteCommitsAndPullRequests = await websiteRepo.fetchCommitsAndPullRequestsSince(
|
|||
|
"v" + previousWebsiteVersion
|
|||
|
);
|
|||
|
items.websiteDocumentation = websiteCommitsAndPullRequests;
|
|||
|
|
|||
|
contributors = extractContributors([
|
|||
|
...codeCommitsAndPullRequests,
|
|||
|
...websiteCommitsAndPullRequests,
|
|||
|
]);
|
|||
|
|
|||
|
dedupeEntries(changelog, items);
|
|||
|
}
|
|||
|
|
|||
|
items.version = targetVersion;
|
|||
|
items.date = dayjs().format("YYYY-MM-DD");
|
|||
|
items.fullChangelogUrl = fullChangelogUrl(previousVersion, targetVersion);
|
|||
|
|
|||
|
return {
|
|||
|
changelogEntry: template(items),
|
|||
|
skipped: items.skipped || [],
|
|||
|
contributors,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// Write a changelog entry into the CHANGELOG.md file, right after a marker that
|
|||
|
// indicates where entries are listed.
|
|||
|
function addToChangelog(changelog, newEntry) {
|
|||
|
const changelogMarker = "<!-- New entries go after this line -->\n\n";
|
|||
|
|
|||
|
const markerPosition = changelog.indexOf(changelogMarker) + changelogMarker.length;
|
|||
|
const newChangelog =
|
|||
|
changelog.substring(0, markerPosition) +
|
|||
|
newEntry +
|
|||
|
changelog.substring(markerPosition, changelog.length);
|
|||
|
|
|||
|
writeFile(changelogPath, newChangelog);
|
|||
|
}
|
|||
|
|
|||
|
// Wrapping this in an Async IIFE because async/await is only supported within
|
|||
|
// functions. ¯\_(ツ)_/¯
|
|||
|
(async () => {
|
|||
|
log.info(`Generating a changelog entry for ${colors.bold("v" + version)}, please wait...`);
|
|||
|
const startTime = Date.now();
|
|||
|
let changelogEntry, skipped, contributors;
|
|||
|
|
|||
|
// Step 1: Generate a changelog entry
|
|||
|
|
|||
|
const changelog = await readFile(changelogPath, "utf8");
|
|||
|
|
|||
|
try {
|
|||
|
({changelogEntry, skipped, contributors} = await generateChangelogEntry(
|
|||
|
changelog,
|
|||
|
version
|
|||
|
));
|
|||
|
} catch (error) {
|
|||
|
if (error.response && error.response.status === 401) {
|
|||
|
log.error(`GitHub returned an error: ${colors.red(error.response.message)}`);
|
|||
|
log.error(
|
|||
|
`Make sure your personal access token is set with ${colors.bold(
|
|||
|
"public_repo"
|
|||
|
)} scope.`
|
|||
|
);
|
|||
|
} else {
|
|||
|
log.error(error);
|
|||
|
}
|
|||
|
|
|||
|
process.exit(1);
|
|||
|
}
|
|||
|
|
|||
|
// Step 2: Write that changelog entry into the CHANGELOG.md file
|
|||
|
|
|||
|
try {
|
|||
|
await addToChangelog(changelog, `${changelogEntry.trim()}\n\n`);
|
|||
|
} catch (error) {
|
|||
|
log.error(error);
|
|||
|
process.exit(1);
|
|||
|
}
|
|||
|
|
|||
|
log.info(`The generated entry was added at the top of ${colors.bold("CHANGELOG.md")}.`);
|
|||
|
|
|||
|
// Step 3 (optional): Print a list of skipped entries if there are any
|
|||
|
if (skipped.length > 0) {
|
|||
|
const pad = Math.max(
|
|||
|
...skipped.map((entry) => (entry.title || entry.messageHeadline).length)
|
|||
|
);
|
|||
|
log.warn(`${skipped.length} ${skipped.length > 1 ? "entries were" : "entry was"} skipped:`);
|
|||
|
skipped.forEach((entry) => {
|
|||
|
log.warn(
|
|||
|
`- ${(entry.title || entry.messageHeadline).padEnd(pad)} ${colors.gray(entry.url)}`
|
|||
|
);
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
// Step 4: Print out some information about what just happened to the console
|
|||
|
const commitCommand = `git commit -m 'Add changelog entry for v${version}' CHANGELOG.md`;
|
|||
|
|
|||
|
if (isPrerelease(version)) {
|
|||
|
log.info(`You can now run: ${colors.bold(commitCommand)}`);
|
|||
|
} else {
|
|||
|
log.info(
|
|||
|
`Please edit ${colors.bold("CHANGELOG.md")} to your liking then run: ${colors.bold(
|
|||
|
commitCommand
|
|||
|
)}`
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
log.info(`Finished in ${colors.bold(Date.now() - startTime)}ms.`);
|
|||
|
|
|||
|
// Step 5 (optional): Print contributors shout out if it exists
|
|||
|
if (contributors.length > 0) {
|
|||
|
log.info(`🎉 Thanks to our ${contributors.length} contributors for this release:`);
|
|||
|
log.info(contributors.map((contributor) => colors.green(contributor)).join(", "));
|
|||
|
}
|
|||
|
})();
|