Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
- Adds support for the `nodejs16` runtime for Cloud Functions.
- `ext:update` now displays release notes for the new Extension Version.
- `ext:dev:publish` now checks for a CHANGELOG.md file during publsihing.
63 changes: 11 additions & 52 deletions src/commands/ext-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as _ from "lodash";
import * as marked from "marked";
import * as ora from "ora";
import TerminalRenderer = require("marked-terminal");
import * as semver from "semver";

import { checkMinRequiredVersion } from "../checkMinRequiredVersion";
import { Command } from "../command";
Expand All @@ -27,8 +26,6 @@ import {
retryUpdate,
updateFromLocalSource,
updateFromUrlSource,
updateFromRegistryFile,
updateToVersionFromRegistryFile,
updateToVersionFromPublisherSource,
updateFromPublisherSource,
getExistingSourceOrigin,
Expand All @@ -44,32 +41,14 @@ marked.setOptions({
});

function isValidUpdate(existingSourceOrigin: SourceOrigin, newSourceOrigin: SourceOrigin): boolean {
let validUpdate = false;
if (existingSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION) {
if (
[SourceOrigin.OFFICIAL_EXTENSION, SourceOrigin.OFFICIAL_EXTENSION_VERSION].includes(
newSourceOrigin
)
) {
validUpdate = true;
}
} else if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) {
if (
[SourceOrigin.PUBLISHED_EXTENSION, SourceOrigin.PUBLISHED_EXTENSION_VERSION].includes(
newSourceOrigin
)
) {
validUpdate = true;
}
} else if (
existingSourceOrigin === SourceOrigin.LOCAL ||
existingSourceOrigin === SourceOrigin.URL
) {
if ([SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin)) {
validUpdate = true;
}
if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) {
return [SourceOrigin.PUBLISHED_EXTENSION, SourceOrigin.PUBLISHED_EXTENSION_VERSION].includes(
newSourceOrigin
);
} else if (existingSourceOrigin === SourceOrigin.LOCAL) {
return [SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin);
}
return validUpdate;
return false;
}

/**
Expand All @@ -93,7 +72,7 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
);
try {
const projectId = needProjectId(options);
let existingInstance;
let existingInstance: extensionsApi.ExtensionInstance;
try {
existingInstance = await extensionsApi.getInstance(projectId, instanceId);
} catch (err) {
Expand Down Expand Up @@ -132,7 +111,7 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
existingSpec.name,
existingSource
);
const newSourceOrigin = await getSourceOrigin(updateSource);
const newSourceOrigin = getSourceOrigin(updateSource);
const validUpdate = isValidUpdate(existingSourceOrigin, newSourceOrigin);
if (!validUpdate) {
throw new FirebaseError(
Expand Down Expand Up @@ -165,24 +144,6 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
);
break;
}
case SourceOrigin.OFFICIAL_EXTENSION_VERSION:
newSourceName = await updateToVersionFromRegistryFile(
projectId,
instanceId,
existingSpec,
existingSource,
updateSource
);
break;
case SourceOrigin.OFFICIAL_EXTENSION:
newSourceName = await updateFromRegistryFile(
projectId,
instanceId,
existingSpec,
existingSource
);
break;
// falls through
case SourceOrigin.PUBLISHED_EXTENSION_VERSION:
newSourceName = await updateToVersionFromPublisherSource(
projectId,
Expand Down Expand Up @@ -229,10 +190,8 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
return;
}
}
const isOfficial =
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION ||
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION_VERSION;
await displayChanges(existingSpec, newSpec, isOfficial);

await displayChanges(existingSpec, newSpec);

await provisioningHelper.checkProductsProvisioned(projectId, newSpec);

Expand Down
122 changes: 122 additions & 0 deletions src/extensions/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as clc from "cli-color";
import * as marked from "marked";
import * as path from "path";
import * as semver from "semver";
import TerminalRenderer = require("marked-terminal");
import Table = require("cli-table");

import { listExtensionVersions, parseRef } from "./extensionsApi";
import { readFile } from "./localHelper";
import { logger } from "../logger";
import { logLabeledWarning } from "../utils";

marked.setOptions({
renderer: new TerminalRenderer(),
});

const EXTENSIONS_CHANGELOG = "CHANGELOG.md";
const VERSION_LINE_REGEX = /##.*(\d+\.\d+\.\d+).*/;

/*
* getReleaseNotesForUpdate fetches all version between toVersion and fromVersion and returns the relase notes
* for those versions if they exist.
* @param extensionRef
* @param fromVersion the version you are updating from
* @param toVersion the version you are upodating to
* @returns a Record of version number to releaseNotes for that version
*/
export async function getReleaseNotesForUpdate(args: {
extensionRef: string;
fromVersion: string;
toVersion: string;
}): Promise<Record<string, string>> {
const releaseNotes: Record<string, string> = {};
const filter = `id<="${args.toVersion}" AND id>"${args.fromVersion}"`;
const extensionVersions = await listExtensionVersions(args.extensionRef, filter);
extensionVersions.sort((ev1, ev2) => {
return -semver.compare(ev1.spec.version, ev2.spec.version);
});
for (const extensionVersion of extensionVersions) {
Copy link
Contributor

@elvisun elvisun Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes listExtensionVersions returns in a sorted manner, which is not part of the contract from our backend

So it'd be safer to sort it here so users are guaranteed to see all versions in incremental order

if (extensionVersion.releaseNotes) {
const version = parseRef(extensionVersion.ref).version!;
releaseNotes[version] = extensionVersion.releaseNotes;
}
}
return releaseNotes;
}

/**
* displayReleaseNotes prints out a nicely formatted table containing all release notes in an update.
* If there is a major version change, it also prints a warning and highlights those release notes.
*/
export function displayReleaseNotes(releaseNotes: Record<string, string>, fromVersion: string) {
const versions = [fromVersion].concat(Object.keys(releaseNotes));
const breakingVersions = breakingChangesInUpdate(versions);
const table = new Table({ head: ["Version", "What's New"], style: { head: ["yellow", "bold"] } });
for (const [version, note] of Object.entries(releaseNotes)) {
if (breakingVersions.includes(version)) {
table.push([clc.yellow.bold(version), marked(note)]);
} else {
table.push([version, marked(note)]);
}
}

logger.info(clc.bold("What's new with this update:"));
if (breakingVersions.length) {
logLabeledWarning(
"warning",
"This is a major version update, which means it may contain breaking changes." +
" Read the release notes carefully before continuing with this update."
);
}
logger.info(table.toString());
}

/**
* breakingChangesInUpdate identifies which versions in an update are major changes.
* Exported for testing.
*/
export function breakingChangesInUpdate(versionsInUpdate: string[]): string[] {
const breakingVersions: string[] = [];
const semvers = versionsInUpdate.map((v) => semver.parse(v)!).sort(semver.compare);
for (let i = 1; i < semvers.length; i++) {
const hasMajorBump = semvers[i - 1].major < semvers[i].major;
const hasMinorBumpInPreview =
semvers[i - 1].major == 0 && semvers[i].major == 0 && semvers[i - 1].minor < semvers[i].minor;
if (hasMajorBump || hasMinorBumpInPreview) {
breakingVersions.push(semvers[i].raw);
}
}
return breakingVersions;
}

/**
* getLocalChangelog checks directory for a CHANGELOG.md, and parses it into a map of
* version to release notes for that version.
* @param directory The directory to check for
* @returns
*/
export function getLocalChangelog(directory: string): Record<string, string> {
const rawChangelog = readFile(path.resolve(directory, EXTENSIONS_CHANGELOG));
return parseChangelog(rawChangelog);
}

// Exported for testing.
export function parseChangelog(rawChangelog: string): Record<string, string> {
const changelog: Record<string, string> = {};
let currentVersion = "";
for (const line of rawChangelog.split("\n")) {
const matches = line.match(VERSION_LINE_REGEX);
if (matches) {
currentVersion = matches[1]; // The first capture group is the SemVer.
} else if (currentVersion) {
// Throw away lines that aren't under a specific version.
if (!changelog[currentVersion]) {
changelog[currentVersion] = line;
} else {
changelog[currentVersion] += `\n${line}`;
}
}
}
return changelog;
}
19 changes: 8 additions & 11 deletions src/extensions/displayExtensionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export function displayExtInfo(
*/
export function displayUpdateChangesNoInput(
spec: extensionsApi.ExtensionSpec,
newSpec: extensionsApi.ExtensionSpec,
isOfficial = true
newSpec: extensionsApi.ExtensionSpec
): string[] {
const lines: string[] = [];
if (spec.displayName !== newSpec.displayName) {
Expand Down Expand Up @@ -105,15 +104,13 @@ export function displayUpdateChangesNoInput(
);
}

if (!isOfficial) {
if (spec.sourceUrl !== newSpec.sourceUrl) {
lines.push(
"",
"**Source code:**",
deletionColor(`- ${spec.sourceUrl}`),
additionColor(`+ ${newSpec.sourceUrl}`)
);
}
if (spec.sourceUrl !== newSpec.sourceUrl) {
lines.push(
"",
"**Source code:**",
deletionColor(`- ${spec.sourceUrl}`),
additionColor(`+ ${newSpec.sourceUrl}`)
);
}

if (spec.billingRequired && !newSpec.billingRequired) {
Expand Down
7 changes: 6 additions & 1 deletion src/extensions/extensionsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface ExtensionVersion {
spec: ExtensionSpec;
hash: string;
sourceDownloadUri: string;
releaseNotes?: string;
createTime?: string;
}

Expand Down Expand Up @@ -534,7 +535,10 @@ export async function listExtensions(publisherId: string): Promise<Extension[]>
* @param ref user-friendly identifier for the ExtensionVersion (publisher-id/extension-id)
* @param showUnpublished whether to include unpublished ExtensionVersions, default = false
*/
export async function listExtensionVersions(ref: string): Promise<ExtensionVersion[]> {
export async function listExtensionVersions(
ref: string,
filter?: string
): Promise<ExtensionVersion[]> {
const { publisherId, extensionId } = parseRef(ref);
const extensionVersions: ExtensionVersion[] = [];
const getNextPage = async (pageToken?: string) => {
Expand All @@ -545,6 +549,7 @@ export async function listExtensionVersions(ref: string): Promise<ExtensionVersi
auth: true,
origin: api.extensionsOrigin,
query: {
filter,
pageSize: PAGE_SIZE_MAX,
pageToken,
},
Expand Down
Loading