Skip to content

Commit dd4dc12

Browse files
authored
Display releaseNotes during ext:update, and streamline ext:update code (#3672)
* Display releaseNotes during ext:update, and streamline ext:update code in general * Add typing to silence linter issues * adding jsdoc comments * bolding after UX feedback * pr fixes * Check for and display CHANGELOG.md during ext:dev:publish (#3693) * save my place * implement parseChangelog * check for changelog on ext:dev:publish * adding changelog
1 parent c2feb08 commit dd4dc12

12 files changed

+422
-480
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
- Adds support for the `nodejs16` runtime for Cloud Functions.
2+
- `ext:update` now displays release notes for the new Extension Version.
3+
- `ext:dev:publish` now checks for a CHANGELOG.md file during publsihing.

src/commands/ext-update.ts

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as _ from "lodash";
33
import * as marked from "marked";
44
import * as ora from "ora";
55
import TerminalRenderer = require("marked-terminal");
6-
import * as semver from "semver";
76

87
import { checkMinRequiredVersion } from "../checkMinRequiredVersion";
98
import { Command } from "../command";
@@ -27,8 +26,6 @@ import {
2726
retryUpdate,
2827
updateFromLocalSource,
2928
updateFromUrlSource,
30-
updateFromRegistryFile,
31-
updateToVersionFromRegistryFile,
3229
updateToVersionFromPublisherSource,
3330
updateFromPublisherSource,
3431
getExistingSourceOrigin,
@@ -44,32 +41,14 @@ marked.setOptions({
4441
});
4542

4643
function isValidUpdate(existingSourceOrigin: SourceOrigin, newSourceOrigin: SourceOrigin): boolean {
47-
let validUpdate = false;
48-
if (existingSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION) {
49-
if (
50-
[SourceOrigin.OFFICIAL_EXTENSION, SourceOrigin.OFFICIAL_EXTENSION_VERSION].includes(
51-
newSourceOrigin
52-
)
53-
) {
54-
validUpdate = true;
55-
}
56-
} else if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) {
57-
if (
58-
[SourceOrigin.PUBLISHED_EXTENSION, SourceOrigin.PUBLISHED_EXTENSION_VERSION].includes(
59-
newSourceOrigin
60-
)
61-
) {
62-
validUpdate = true;
63-
}
64-
} else if (
65-
existingSourceOrigin === SourceOrigin.LOCAL ||
66-
existingSourceOrigin === SourceOrigin.URL
67-
) {
68-
if ([SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin)) {
69-
validUpdate = true;
70-
}
44+
if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) {
45+
return [SourceOrigin.PUBLISHED_EXTENSION, SourceOrigin.PUBLISHED_EXTENSION_VERSION].includes(
46+
newSourceOrigin
47+
);
48+
} else if (existingSourceOrigin === SourceOrigin.LOCAL) {
49+
return [SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin);
7150
}
72-
return validUpdate;
51+
return false;
7352
}
7453

7554
/**
@@ -93,7 +72,7 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
9372
);
9473
try {
9574
const projectId = needProjectId(options);
96-
let existingInstance;
75+
let existingInstance: extensionsApi.ExtensionInstance;
9776
try {
9877
existingInstance = await extensionsApi.getInstance(projectId, instanceId);
9978
} catch (err) {
@@ -132,7 +111,7 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
132111
existingSpec.name,
133112
existingSource
134113
);
135-
const newSourceOrigin = await getSourceOrigin(updateSource);
114+
const newSourceOrigin = getSourceOrigin(updateSource);
136115
const validUpdate = isValidUpdate(existingSourceOrigin, newSourceOrigin);
137116
if (!validUpdate) {
138117
throw new FirebaseError(
@@ -165,24 +144,6 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
165144
);
166145
break;
167146
}
168-
case SourceOrigin.OFFICIAL_EXTENSION_VERSION:
169-
newSourceName = await updateToVersionFromRegistryFile(
170-
projectId,
171-
instanceId,
172-
existingSpec,
173-
existingSource,
174-
updateSource
175-
);
176-
break;
177-
case SourceOrigin.OFFICIAL_EXTENSION:
178-
newSourceName = await updateFromRegistryFile(
179-
projectId,
180-
instanceId,
181-
existingSpec,
182-
existingSource
183-
);
184-
break;
185-
// falls through
186147
case SourceOrigin.PUBLISHED_EXTENSION_VERSION:
187148
newSourceName = await updateToVersionFromPublisherSource(
188149
projectId,
@@ -229,10 +190,8 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
229190
return;
230191
}
231192
}
232-
const isOfficial =
233-
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION ||
234-
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION_VERSION;
235-
await displayChanges(existingSpec, newSpec, isOfficial);
193+
194+
await displayChanges(existingSpec, newSpec);
236195

237196
await provisioningHelper.checkProductsProvisioned(projectId, newSpec);
238197

src/extensions/changelog.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import * as clc from "cli-color";
2+
import * as marked from "marked";
3+
import * as path from "path";
4+
import * as semver from "semver";
5+
import TerminalRenderer = require("marked-terminal");
6+
import Table = require("cli-table");
7+
8+
import { listExtensionVersions, parseRef } from "./extensionsApi";
9+
import { readFile } from "./localHelper";
10+
import { logger } from "../logger";
11+
import { logLabeledWarning } from "../utils";
12+
13+
marked.setOptions({
14+
renderer: new TerminalRenderer(),
15+
});
16+
17+
const EXTENSIONS_CHANGELOG = "CHANGELOG.md";
18+
const VERSION_LINE_REGEX = /##.*(\d+\.\d+\.\d+).*/;
19+
20+
/*
21+
* getReleaseNotesForUpdate fetches all version between toVersion and fromVersion and returns the relase notes
22+
* for those versions if they exist.
23+
* @param extensionRef
24+
* @param fromVersion the version you are updating from
25+
* @param toVersion the version you are upodating to
26+
* @returns a Record of version number to releaseNotes for that version
27+
*/
28+
export async function getReleaseNotesForUpdate(args: {
29+
extensionRef: string;
30+
fromVersion: string;
31+
toVersion: string;
32+
}): Promise<Record<string, string>> {
33+
const releaseNotes: Record<string, string> = {};
34+
const filter = `id<="${args.toVersion}" AND id>"${args.fromVersion}"`;
35+
const extensionVersions = await listExtensionVersions(args.extensionRef, filter);
36+
extensionVersions.sort((ev1, ev2) => {
37+
return -semver.compare(ev1.spec.version, ev2.spec.version);
38+
});
39+
for (const extensionVersion of extensionVersions) {
40+
if (extensionVersion.releaseNotes) {
41+
const version = parseRef(extensionVersion.ref).version!;
42+
releaseNotes[version] = extensionVersion.releaseNotes;
43+
}
44+
}
45+
return releaseNotes;
46+
}
47+
48+
/**
49+
* displayReleaseNotes prints out a nicely formatted table containing all release notes in an update.
50+
* If there is a major version change, it also prints a warning and highlights those release notes.
51+
*/
52+
export function displayReleaseNotes(releaseNotes: Record<string, string>, fromVersion: string) {
53+
const versions = [fromVersion].concat(Object.keys(releaseNotes));
54+
const breakingVersions = breakingChangesInUpdate(versions);
55+
const table = new Table({ head: ["Version", "What's New"], style: { head: ["yellow", "bold"] } });
56+
for (const [version, note] of Object.entries(releaseNotes)) {
57+
if (breakingVersions.includes(version)) {
58+
table.push([clc.yellow.bold(version), marked(note)]);
59+
} else {
60+
table.push([version, marked(note)]);
61+
}
62+
}
63+
64+
logger.info(clc.bold("What's new with this update:"));
65+
if (breakingVersions.length) {
66+
logLabeledWarning(
67+
"warning",
68+
"This is a major version update, which means it may contain breaking changes." +
69+
" Read the release notes carefully before continuing with this update."
70+
);
71+
}
72+
logger.info(table.toString());
73+
}
74+
75+
/**
76+
* breakingChangesInUpdate identifies which versions in an update are major changes.
77+
* Exported for testing.
78+
*/
79+
export function breakingChangesInUpdate(versionsInUpdate: string[]): string[] {
80+
const breakingVersions: string[] = [];
81+
const semvers = versionsInUpdate.map((v) => semver.parse(v)!).sort(semver.compare);
82+
for (let i = 1; i < semvers.length; i++) {
83+
const hasMajorBump = semvers[i - 1].major < semvers[i].major;
84+
const hasMinorBumpInPreview =
85+
semvers[i - 1].major == 0 && semvers[i].major == 0 && semvers[i - 1].minor < semvers[i].minor;
86+
if (hasMajorBump || hasMinorBumpInPreview) {
87+
breakingVersions.push(semvers[i].raw);
88+
}
89+
}
90+
return breakingVersions;
91+
}
92+
93+
/**
94+
* getLocalChangelog checks directory for a CHANGELOG.md, and parses it into a map of
95+
* version to release notes for that version.
96+
* @param directory The directory to check for
97+
* @returns
98+
*/
99+
export function getLocalChangelog(directory: string): Record<string, string> {
100+
const rawChangelog = readFile(path.resolve(directory, EXTENSIONS_CHANGELOG));
101+
return parseChangelog(rawChangelog);
102+
}
103+
104+
// Exported for testing.
105+
export function parseChangelog(rawChangelog: string): Record<string, string> {
106+
const changelog: Record<string, string> = {};
107+
let currentVersion = "";
108+
for (const line of rawChangelog.split("\n")) {
109+
const matches = line.match(VERSION_LINE_REGEX);
110+
if (matches) {
111+
currentVersion = matches[1]; // The first capture group is the SemVer.
112+
} else if (currentVersion) {
113+
// Throw away lines that aren't under a specific version.
114+
if (!changelog[currentVersion]) {
115+
changelog[currentVersion] = line;
116+
} else {
117+
changelog[currentVersion] += `\n${line}`;
118+
}
119+
}
120+
}
121+
return changelog;
122+
}

src/extensions/displayExtensionInfo.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ export function displayExtInfo(
7474
*/
7575
export function displayUpdateChangesNoInput(
7676
spec: extensionsApi.ExtensionSpec,
77-
newSpec: extensionsApi.ExtensionSpec,
78-
isOfficial = true
77+
newSpec: extensionsApi.ExtensionSpec
7978
): string[] {
8079
const lines: string[] = [];
8180
if (spec.displayName !== newSpec.displayName) {
@@ -105,15 +104,13 @@ export function displayUpdateChangesNoInput(
105104
);
106105
}
107106

108-
if (!isOfficial) {
109-
if (spec.sourceUrl !== newSpec.sourceUrl) {
110-
lines.push(
111-
"",
112-
"**Source code:**",
113-
deletionColor(`- ${spec.sourceUrl}`),
114-
additionColor(`+ ${newSpec.sourceUrl}`)
115-
);
116-
}
107+
if (spec.sourceUrl !== newSpec.sourceUrl) {
108+
lines.push(
109+
"",
110+
"**Source code:**",
111+
deletionColor(`- ${spec.sourceUrl}`),
112+
additionColor(`+ ${newSpec.sourceUrl}`)
113+
);
117114
}
118115

119116
if (spec.billingRequired && !newSpec.billingRequired) {

src/extensions/extensionsApi.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface ExtensionVersion {
4141
spec: ExtensionSpec;
4242
hash: string;
4343
sourceDownloadUri: string;
44+
releaseNotes?: string;
4445
createTime?: string;
4546
}
4647

@@ -534,7 +535,10 @@ export async function listExtensions(publisherId: string): Promise<Extension[]>
534535
* @param ref user-friendly identifier for the ExtensionVersion (publisher-id/extension-id)
535536
* @param showUnpublished whether to include unpublished ExtensionVersions, default = false
536537
*/
537-
export async function listExtensionVersions(ref: string): Promise<ExtensionVersion[]> {
538+
export async function listExtensionVersions(
539+
ref: string,
540+
filter?: string
541+
): Promise<ExtensionVersion[]> {
538542
const { publisherId, extensionId } = parseRef(ref);
539543
const extensionVersions: ExtensionVersion[] = [];
540544
const getNextPage = async (pageToken?: string) => {
@@ -545,6 +549,7 @@ export async function listExtensionVersions(ref: string): Promise<ExtensionVersi
545549
auth: true,
546550
origin: api.extensionsOrigin,
547551
query: {
552+
filter,
548553
pageSize: PAGE_SIZE_MAX,
549554
pageToken,
550555
},

0 commit comments

Comments
 (0)