Skip to content

Commit

Permalink
[release-notes] add script to generate release notes from PRs (#68816)
Browse files Browse the repository at this point in the history
Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
3 people authored Jun 15, 2020
1 parent 7e2ab7f commit cc1758d
Show file tree
Hide file tree
Showing 27 changed files with 1,745 additions and 3 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ npm-debug.log*
# apm plugin
/x-pack/plugins/apm/tsconfig.json
apm.tsconfig.json

# release notes script output
report.csv
report.asciidoc
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@
"@kbn/expect": "1.0.0",
"@kbn/optimizer": "1.0.0",
"@kbn/plugin-generator": "1.0.0",
"@kbn/release-notes": "1.0.0",
"@kbn/test": "1.0.0",
"@kbn/utility-types": "1.0.0",
"@microsoft/api-documenter": "7.7.2",
Expand Down
23 changes: 23 additions & 0 deletions packages/kbn-release-notes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@kbn/release-notes",
"version": "1.0.0",
"license": "Apache-2.0",
"main": "target/index.js",
"scripts": {
"kbn:bootstrap": "tsc",
"kbn:watch": "tsc --watch"
},
"dependencies": {
"@kbn/dev-utils": "1.0.0",
"axios": "^0.19.2",
"cheerio": "0.22.0",
"dedent": "^0.7.0",
"graphql": "^14.0.0",
"graphql-tag": "^2.10.3",
"terminal-link": "^2.1.1"
},
"devDependencies": {
"markdown-it": "^10.0.0",
"typescript": "3.9.5"
}
}
162 changes: 162 additions & 0 deletions packages/kbn-release-notes/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you 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.
*/

import Fs from 'fs';
import Path from 'path';
import { inspect } from 'util';

import { run, createFlagError, createFailError, REPO_ROOT } from '@kbn/dev-utils';

import { FORMATS, SomeFormat } from './formats';
import {
iterRelevantPullRequests,
getPr,
Version,
ClassifiedPr,
streamFromIterable,
asyncPipeline,
IrrelevantPrSummary,
isPrRelevant,
classifyPr,
} from './lib';

const rootPackageJson = JSON.parse(
Fs.readFileSync(Path.resolve(REPO_ROOT, 'package.json'), 'utf8')
);
const extensions = FORMATS.map((f) => f.extension);

export function runReleaseNotesCli() {
run(
async ({ flags, log }) => {
const token = flags.token;
if (!token || typeof token !== 'string') {
throw createFlagError('--token must be defined');
}

const version = Version.fromFlag(flags.version);
if (!version) {
throw createFlagError('unable to parse --version, use format "v{major}.{minor}.{patch}"');
}

const includeVersions = Version.fromFlags(flags.include || []);
if (!includeVersions) {
throw createFlagError('unable to parse --include, use format "v{major}.{minor}.{patch}"');
}

const Formats: SomeFormat[] = [];
for (const flag of Array.isArray(flags.format) ? flags.format : [flags.format]) {
const Format = FORMATS.find((F) => F.extension === flag);
if (!Format) {
throw createFlagError(`--format must be one of "${extensions.join('", "')}"`);
}
Formats.push(Format);
}

const filename = flags.filename;
if (!filename || typeof filename !== 'string') {
throw createFlagError('--filename must be a string');
}

if (flags['debug-pr']) {
const number = parseInt(String(flags['debug-pr']), 10);
if (Number.isNaN(number)) {
throw createFlagError('--debug-pr must be a pr number when specified');
}

const summary = new IrrelevantPrSummary(log);
const pr = await getPr(token, number);
log.success(
inspect(
{
version: version.label,
includeVersions: includeVersions.map((v) => v.label),
isPrRelevant: isPrRelevant(pr, version, includeVersions, summary),
...classifyPr(pr, log),
pr,
},
{ depth: 100 }
)
);
summary.logStats();
return;
}

log.info(`Loading all PRs with label [${version.label}] to build release notes...`);

const summary = new IrrelevantPrSummary(log);
const prsToReport: ClassifiedPr[] = [];
const prIterable = iterRelevantPullRequests(token, version, log);
for await (const pr of prIterable) {
if (!isPrRelevant(pr, version, includeVersions, summary)) {
continue;
}
prsToReport.push(classifyPr(pr, log));
}
summary.logStats();

if (!prsToReport.length) {
throw createFailError(
`All PRs with label [${version.label}] were filtered out by the config. Run again with --debug for more info.`
);
}

log.info(`Found ${prsToReport.length} prs to report on`);

for (const Format of Formats) {
const format = new Format(version, prsToReport, log);
const outputPath = Path.resolve(`${filename}.${Format.extension}`);
await asyncPipeline(streamFromIterable(format.print()), Fs.createWriteStream(outputPath));
log.success(`[${Format.extension}] report written to ${outputPath}`);
}
},
{
usage: `node scripts/release_notes --token {token} --version {version}`,
flags: {
alias: {
version: 'v',
include: 'i',
},
string: ['token', 'version', 'format', 'filename', 'include', 'debug-pr'],
default: {
filename: 'report',
version: rootPackageJson.version,
format: extensions,
},
help: `
--token (required) The Github access token to use for requests
--version, -v The version to fetch PRs by, PRs with version labels prior to
this one will be ignored (see --include-version) (default ${
rootPackageJson.version
})
--include, -i A version that is before --version but shouldn't be considered
"released" and cause PRs with a matching label to be excluded from
release notes. Use this when PRs are labeled with a version that
is less that --version and is expected to be released after
--version, can be specified multiple times.
--format Only produce a certain format, options: "${extensions.join('", "')}"
--filename Output filename, defaults to "report"
--debug-pr Fetch and print the details for a single PR, disabling reporting
`,
},
description: `
Fetch details from Github PRs for generating release notes
`,
}
);
}
84 changes: 84 additions & 0 deletions packages/kbn-release-notes/src/formats/asciidoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you 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.
*/

import dedent from 'dedent';

import { Format } from './format';
import {
ASCIIDOC_SECTIONS,
UNKNOWN_ASCIIDOC_SECTION,
AREAS,
UNKNOWN_AREA,
} from '../release_notes_config';

function* lines(body: string) {
for (const line of dedent(body).split('\n')) {
yield `${line}\n`;
}
}

export class AsciidocFormat extends Format {
static extension = 'asciidoc';

*print() {
const sortedAreas = [
...AREAS.slice().sort((a, b) => a.title.localeCompare(b.title)),
UNKNOWN_AREA,
];

yield* lines(`
[[release-notes-${this.version.label}]]
== ${this.version.label} Release Notes
Also see <<breaking-changes-${this.version.major}.${this.version.minor}>>.
`);

for (const section of [...ASCIIDOC_SECTIONS, UNKNOWN_ASCIIDOC_SECTION]) {
const prsInSection = this.prs.filter((pr) => pr.asciidocSection === section);
if (!prsInSection.length) {
continue;
}

yield '\n';
yield* lines(`
[float]
[[${section.id}-${this.version.label}]]
=== ${section.title}
`);

for (const area of sortedAreas) {
const prsInArea = prsInSection.filter((pr) => pr.area === area);

if (!prsInArea.length) {
continue;
}

yield `${area.title}::\n`;
for (const pr of prsInArea) {
const fixes = pr.fixes.length ? `[Fixes ${pr.fixes.join(', ')}] ` : '';
const strippedTitle = pr.title.replace(/^\s*\[[^\]]+\]\s*/, '');
yield `* ${fixes}${strippedTitle} {pull}${pr.number}[#${pr.number}]\n`;
if (pr.note) {
yield ` - ${pr.note}\n`;
}
}
}
}
}
}
74 changes: 74 additions & 0 deletions packages/kbn-release-notes/src/formats/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you 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.
*/

import { Format } from './format';

/**
* Escape a value to conform to field and header encoding defined at https://tools.ietf.org/html/rfc4180
*/
function esc(value: string | number) {
if (typeof value === 'number') {
return String(value);
}

if (!value.includes(',') && !value.includes('\n') && !value.includes('"')) {
return value;
}

return `"${value.split('"').join('""')}"`;
}

function row(...fields: Array<string | number>) {
return fields.map(esc).join(',') + '\r\n';
}

export class CsvFormat extends Format {
static extension = 'csv';

*print() {
// columns
yield row(
'areas',
'versions',
'user',
'title',
'number',
'url',
'date',
'fixes',
'labels',
'state'
);

for (const pr of this.prs) {
yield row(
pr.area.title,
pr.versions.map((v) => v.label).join(', '),
pr.user.name || pr.user.login,
pr.title,
pr.number,
pr.url,
pr.mergedAt,
pr.fixes.join(', '),
pr.labels.join(', '),
pr.state
);
}
}
}
34 changes: 34 additions & 0 deletions packages/kbn-release-notes/src/formats/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you 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.
*/

import { ToolingLog } from '@kbn/dev-utils';

import { Version, ClassifiedPr } from '../lib';

export abstract class Format {
static extension: string;

constructor(
protected readonly version: Version,
protected readonly prs: ClassifiedPr[],
protected readonly log: ToolingLog
) {}

abstract print(): Iterator<string>;
}
Loading

0 comments on commit cc1758d

Please sign in to comment.