public async runAsync()

in apps/rush-lib/src/cli/actions/ChangeAction.ts [158:682]


  public async runAsync(): Promise<void> {
    console.log(`The target branch is ${this._targetBranch}`);

    if (this._verifyParameter.value) {
      const errors: string[] = [
        this._bulkChangeParameter,
        this._bulkChangeMessageParameter,
        this._bulkChangeBumpTypeParameter,
        this._overwriteFlagParameter
      ]
        .map((parameter) => {
          return parameter.value
            ? `The {${this._bulkChangeParameter.longName} parameter cannot be provided with the ` +
                `${this._verifyParameter.longName} parameter`
            : '';
        })
        .filter((error) => error !== '');
      if (errors.length > 0) {
        errors.forEach((error) => console.error(error));
        throw new AlreadyReportedError();
      }

      await this._verifyAsync();
      return;
    }

    const sortedProjectList: string[] = (await this._getChangedProjectNamesAsync()).sort();
    if (sortedProjectList.length === 0) {
      this._logNoChangeFileRequired();
      this._warnUncommittedChanges();
      return;
    }

    this._warnUncommittedChanges();

    const promptModule: inquirerTypes.PromptModule = inquirer.createPromptModule();
    let changeFileData: Map<string, IChangeFile> = new Map<string, IChangeFile>();
    let interactiveMode: boolean = false;
    if (this._bulkChangeParameter.value) {
      if (
        !this._bulkChangeBumpTypeParameter.value ||
        (!this._bulkChangeMessageParameter.value &&
          this._bulkChangeBumpTypeParameter.value !== ChangeType[ChangeType.none])
      ) {
        throw new Error(
          `The ${this._bulkChangeBumpTypeParameter.longName} and ${this._bulkChangeMessageParameter.longName} ` +
            `parameters must provided if the ${this._bulkChangeParameter.longName} flag is provided. If the value ` +
            `"${ChangeType[ChangeType.none]}" is provided to the ${
              this._bulkChangeBumpTypeParameter.longName
            } ` +
            `parameter, the ${this._bulkChangeMessageParameter.longName} parameter may be omitted.`
        );
      }

      const email: string | undefined = this._changeEmailParameter.value || this._detectEmail();
      if (!email) {
        throw new Error(
          "Unable to detect Git email and an email address wasn't provided using the " +
            `${this._changeEmailParameter.longName} parameter.`
        );
      }

      const errors: string[] = [];

      const comment: string = this._bulkChangeMessageParameter.value || '';
      const changeType: string = this._bulkChangeBumpTypeParameter.value;
      for (const packageName of sortedProjectList) {
        const allowedBumpTypes: string[] = Object.keys(this._getBumpOptions(packageName));
        let projectChangeType: string = changeType;
        if (allowedBumpTypes.length === 0) {
          projectChangeType = ChangeType[ChangeType.none];
        } else if (
          projectChangeType !== ChangeType[ChangeType.none] &&
          allowedBumpTypes.indexOf(projectChangeType) === -1
        ) {
          errors.push(`The "${projectChangeType}" change type is not allowed for package "${packageName}".`);
        }

        changeFileData.set(packageName, {
          changes: [
            {
              comment,
              type: projectChangeType,
              packageName
            } as IChangeInfo
          ],
          packageName,
          email
        });
      }

      if (errors.length > 0) {
        for (const error of errors) {
          console.error(error);
        }

        throw new AlreadyReportedError();
      }
    } else if (this._bulkChangeBumpTypeParameter.value || this._bulkChangeMessageParameter.value) {
      throw new Error(
        `The ${this._bulkChangeParameter.longName} flag must be provided with the ` +
          `${this._bulkChangeBumpTypeParameter.longName} and ${this._bulkChangeMessageParameter.longName} parameters.`
      );
    } else {
      interactiveMode = true;

      const existingChangeComments: Map<string, string[]> = ChangeFiles.getChangeComments(
        this._getChangeFiles()
      );
      changeFileData = await this._promptForChangeFileData(
        promptModule,
        sortedProjectList,
        existingChangeComments
      );

      if (this._isEmailRequired(changeFileData)) {
        const email: string = this._changeEmailParameter.value
          ? this._changeEmailParameter.value
          : await this._detectOrAskForEmail(promptModule);
        changeFileData.forEach((changeFile: IChangeFile) => {
          changeFile.email = this.rushConfiguration.getProjectByName(changeFile.packageName)?.versionPolicy
            ?.includeEmailInChangeFile
            ? email
            : '';
        });
      }
    }

    try {
      return await this._writeChangeFiles(
        promptModule,
        changeFileData,
        this._overwriteFlagParameter.value,
        interactiveMode
      );
    } catch (error) {
      throw new Error(`There was an error creating a change file: ${(error as Error).toString()}`);
    }
  }

  private _generateHostMap(): Map<RushConfigurationProject, string> {
    const hostMap: Map<RushConfigurationProject, string> = new Map();
    for (const project of this.rushConfiguration.projects) {
      let hostProjectName: string = project.packageName;
      if (project.versionPolicy?.isLockstepped) {
        const lockstepPolicy: LockStepVersionPolicy = project.versionPolicy as LockStepVersionPolicy;
        hostProjectName = lockstepPolicy.mainProject || project.packageName;
      }

      hostMap.set(project, hostProjectName);
    }

    return hostMap;
  }

  private async _verifyAsync(): Promise<void> {
    const changedPackages: string[] = await this._getChangedProjectNamesAsync();
    if (changedPackages.length > 0) {
      this._validateChangeFile(changedPackages);
    } else {
      this._logNoChangeFileRequired();
    }
  }

  private get _targetBranch(): string {
    if (!this._targetBranchName) {
      this._targetBranchName = this._targetBranchParameter.value || this._git.getRemoteDefaultBranch();
    }

    return this._targetBranchName;
  }

  private async _getChangedProjectNamesAsync(): Promise<string[]> {
    const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(this.rushConfiguration);
    const changedProjects: Set<RushConfigurationProject> =
      await projectChangeAnalyzer.getChangedProjectsAsync({
        targetBranchName: this._targetBranch,
        terminal: this._terminal,
        shouldFetch: !this._noFetchParameter.value,
        // Lockfile evaluation will expand the set of projects that request change files
        // Not enabling, since this would be a breaking change
        includeExternalDependencies: false,
        // Since install may not have happened, cannot read rush-project.json
        enableFiltering: false
      });
    const projectHostMap: Map<RushConfigurationProject, string> = this._generateHostMap();

    const changedProjectNames: Set<string> = new Set<string>();
    for (const changedProject of changedProjects) {
      if (changedProject.shouldPublish && !changedProject.versionPolicy?.exemptFromRushChange) {
        const hostName: string | undefined = projectHostMap.get(changedProject);
        if (hostName) {
          changedProjectNames.add(hostName);
        }
      }
    }

    return Array.from(changedProjectNames);
  }

  private _validateChangeFile(changedPackages: string[]): void {
    const files: string[] = this._getChangeFiles();
    ChangeFiles.validate(files, changedPackages, this.rushConfiguration);
  }

  private _getChangeFiles(): string[] {
    return this._git
      .getChangedFiles(this._targetBranch, this._terminal, true, `common/changes/`)
      .map((relativePath) => {
        return path.join(this.rushConfiguration.rushJsonFolder, relativePath);
      });
  }

  /**
   * The main loop which prompts the user for information on changed projects.
   */
  private async _promptForChangeFileData(
    promptModule: inquirerTypes.PromptModule,
    sortedProjectList: string[],
    existingChangeComments: Map<string, string[]>
  ): Promise<Map<string, IChangeFile>> {
    const changedFileData: Map<string, IChangeFile> = new Map<string, IChangeFile>();

    for (const projectName of sortedProjectList) {
      const changeInfo: IChangeInfo | undefined = await this._askQuestions(
        promptModule,
        projectName,
        existingChangeComments
      );
      if (changeInfo) {
        // Save the info into the change file
        let changeFile: IChangeFile | undefined = changedFileData.get(changeInfo.packageName);
        if (!changeFile) {
          changeFile = {
            changes: [],
            packageName: changeInfo.packageName,
            email: undefined
          };
          changedFileData.set(changeInfo.packageName, changeFile!);
        }

        changeFile!.changes.push(changeInfo);
      }
    }

    return changedFileData;
  }

  /**
   * Asks all questions which are needed to generate changelist for a project.
   */
  private async _askQuestions(
    promptModule: inquirerTypes.PromptModule,
    packageName: string,
    existingChangeComments: Map<string, string[]>
  ): Promise<IChangeInfo | undefined> {
    console.log(`${os.EOL}${packageName}`);
    const comments: string[] | undefined = existingChangeComments.get(packageName);
    if (comments) {
      console.log(`Found existing comments:`);
      comments.forEach((comment) => {
        console.log(`    > ${comment}`);
      });
      const { appendComment }: { appendComment: 'skip' | 'append' } = await promptModule({
        name: 'appendComment',
        type: 'list',
        default: 'skip',
        message: 'Append to existing comments or skip?',
        choices: [
          {
            name: 'Skip',
            value: 'skip'
          },
          {
            name: 'Append',
            value: 'append'
          }
        ]
      });

      if (appendComment === 'skip') {
        return undefined;
      } else {
        return await this._promptForComments(promptModule, packageName);
      }
    } else {
      return await this._promptForComments(promptModule, packageName);
    }
  }

  private async _promptForComments(
    promptModule: inquirerTypes.PromptModule,
    packageName: string
  ): Promise<IChangeInfo | undefined> {
    const bumpOptions: { [type: string]: string } = this._getBumpOptions(packageName);
    const { comment }: { comment: string } = await promptModule({
      name: 'comment',
      type: 'input',
      message: `Describe changes, or ENTER if no changes:`
    });

    if (Object.keys(bumpOptions).length === 0 || !comment) {
      return {
        packageName: packageName,
        comment: comment || '',
        type: ChangeType[ChangeType.none]
      } as IChangeInfo;
    } else {
      const { bumpType }: { bumpType: string } = await promptModule({
        choices: Object.keys(bumpOptions).map((option) => {
          return {
            value: option,
            name: bumpOptions[option]
          };
        }),
        default: 'patch',
        message: 'Select the type of change:',
        name: 'bumpType',
        type: 'list'
      });

      return {
        packageName: packageName,
        comment: comment,
        type: bumpType
      } as IChangeInfo;
    }
  }

  private _getBumpOptions(packageName?: string): { [type: string]: string } {
    let bumpOptions: { [type: string]: string } =
      this.rushConfiguration && this.rushConfiguration.hotfixChangeEnabled
        ? {
            [ChangeType[ChangeType.hotfix]]:
              'hotfix - for changes that need to be published in a separate hotfix package'
          }
        : {
            [ChangeType[ChangeType.major]]:
              'major - for changes that break compatibility, e.g. removing an API',
            [ChangeType[ChangeType.minor]]: 'minor - for backwards compatible changes, e.g. adding a new API',
            [ChangeType[ChangeType.patch]]:
              'patch - for changes that do not affect compatibility, e.g. fixing a bug'
          };

    if (packageName) {
      const project: RushConfigurationProject | undefined =
        this.rushConfiguration.getProjectByName(packageName);
      const versionPolicy: VersionPolicy | undefined = project!.versionPolicy;

      if (versionPolicy) {
        if (versionPolicy.definitionName === VersionPolicyDefinitionName.lockStepVersion) {
          // No need to ask for bump types if project is lockstep versioned.
          bumpOptions = {};
        } else if (versionPolicy.definitionName === VersionPolicyDefinitionName.individualVersion) {
          const individualPolicy: IndividualVersionPolicy = versionPolicy as IndividualVersionPolicy;
          if (individualPolicy.lockedMajor !== undefined) {
            delete bumpOptions[ChangeType[ChangeType.major]];
          }
        }
      }
    }

    return bumpOptions;
  }

  private _isEmailRequired(changeFileData: Map<string, IChangeFile>): boolean {
    return [...changeFileData.values()].some(
      (changeFile) =>
        !!this.rushConfiguration.getProjectByName(changeFile.packageName)?.versionPolicy
          ?.includeEmailInChangeFile
    );
  }

  /**
   * Will determine a user's email by first detecting it from their Git config,
   * or will ask for it if it is not found or the Git config is wrong.
   */
  private async _detectOrAskForEmail(promptModule: inquirerTypes.PromptModule): Promise<string> {
    return (await this._detectAndConfirmEmail(promptModule)) || (await this._promptForEmail(promptModule));
  }

  private _detectEmail(): string | undefined {
    try {
      return child_process
        .execSync('git config user.email')
        .toString()
        .replace(/(\r\n|\n|\r)/gm, '');
    } catch (err) {
      console.log('There was an issue detecting your Git email...');
      return undefined;
    }
  }

  /**
   * Detects the user's email address from their Git configuration, prompts the user to approve the
   * detected email. It returns undefined if it cannot be detected.
   */
  private async _detectAndConfirmEmail(
    promptModule: inquirerTypes.PromptModule
  ): Promise<string | undefined> {
    const email: string | undefined = this._detectEmail();

    if (email) {
      const { isCorrectEmail }: { isCorrectEmail: boolean } = await promptModule([
        {
          type: 'confirm',
          name: 'isCorrectEmail',
          default: 'Y',
          message: `Is your email address ${email}?`
        }
      ]);
      return isCorrectEmail ? email : undefined;
    } else {
      return undefined;
    }
  }

  /**
   * Asks the user for their email address
   */
  private async _promptForEmail(promptModule: inquirerTypes.PromptModule): Promise<string> {
    const { email }: { email: string } = await promptModule([
      {
        type: 'input',
        name: 'email',
        message: 'What is your email address?',
        validate: (input: string) => {
          return true; // @todo should be an email
        }
      }
    ]);
    return email;
  }

  private _warnUncommittedChanges(): void {
    try {
      if (this._git.hasUncommittedChanges()) {
        console.log(
          os.EOL +
            colors.yellow(
              'Warning: You have uncommitted changes, which do not trigger prompting for change ' +
                'descriptions.'
            )
        );
      }
    } catch (error) {
      console.log(`An error occurred when detecting uncommitted changes: ${error}`);
    }
  }

  /**
   * Writes change files to the common/changes folder. Will prompt for overwrite if file already exists.
   */
  private async _writeChangeFiles(
    promptModule: inquirerTypes.PromptModule,
    changeFileData: Map<string, IChangeFile>,
    overwrite: boolean,
    interactiveMode: boolean
  ): Promise<void> {
    await changeFileData.forEach(async (changeFile: IChangeFile) => {
      await this._writeChangeFile(promptModule, changeFile, overwrite, interactiveMode);
    });
  }

  private async _writeChangeFile(
    promptModule: inquirerTypes.PromptModule,
    changeFileData: IChangeFile,
    overwrite: boolean,
    interactiveMode: boolean
  ): Promise<void> {
    const output: string = JSON.stringify(changeFileData, undefined, 2);
    const changeFile: ChangeFile = new ChangeFile(changeFileData, this.rushConfiguration);
    const filePath: string = changeFile.generatePath();

    const fileExists: boolean = FileSystem.exists(filePath);
    const shouldWrite: boolean =
      !fileExists ||
      overwrite ||
      (interactiveMode ? await this._promptForOverwrite(promptModule, filePath) : false);

    if (!interactiveMode && fileExists && !overwrite) {
      throw new Error(`Changefile ${filePath} already exists`);
    }

    if (shouldWrite) {
      this._writeFile(filePath, output, shouldWrite && fileExists);
    }
  }

  private async _promptForOverwrite(
    promptModule: inquirerTypes.PromptModule,
    filePath: string
  ): Promise<boolean> {
    const overwrite: boolean = await promptModule([
      {
        name: 'overwrite',
        type: 'confirm',
        message: `Overwrite ${filePath}?`
      }
    ]);

    if (overwrite) {
      return true;
    } else {
      console.log(`Not overwriting ${filePath}`);
      return false;
    }
  }

  /**
   * Writes a file to disk, ensuring the directory structure up to that point exists
   */
  private _writeFile(fileName: string, output: string, isOverwrite: boolean): void {
    FileSystem.writeFile(fileName, output, { ensureFolderExists: true });
    if (isOverwrite) {
      console.log(`Overwrote file: ${fileName}`);
    } else {
      console.log(`Created file: ${fileName}`);
    }
  }

  private _logNoChangeFileRequired(): void {
    console.log('No changes were detected to relevant packages on this branch. Nothing to do.');
  }
}