diff --git a/common/changes/@microsoft/rush/chao-rush-notification_2024-06-23-18-32.json b/common/changes/@microsoft/rush/chao-rush-notification_2024-06-23-18-32.json new file mode 100644 index 00000000000..e25997cd886 --- /dev/null +++ b/common/changes/@microsoft/rush/chao-rush-notification_2024-06-23-18-32.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "(EXPERIMENTAL) Initial implementation of Rush alerts feature", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/chao-rush-notification_2024-07-17-06-11.json b/common/changes/@rushstack/terminal/chao-rush-notification_2024-07-17-06-11.json new file mode 100644 index 00000000000..42871439fa2 --- /dev/null +++ b/common/changes/@rushstack/terminal/chao-rush-notification_2024-07-17-06-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/terminal", + "comment": "Improve the PrintUtilities API to handle an edge case when word-wrapping a final line", + "type": "patch" + } + ], + "packageName": "@rushstack/terminal" +} \ No newline at end of file diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 60e50cc8c3a..c4b37573381 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -103,7 +103,7 @@ "policyName": "rush", "definitionName": "lockStepVersion", "version": "5.129.7", - "nextBump": "patch", + "nextBump": "minor", "mainProject": "@microsoft/rush" } ] diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1bb9b2bf0c0..969d5b1b0ae 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -475,6 +475,7 @@ export interface IExperimentsJson { noChmodFieldInTarHeaderNormalization?: boolean; omitImportersFromPreventManualShrinkwrapChanges?: boolean; printEventHooksOutputToConsole?: boolean; + rushAlerts?: boolean; useIPCScriptsInWatchMode?: boolean; usePnpmFrozenLockfileForRushInstall?: boolean; usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; @@ -1357,6 +1358,8 @@ export class RushConstants { static readonly projectShrinkwrapFilename: 'shrinkwrap-deps.json'; static readonly rebuildCommandName: 'rebuild'; static readonly repoStateFilename: 'repo-state.json'; + static readonly rushAlertsConfigFilename: 'rush-alerts.json'; + static readonly rushAlertsStateFilename: 'rush-alerts-state.json'; static readonly rushJsonFilename: 'rush.json'; static readonly rushLogsFolderName: 'rush-logs'; static readonly rushPackageName: '@microsoft/rush'; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index ab425270e97..326b7c42af0 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -82,10 +82,19 @@ * If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`. */ /*[LINE "HYPOTHETICAL"]*/ "generateProjectImpactGraphDuringRushUpdate": true, + /** * If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead * of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist * across invocations. */ - /*[LINE "HYPOTHETICAL"]*/ "useIPCScriptsInWatchMode": true + /*[LINE "HYPOTHETICAL"]*/ "useIPCScriptsInWatchMode": true, + + /** + * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers + * working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. + * This ensures that important notices will be seen by anyone doing active development, since people often + * ignore normal discussion group messages or don't know to subscribe. + */ + /*[LINE "HYPOTHETICAL"]*/ "rushAlerts": true } diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/rush-alerts.json b/libraries/rush-lib/assets/rush-init/common/config/rush/rush-alerts.json new file mode 100644 index 00000000000..f880525e11a --- /dev/null +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/rush-alerts.json @@ -0,0 +1,90 @@ +/** + * This configuration file manages the Rush alerts feature. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-alerts.schema.json", + + /** + * Settings such as `startTime` and `endTime` will use this timezone. + * If omitted, the default timezone is UTC (`+00:00`). + */ + "timezone": "-08:00", + + /** + * An array of alert messages and conditions for triggering them. + */ + "alerts": [ + /*[BEGIN "DEMO"]*/ + { + /** + * When the alert is displayed, this title will appear at the top of the message box. + * It should be a single line of text, as concise as possible. + */ + "title": "Node.js upgrade soon!", + + /** + * When the alert is displayed, this text appears in the message box. To make the + * JSON file more readable, if the text is longer than one line, you can instead provide + * an array of strings that will be concatenated. Your text may contain newline characters, + * but generally this is unnecessary because word-wrapping is automatically applied. + */ + "message": [ + "This Thursday, we will complete the Node.js version upgrade. Any pipelines that", + " still have not upgraded will be temporarily disabled." + ], + + /** + * (OPTIONAL) To avoid spamming users, the `title` and `message` settings should be kept + * as concise as possible. If you need to provide more detail, use this setting to + * print a hyperlink to a web page with further guidance. + */ + /*[LINE "HYPOTHETICAL"]*/ "detailsUrl": "https://contoso.com/team-wiki/2024-01-01-migration", + + /** + * (OPTIONAL) If `startTime` is specified, then this alert will not be shown prior to + * that time. + * + * Keep in mind that the alert is not guaranteed to be shown at this time, or at all: + * Alerts are only displayed after a Rush command has triggered fetching of the + * latest rush-alerts.json configuration. Also, display of alerts is throttled to + * avoid spamming the user with too many messages. If you need to test your alert, + * set the environment variable `RUSH_ALERTS_DEBUG=1` to disable throttling. + * + * The `startTime` should be specified as `YYYY-MM-DD HH:MM` using 24 hour time format, + * or else `YYYY-MM-DD` in which case the time part will be `00:00` (start of that day). + * The time zone is obtained from the `timezone` setting above. + */ + /*[LINE "HYPOTHETICAL"]*/ "startTime": "2024-01-01 15:00", + + /** + * (OPTIONAL) This alert will not be shown if the current time is later than `endTime`. + * The format is the same as `startTime`. + */ + /*[LINE "HYPOTHETICAL"]*/ "endTime": "2024-01-05", + + /** + * (OPTIONAL) The filename of a script that determines whether this alert can be shown, + * found in the "common/config/rush/alert-scripts" folder. The script must define + * a CommonJS export named `canShowAlert` that returns a boolean value, for example: + * + * ``` + * module.exports.canShowAlert = function () { + * // (your logic goes here) + * return true; + * } + * ``` + * + * Rush will invoke this script with the working directory set to the monorepo root folder, + * with no guarantee that `rush install` has been run. To ensure up-to-date alerts, Rush + * may fetch and checkout the "common/config/rush-alerts" folder in an unpredictable temporary + * path. Therefore, your script should avoid importing dependencies from outside its folder, + * generally be kept as simple and reliable and quick as possible. For more complex conditions, + * we suggest to design some other process that prepares a data file or environment variable + * that can be cheaply checked by your condition script. + */ + /*[LINE "HYPOTHETICAL"]*/ "conditionScript": "rush-alexrt-node-upgrade.js" + } + /*[END "DEMO"]*/ + ] +} diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index af4b67e29ad..0e66fa3dc1c 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -98,6 +98,14 @@ export interface IExperimentsJson { * across invocations. */ useIPCScriptsInWatchMode?: boolean; + + /** + * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers + * working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. + * This ensures that important notices will be seen by anyone doing active development, since people often + * ignore normal discussion group messages or don't know to subscribe. + */ + rushAlerts?: boolean; } const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index d93b303ac4b..5ab4b13d7a7 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -71,7 +71,8 @@ const knownRushConfigFilenames: string[] = [ RushConstants.versionPoliciesFilename, RushConstants.rushPluginsConfigFilename, RushConstants.pnpmConfigFilename, - RushConstants.subspacesConfigFilename + RushConstants.subspacesConfigFilename, + RushConstants.rushAlertsConfigFilename ]; /** diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 733bc14b071..74893b48952 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -59,6 +59,7 @@ import { RushSession } from '../pluginFramework/RushSession'; import { PhasedScriptAction } from './scriptActions/PhasedScriptAction'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; +import { RushAlerts } from '../utilities/RushAlerts'; /** * Options for `RushCommandLineParser`. @@ -222,6 +223,33 @@ export class RushCommandLineParser extends CommandLineParser { try { await this._wrapOnExecuteAsync(); + + try { + const { configuration: experiments } = this.rushConfiguration.experimentsConfiguration; + if (experiments.rushAlerts) { + this._terminal.writeDebugLine('Checking Rush alerts...'); + // Print out alerts if have after each successful command actions + const rushAlerts: RushAlerts = new RushAlerts({ + rushConfiguration: this.rushConfiguration, + terminal: this._terminal + }); + if (await rushAlerts.isAlertsStateUpToDateAsync()) { + await rushAlerts.printAlertsAsync(); + } else { + await rushAlerts.retrieveAlertsAsync(); + } + } + } catch (error) { + if (error instanceof AlreadyReportedError) { + throw error; + } + // Generally the RushAlerts implementation should handle its own error reporting; if not, + // clarify the source, since the Rush Alerts behavior is nondeterministic and may not repro easily: + this._terminal.writeErrorLine(`\nAn unexpected error was encountered by the Rush alerts feature:`); + this._terminal.writeErrorLine(error.message); + throw new AlreadyReportedError(); + } + // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; } catch (error) { diff --git a/libraries/rush-lib/src/cli/actions/InitAction.ts b/libraries/rush-lib/src/cli/actions/InitAction.ts index ac5273ff94f..bad633ceb58 100644 --- a/libraries/rush-lib/src/cli/actions/InitAction.ts +++ b/libraries/rush-lib/src/cli/actions/InitAction.ts @@ -143,7 +143,8 @@ export class InitAction extends BaseConfiglessRushAction { ]; const experimentalTemplateFilePaths: string[] = [ - `common/config/rush/${RushConstants.subspacesConfigFilename}` + `common/config/rush/${RushConstants.subspacesConfigFilename}`, + 'common/config/rush/rush-alerts.json' ]; if (this._experimentsParameter.value) { diff --git a/libraries/rush-lib/src/logic/RushConstants.ts b/libraries/rush-lib/src/logic/RushConstants.ts index 201a7523128..ad0099d7cc1 100644 --- a/libraries/rush-lib/src/logic/RushConstants.ts +++ b/libraries/rush-lib/src/logic/RushConstants.ts @@ -325,4 +325,14 @@ export class RushConstants { * The filename for the last link flag */ public static readonly lastLinkFlagFilename: 'last-link' = 'last-link'; + + /** + * The filename for the Rush alerts config file. + */ + public static readonly rushAlertsConfigFilename: 'rush-alerts.json' = 'rush-alerts.json'; + + /** + * The filename for the machine-generated file that tracks state for Rush alerts. + */ + public static readonly rushAlertsStateFilename: 'rush-alerts-state.json' = 'rush-alerts-state.json'; } diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index 38df9737ddc..c2026513fdb 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -65,6 +65,10 @@ "useIPCScriptsInWatchMode": { "description": "If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist across invocations.", "type": "boolean" + }, + "rushAlerts": { + "description": "(UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. This ensures that important notices will be seen by anyone doing active development, since people often ignore normal discussion group messages or don't know to subscribe.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/schemas/rush-alerts.schema.json b/libraries/rush-lib/src/schemas/rush-alerts.schema.json new file mode 100644 index 00000000000..12cc5c4b8bf --- /dev/null +++ b/libraries/rush-lib/src/schemas/rush-alerts.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Rush rush-alerts.json file", + "description": "This configuration file provides settings to rush alerts feature.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", + "type": "string" + }, + + "timezone": { + "description": "Settings such as `startTime` and `endTime` will use this timezone.\n\nIf omitted, the default timezone is UTC (`+00:00`).", + "type": "string" + }, + "alerts": { + "description": "An array of alert messages and conditions for triggering them.", + "items": { + "$ref": "#/definitions/IAlert" + }, + "type": "array" + } + }, + "definitions": { + "IAlert": { + "type": "object", + "properties": { + "title": { + "description": "When the alert is displayed, this title will appear at the top of the message box. It should be a single line of text, as concise as possible.", + "type": "string" + }, + "message": { + "description": "When the alert is displayed, this text appears in the message box.\n\nTo make the JSON file more readable, if the text is longer than one line, you can instead provide an array of strings that will be concatenated.\n\nYour text may contain newline characters, but generally this is unnecessary because word-wrapping is automatically applied.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "detailsUrl": { + "description": "(OPTIONAL) To avoid spamming users, the `title` and `message` settings should be kept as concise as possible.\n\nIf you need to provide more detail, use this setting to print a hyperlink to a web page with further guidance.", + "type": "string" + }, + "startTime": { + "description": "(OPTIONAL) If `startTime` is specified, then this alert will not be shown prior to that time.\n\nKeep in mind that the alert is not guaranteed to be shown at this time, or at all. Alerts are only displayed after a Rush command has triggered fetching of the latest rush-alerts.json configuration.\n\nAlso, display of alerts is throttled to avoid spamming the user with too many messages.\n\nIf you need to test your alert, set the environment variable `RUSH_ALERTS_DEBUG=1` to disable throttling.\n\nThe `startTime` should be specified as `YYYY-MM-DD HH:MM` using 24 hour time format, or else `YYYY-MM-DD` in which case the time part will be `00:00` (start of that day). The time zone is obtained from the `timezone` setting above.", + "type": "string" + }, + "endTime": { + "description": "(OPTIONAL) This alert will not be shown if the current time is later than `endTime`.\n\nThe format is the same as `startTime`.", + "type": "string" + }, + "conditionScript": { + "description": "(OPTIONAL) The filename of a script that determines whether this alert can be shown, found in the 'common/config/rush/alert-scripts' folder.\n\nThe script must define a CommonJS export named `canShowAlert` that returns a boolean value, for example:\n\n`module.exports.canShowAlert = function () { // (your logic goes here) return true; }`.\n\nRush will invoke this script with the working directory set to the monorepo root folder, with no guarantee that `rush install` has been run.\n\nTo ensure up-to-date alerts, Rush may fetch and checkout the 'common/config/rush-alerts' folder in an unpredictable temporary path. Therefore, your script should avoid importing dependencies from outside its folder, generally be kept as simple and reliable and quick as possible.\n\nFor more complex conditions, we suggest to design some other process that prepares a data file or environment variable that can be cheaply checked by your condition script.", + "type": "string" + } + }, + "required": ["title", "message"] + } + } +} diff --git a/libraries/rush-lib/src/utilities/RushAlerts.ts b/libraries/rush-lib/src/utilities/RushAlerts.ts new file mode 100644 index 00000000000..ac47145f59f --- /dev/null +++ b/libraries/rush-lib/src/utilities/RushAlerts.ts @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Colorize, PrintUtilities, type Terminal } from '@rushstack/terminal'; +import type { RushConfiguration } from '../api/RushConfiguration'; +import { FileSystem, JsonFile, JsonSchema, JsonSyntax } from '@rushstack/node-core-library'; +import rushAlertsSchemaJson from '../schemas/rush-alerts.schema.json'; +import { RushConstants } from '../logic/RushConstants'; + +export interface IRushAlertsOptions { + rushConfiguration: RushConfiguration; + terminal: Terminal; +} + +interface IRushAlertsConfig { + alerts: Array; +} +interface IRushAlertsConfigEntry { + title: string; + message: Array; + detailsUrl: string; + startTime: string; + endTime: string; + conditionScript?: string; +} +interface IRushAlertsState { + lastUpdateTime: string; + alerts: Array; +} +interface IRushAlertStateEntry { + title: string; + message: Array | string; + detailsUrl: string; +} + +export class RushAlerts { + private readonly _rushConfiguration: RushConfiguration; + private readonly _terminal: Terminal; + private static _rushAlertsJsonSchema: JsonSchema = JsonSchema.fromLoadedObject(rushAlertsSchemaJson); + + public readonly rushAlertsStateFilePath: string; + + public constructor(options: IRushAlertsOptions) { + this._rushConfiguration = options.rushConfiguration; + this._terminal = options.terminal; + + this.rushAlertsStateFilePath = `${this._rushConfiguration.commonTempFolder}/${RushConstants.rushAlertsConfigFilename}`; + } + + private async _loadRushAlertsStateAsync(): Promise { + if (!(await FileSystem.existsAsync(this.rushAlertsStateFilePath))) { + return undefined; + } + const rushAlertsState: IRushAlertsState = await JsonFile.loadAsync(this.rushAlertsStateFilePath, { + jsonSyntax: JsonSyntax.JsonWithComments + }); + return rushAlertsState; + } + + public async isAlertsStateUpToDateAsync(): Promise { + const rushAlertsState: IRushAlertsState | undefined = await this._loadRushAlertsStateAsync(); + + if (rushAlertsState === undefined || !rushAlertsState.lastUpdateTime) { + return false; + } + + const currentTime: Date = new Date(); + const lastUpdateTime: Date = new Date(rushAlertsState.lastUpdateTime); + + const hours: number = (Number(currentTime) - Number(lastUpdateTime)) / (1000 * 60 * 60); + + if (hours > 24) { + return false; + } + + return true; + } + + public async retrieveAlertsAsync(): Promise { + const rushAlertsConfigFilePath: string = `${this._rushConfiguration.commonRushConfigFolder}/${RushConstants.rushAlertsConfigFilename}`; + + if (await FileSystem.existsAsync(rushAlertsConfigFilePath)) { + const rushAlertsConfig: IRushAlertsConfig = JsonFile.loadAndValidate( + rushAlertsConfigFilePath, + RushAlerts._rushAlertsJsonSchema + ); + const validAlerts: Array = []; + if (rushAlertsConfig?.alerts.length !== 0) { + for (const alert of rushAlertsConfig.alerts) { + if (await this._isAlertValidAsync(alert)) { + validAlerts.push({ + title: alert.title, + message: alert.message, + detailsUrl: alert.detailsUrl + }); + } + } + } + + await this._writeRushAlertStateAsync(validAlerts); + } + } + + public async printAlertsAsync(): Promise { + const rushAlertsState: IRushAlertsState | undefined = await this._loadRushAlertsStateAsync(); + + if (!rushAlertsState) { + return; + } + + if (rushAlertsState?.alerts.length !== 0) { + this._terminal.writeLine(); + for (const alert of rushAlertsState.alerts) { + this._printMessageInBoxStyle(alert); + } + } + } + + private static _parseDate(dateString: string): Date { + const parsedDate: Date = new Date(dateString); + if (isNaN(parsedDate.getTime())) { + throw new Error(`Invalid date/time value ${JSON.stringify(dateString)}`); + } + return parsedDate; + } + + private async _isAlertValidAsync(alert: IRushAlertsConfigEntry): Promise { + const timeNow: Date = new Date(); + + if (alert.startTime) { + const startTime: Date = RushAlerts._parseDate(alert.startTime); + if (timeNow < startTime) { + return false; + } + } + + if (alert.endTime) { + const endTime: Date = RushAlerts._parseDate(alert.endTime); + if (timeNow > endTime) { + return false; + } + } + + const conditionScript: string | undefined = alert.conditionScript; + if (conditionScript) { + // "(OPTIONAL) The filename of a script that determines whether this alert can be shown, + // found in the "common/config/rush/alert-scripts" folder." ... "To ensure up-to-date alerts, Rush + // may fetch and checkout the "common/config/rush-alerts" folder in an unpredictable temporary + // path. Therefore, your script should avoid importing dependencies from outside its folder, + // generally be kept as simple and reliable and quick as possible." + if (conditionScript.indexOf('/') >= 0 || conditionScript.indexOf('\\') >= 0) { + throw new Error( + `The rush-alerts.json file contains a "conditionScript" that is not inside the "alert-scripts" folder: ` + + JSON.stringify(conditionScript) + ); + } + const conditionScriptPath: string = `${this._rushConfiguration.rushJsonFolder}/common/config/rush/alert-scripts/${conditionScript}`; + if (!(await FileSystem.existsAsync(conditionScriptPath))) { + throw new Error( + 'The "conditionScript" field in rush-alerts.json refers to a nonexistent file:\n' + + conditionScriptPath + ); + } + + this._terminal.writeDebugLine(`Invoking condition script "${conditionScript}" from rush-alerts.json`); + const startTimemark: number = performance.now(); + + interface IAlertsConditionScriptModule { + canShowAlert(): boolean; + } + + let conditionScriptModule: IAlertsConditionScriptModule; + try { + conditionScriptModule = require(conditionScriptPath); + + if (typeof conditionScriptModule.canShowAlert !== 'function') { + throw new Error('The "canShowAlert" module export is missing'); + } + } catch (e) { + throw new Error( + `Error loading condition script "${conditionScript}" from rush-alerts.json:\n${e.stack}` + ); + } + + const oldCwd: string = process.cwd(); + + let conditionResult: boolean; + try { + // "Rush will invoke this script with the working directory set to the monorepo root folder, + // with no guarantee that `rush install` has been run." + process.chdir(this._rushConfiguration.rushJsonFolder); + conditionResult = conditionScriptModule.canShowAlert(); + + if (typeof conditionResult !== 'boolean') { + throw new Error('canShowAlert() did not return a boolean value'); + } + } catch (e) { + throw new Error( + `Error invoking condition script "${conditionScript}" from rush-alerts.json:\n${e.stack}` + ); + } finally { + process.chdir(oldCwd); + } + + const totalMs: number = performance.now() - startTimemark; + this._terminal.writeDebugLine( + `Invoked conditionScript "${conditionScript}"` + + ` in ${Math.round(totalMs)} ms with result "${conditionResult}"` + ); + + if (!conditionResult) { + return false; + } + } + return true; + } + + private _printMessageInBoxStyle(alert: IRushAlertStateEntry): void { + const boxTitle: string = alert.title.toUpperCase(); + + const boxMessage: string = typeof alert.message === 'string' ? alert.message : alert.message.join(''); + + const boxDetails: string = alert.detailsUrl ? 'Details: ' + alert.detailsUrl : ''; + + // ...minus the padding. + const PADDING: number = '╔══╗'.length; + + // Try to make it wide enough to fit the (unwrapped) strings... + let lineLength: number = Math.max(boxTitle.length, boxMessage.length, boxDetails.length); + + // ...but don't exceed the console width, and also keep it under 80... + lineLength = Math.min(lineLength, (PrintUtilities.getConsoleWidth() ?? 80) - PADDING, 80 - PADDING); + + // ...and the width needs to be at least 40 characters... + lineLength = Math.max(lineLength, 40 - PADDING); + + const lines: string[] = [ + ...PrintUtilities.wrapWordsToLines(boxTitle, lineLength).map((x) => + Colorize.bold(x.padEnd(lineLength)) + ), + '', + ...PrintUtilities.wrapWordsToLines(boxMessage, lineLength).map((x) => x.padEnd(lineLength)) + ]; + if (boxDetails) { + lines.push( + '', + ...PrintUtilities.wrapWordsToLines(boxDetails, lineLength).map((x) => + Colorize.cyan(x.padEnd(lineLength)) + ) + ); + } + + // Print the box + this._terminal.writeLine('╔═' + '═'.repeat(lineLength) + '═╗'); + for (const line of lines) { + this._terminal.writeLine(`║ ${line.padEnd(lineLength)} ║`); + } + this._terminal.writeLine('╚═' + '═'.repeat(lineLength) + '═╝'); + } + + private async _writeRushAlertStateAsync(validAlerts: Array): Promise { + if (validAlerts.length > 0) { + const rushAlertsState: IRushAlertsState = { + lastUpdateTime: new Date().toISOString(), + alerts: validAlerts + }; + + await JsonFile.saveAsync(rushAlertsState, this.rushAlertsStateFilePath, { + ignoreUndefinedValues: true, + headerComment: '// THIS FILE IS MACHINE-GENERATED -- DO NOT MODIFY', + jsonSyntax: JsonSyntax.JsonWithComments + }); + } else { + // if no valid alerts + // remove exist alerts state if exist + await FileSystem.deleteFileAsync(this.rushAlertsStateFilePath); + } + } +} diff --git a/libraries/terminal/src/PrintUtilities.ts b/libraries/terminal/src/PrintUtilities.ts index 6b22e6acd32..b59ea247961 100644 --- a/libraries/terminal/src/PrintUtilities.ts +++ b/libraries/terminal/src/PrintUtilities.ts @@ -156,6 +156,20 @@ export class PrintUtilities { previousWhitespaceMatch = currentWhitespaceMatch; } + if ( + previousWhitespaceMatch && + line.length + linePrefixLength - currentLineStartIndex > maxLineLength + ) { + const whitespaceToSplitAt: RegExpExecArray = previousWhitespaceMatch; + + wrappedLines.push( + linePrefix + + lineAdditionalPrefix + + line.substring(currentLineStartIndex, whitespaceToSplitAt.index) + ); + currentLineStartIndex = whitespaceToSplitAt.index + whitespaceToSplitAt[0].length; + } + if (currentLineStartIndex < line.length) { wrappedLines.push(linePrefix + lineAdditionalPrefix + line.substring(currentLineStartIndex)); } diff --git a/libraries/terminal/src/test/PrintUtilities.test.ts b/libraries/terminal/src/test/PrintUtilities.test.ts index f09a79b3e0a..81fc2e51526 100644 --- a/libraries/terminal/src/test/PrintUtilities.test.ts +++ b/libraries/terminal/src/test/PrintUtilities.test.ts @@ -103,7 +103,7 @@ describe(PrintUtilities.name, () => { .split('[n]'); expect(outputLines).toMatchSnapshot(); - expect(outputLines[0].trim().length).toEqual(expectedWidth); + expect(outputLines.every((x) => x.length <= expectedWidth)); } const MESSAGE: string = @@ -149,5 +149,25 @@ describe(PrintUtilities.name, () => { PrintUtilities.printMessageInBox(userMessage, terminal, 50); validateOutput(50); }); + + it('word-wraps a message with a trailing fragment', () => { + const lines: string[] = PrintUtilities.wrapWordsToLines( + 'This Thursday, we will complete the Node.js version upgrade. Any pipelines that still have not upgraded will be temporarily disabled.', + 36 + ); + expect(lines).toMatchInlineSnapshot(` +Array [ + "This Thursday, we will complete the", + "Node.js version upgrade. Any", + "pipelines that still have not", + "upgraded will be temporarily", + "disabled.", +] +`); + + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(36); + } + }); }); }); diff --git a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap index da65f854acf..511965a165b 100644 --- a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap @@ -95,13 +95,16 @@ Array [ exports[`PrintUtilities wrapWordsToLines with prefix="| " applies pre-existing indents on both margins 1`] = ` Array [ "| Lorem ipsum dolor sit amet, consectetuer", - "| adipiscing elit. Maecenas porttitor congue massa.", + "| adipiscing elit. Maecenas porttitor congue", + "| massa.", "| ", "| Lorem ipsum dolor sit amet, consectetuer", - "| adipiscing elit. Maecenas porttitor congue massa.", + "| adipiscing elit. Maecenas porttitor congue", + "| massa.", "| ", "| Lorem ipsum dolor sit amet, consectetuer", - "| adipiscing elit. Maecenas porttitor congue massa.", + "| adipiscing elit. Maecenas porttitor congue", + "| massa.", ] `; @@ -111,7 +114,8 @@ Array [ "| occurrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrred", "| while pushing commits to git remote. Please make", "| sure you have installed and enabled git lfs. The", - "| easiest way to do that is run the provided setup script:", + "| easiest way to do that is run the provided setup", + "| script:", "| ", "| common/scripts/setup.sh", "| ", @@ -166,13 +170,16 @@ Array [ exports[`PrintUtilities wrapWordsToLines with prefix="4" applies pre-existing indents on both margins 1`] = ` Array [ " Lorem ipsum dolor sit amet, consectetuer", - " adipiscing elit. Maecenas porttitor congue massa.", + " adipiscing elit. Maecenas porttitor congue", + " massa.", " ", " Lorem ipsum dolor sit amet, consectetuer", - " adipiscing elit. Maecenas porttitor congue massa.", + " adipiscing elit. Maecenas porttitor congue", + " massa.", " ", " Lorem ipsum dolor sit amet, consectetuer", - " adipiscing elit. Maecenas porttitor congue massa.", + " adipiscing elit. Maecenas porttitor congue", + " massa.", ] `; @@ -254,7 +261,8 @@ Array [ "occurrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrred", "while pushing commits to git remote. Please make", "sure you have installed and enabled git lfs. The", - "easiest way to do that is run the provided setup script:", + "easiest way to do that is run the provided setup", + "script:", "", " common/scripts/setup.sh", "",