diff --git a/github-actions/slash-commands/main.js b/github-actions/slash-commands/main.js index d8a59e26e..95d3dbd3e 100644 --- a/github-actions/slash-commands/main.js +++ b/github-actions/slash-commands/main.js @@ -54645,7 +54645,7 @@ var require_authenticated_git_client = __commonJS({ this.githubToken = githubToken; this._githubTokenRegex = new RegExp(this.githubToken, "g"); this._cachedOauthScopes = null; - this._cachedForkRepo = null; + this._cachedForkRepositories = null; this.github = new github_12.AuthenticatedGithubClient(this.githubToken); } sanitizeConsoleOutput(value) { @@ -54671,17 +54671,22 @@ Alternatively, a new token can be created at: ${github_urls_1.GITHUB_TOKEN_GENER return { error }; } async getForkOfAuthenticatedUser() { - if (this._cachedForkRepo !== null) { - return this._cachedForkRepo; + const forks = await this.getForksForAuthenticatedUser(); + if (forks.length === 0) { + throw Error("Unable to find fork a for currently authenticated user."); + } + return forks[0]; + } + async getForksForAuthenticatedUser() { + if (this._cachedForkRepositories !== null) { + return this._cachedForkRepositories; } const { owner, name } = this.remoteConfig; const result = await this.github.graphql(graphql_queries_1.findOwnedForksOfRepoQuery, { owner, name }); - const forks = result.repository.forks.nodes; - if (forks.length === 0) { - throw Error(`Unable to find fork for currently authenticated user. Please ensure you created a fork of: ${owner}/${name}.`); - } - const fork = forks[0]; - return this._cachedForkRepo = { owner: fork.owner.login, name: fork.name }; + return this._cachedForkRepositories = result.repository.forks.nodes.map((node) => ({ + owner: node.owner.login, + name: node.name + })); } _fetchAuthScopesForToken() { if (this._cachedOauthScopes !== null) { diff --git a/ng-dev/misc/cli.ts b/ng-dev/misc/cli.ts index f1e0d00e1..bb3658ef5 100644 --- a/ng-dev/misc/cli.ts +++ b/ng-dev/misc/cli.ts @@ -8,6 +8,7 @@ import * as yargs from 'yargs'; import {BuildAndLinkCommandModule} from './build-and-link/cli'; +import {NewMainBranchCommandModule} from './new-main-branch/cli'; import {UpdateYarnCommandModule} from './update-yarn/cli'; /** Build the parser for the misc commands. */ @@ -16,5 +17,6 @@ export function buildMiscParser(localYargs: yargs.Argv) { .help() .strict() .command(BuildAndLinkCommandModule) + .command(NewMainBranchCommandModule) .command(UpdateYarnCommandModule); } diff --git a/ng-dev/misc/new-main-branch/cli.ts b/ng-dev/misc/new-main-branch/cli.ts new file mode 100644 index 000000000..a4db65946 --- /dev/null +++ b/ng-dev/misc/new-main-branch/cli.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertValidGithubConfig, getConfig} from '../../utils/config'; +import {error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console'; +import {findAvailableLocalBranchName, hasLocalBranch} from './find-local-branch'; +import {getRemotesForRepo, isAngularOwnedRemote} from './remotes'; + +import {CommandModule} from 'yargs'; +import {GitClient} from '../../utils/git/git-client'; +import {promptForRemoteForkUpdate} from './remote-fork-update'; + +/** + * Migration command that performs local changes to account for an upstream + * branch rename from `master` to `main`. More details can be found here: + * + * https://docs.google.com/document/d/1nqb94eSIcGuPC0M9Rv7-IeqOiJKzsMnuistHZNeUmAg. + */ +async function handler() { + const git = GitClient.get(); + + // The command cannot operate on the local repository if there are uncommitted changes. + if (git.hasUncommittedChanges()) { + error(red('There are uncommitted changes. Unable to switch to new main branch. Aborting..')); + return; + } + + const config = getConfig([assertValidGithubConfig]); + const repoSlug = `${config.github.owner}/${config.github.name}`; + + if (config.github.mainBranchName !== 'main') { + error(red('Current project is not part of the default branch renaming.')); + return; + } + + if (!hasLocalBranch(git, 'master')) { + error(red('Local repository does not have a local branch named `master`. Aborting..')); + return; + } + + if (hasLocalBranch(git, 'main')) { + error(red('Local repository already has a branch named `main`. Aborting..')); + return; + } + + const remotes = getRemotesForRepo(git); + const angularRemotes = Array.from(remotes.entries()).filter((r) => isAngularOwnedRemote(r[1])); + const angularRemoteNames = angularRemotes.map(([name]) => name); + + // Usually we expect only a single remote pointing to the Angular-owned Github + // repository. If there are more, we will just assume the first one to be the primary. + const primaryRemoteName = angularRemoteNames[0]; + + if (angularRemoteNames.length === 0) { + warn(yellow(`Found no remote in repository that points to the \`${repoSlug}\` repository.`)); + } + + info('The following steps will be performed:'); + if (angularRemoteNames.length) { + info(` → Remotes (${angularRemoteNames.join(`, `)}) are refreshed.`); + } + info(` → The \`main\` branch is fetched from \`${repoSlug}\`.`); + info(' → The new `main` branch is checked out.'); + if (primaryRemoteName) { + info(` → The new \`main\` branch is linked to the \`${primaryRemoteName}\` remote.`); + } + info(' → The old `master` branch is deleted or renamed (you will be prompted).'); + info(' → Remote references to `master` branches in the index are removed.'); + info(''); + + if (!(await promptConfirm('Do you want to continue?'))) { + return; + } + + // Refreshing Angular-owned remotes. + git.run(['fetch', ...angularRemoteNames]); + + // Refresh the remote head to account for the new default branch (i.e. `main`). + for (const remoteName of angularRemoteNames) { + git.run(['remote', 'set-head', remoteName, '-a']); + } + + // Fetch the `main` remote branch and store it locally as `refs/heads/main`. + git.run(['fetch', git.getRepoGitUrl(), 'main:main']); + + // Checkout the new main branch so that we can potentially delete it later. + git.run(['checkout', 'main']); + + // Ensure the local `main` branch has its remote set to an Angular-remote. + if (primaryRemoteName !== undefined) { + git.run(['branch', '--set-upstream-to', primaryRemoteName, 'main']); + } + + // Delete the old `master` branch if desirable, or preserve it if desired. + if ( + await promptConfirm('Are there changes in your local `master` branch that you want to keep?') + ) { + const tmpBranch = findAvailableLocalBranchName(git, 'old-master'); + git.run(['branch', '-m', 'master', tmpBranch]); + + info(''); + info(yellow(`Renamed local \`master\` branch to \`${tmpBranch}\`, preserving your changes.`)); + } else { + git.run(['branch', '-D', 'master']); + } + + // Remove all remote-tracking branches for `master` that could + // cause `git checkout master` to work due to remote tracking. + for (const [remoteName] of remotes) { + git.runGraceful(['update-ref', '-d', `refs/remotes/${remoteName}/master`]); + } + + info(''); + info(green('---------------------------------------------------------')); + info(green('Successfully updated the local repository to use `main`.')); + info(green('---------------------------------------------------------')); + info(''); + info(''); + + await promptForRemoteForkUpdate(); +} + +export const NewMainBranchCommandModule: CommandModule = { + handler, + command: 'new-main-branch', + describe: 'Updates the local repository to account for the new GitHub main branch.', +}; diff --git a/ng-dev/misc/new-main-branch/find-local-branch.ts b/ng-dev/misc/new-main-branch/find-local-branch.ts new file mode 100644 index 000000000..97cfd7d1c --- /dev/null +++ b/ng-dev/misc/new-main-branch/find-local-branch.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {GitClient} from '../../utils/git/git-client'; + +/** Finds a non-reserved branch name in the repository with respect to a base name. */ +export function findAvailableLocalBranchName(git: GitClient, baseName: string): string { + let currentName = baseName; + let suffixNum = 0; + + while (hasLocalBranch(git, currentName)) { + suffixNum++; + currentName = `${baseName}_${suffixNum}`; + } + + return currentName; +} + +/** Gets whether the given branch exists locally. */ +export function hasLocalBranch(git: GitClient, branchName: string): boolean { + return git.runGraceful(['rev-parse', `refs/heads/${branchName}`], {stdio: 'ignore'}).status === 0; +} diff --git a/ng-dev/misc/new-main-branch/remote-fork-update.ts b/ng-dev/misc/new-main-branch/remote-fork-update.ts new file mode 100644 index 000000000..a867f7030 --- /dev/null +++ b/ng-dev/misc/new-main-branch/remote-fork-update.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {debug, green, promptConfirm, promptInput, warn, yellow} from '../../utils/console'; + +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; +import {GithubRepo} from '../../utils/git/github'; +import {info} from 'console'; + +/** + * Prompts the user whether remote forks for the current repository should be + * updated from `master` to `main`. + * + * An access token for performing the Github Admin operation is demanded through + * a prompt. This is opt-in as not every contributor may want to grant the tool a + * Github access token/or some contributors may want to make the changes themselves. + */ +export async function promptForRemoteForkUpdate() { + if ( + !(await promptConfirm( + 'Do you want to update your fork(s) on Github to also use `main`? (recommended)', + )) + ) { + return; + } + + info(''); + info(yellow('In order to be able to update your fork automatically, the script needs')); + info(yellow('authenticated access to your GitHub account. For this, you need to enter a')); + info(yellow('GitHub access token that is temporarily stored in memory until the script exits.')); + info(''); + + if (!(await promptConfirm('Do you want to proceed updating your forks automatically?'))) { + return; + } + + info(''); + info(yellow('You can create an access token by visiting the following GitHub URL:')); + info( + yellow( + 'https://github.com/settings/tokens/new?scopes=public_repo&description=ForkBranchRename', + ), + ); + + const accessToken = await promptInput( + 'Please enter a Github access token (`public_repo` scope is required)', + ); + + // Configure an authenticated Git client. + AuthenticatedGitClient.configure(accessToken); + + const git = AuthenticatedGitClient.get(); + const forks = (await git.getAllForksOfAuthenticatedUser()).map((fork) => ({ + ...fork, + description: getDescriptionForRepo(fork), + })); + const failedForks: string[] = []; + + if (forks.length === 0) { + warn(yellow('Could not find any forks associated with the provided access token.')); + warn(yellow('You will need to manually rename the `master` branch to `main` for your fork.')); + return; + } + + for (const fork of forks) { + const forkApiParams = {owner: fork.owner, repo: fork.name}; + + debug(`Updating fork: ${fork.description}`); + + try { + await git.github.repos.renameBranch({...forkApiParams, branch: 'master', new_name: 'main'}); + await git.github.repos.update({...forkApiParams, default_branch: 'main'}); + debug(`Successfully updated the fork: ${fork.description}`); + } catch (e) { + debug(`An error occurred while renaming the default branch for fork: ${fork.description}`); + failedForks.push(fork.description); + } + } + + if (failedForks.length > 0) { + warn(yellow('Could not update the following forks automatically:', failedForks.join(', '))); + warn(yellow('You will need to manually rename the `master` branch to `main` in the UI.')); + return; + } + + info(''); + info(green('---------------------------------------------------------')); + info(green('Successfully updated your fork(s) from `master` to `main`.')); + forks.forEach((fork) => info(green(`→ ${fork.description}`))); + info(green('---------------------------------------------------------')); +} + +/** Gets a human-readable description for a given Github repo instance. */ +function getDescriptionForRepo(repo: GithubRepo) { + return `${repo.owner}/${repo.name}`; +} diff --git a/ng-dev/misc/new-main-branch/remotes.ts b/ng-dev/misc/new-main-branch/remotes.ts new file mode 100644 index 000000000..7a4fd77bf --- /dev/null +++ b/ng-dev/misc/new-main-branch/remotes.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {GitClient} from '../../utils/git/git-client'; +import {debug} from '../../utils/console'; + +/** + * Regular expression matching a remote verbose info line, capturing + * the remote name, remote url and type (for different mirrors). + * + * ``` + * origin https://github.com/devversion/dev-infra.git (fetch) + * origin https://github.com/devversion/dev-infra.git (push) + * upstream https://github.com/angular/dev-infra.git (fetch) + * upstream https://github.com/angular/dev-infra.git (push) + * ``` + */ +const remoteVerboseInfoRegex = /^([^\s]+)\s+([^\s]+)\s+\((fetch|push)\)$/; + +/** + * Regular expression that matches Git remote SSH/HTTP urls which are referring + * to a repository that is owned by the Angular Github organization. + */ +const angularOrganizationRemoteUrl = /github.com[:/]angular\//; + +/** Type describing extracted remotes, mapping remote name to its URL. */ +export type Remotes = Map; + +/** + * Gets all remotes for the repository associated with the given Git client. + * + * Assumes that both `fetch` and `push` mirrors of a remote have the same URL. + */ +export function getRemotesForRepo(git: GitClient): Remotes { + const remotesVerboseInfo = git.run(['remote', '--verbose']); + const remotes: Remotes = new Map(); + + for (const line of remotesVerboseInfo.stdout.trim().split(/\r?\n/)) { + const matches = line.match(remoteVerboseInfoRegex); + + if (matches === null) { + debug('Could not parse remote info line:', line); + continue; + } + + remotes.set(matches[1], matches[2]); + } + + return remotes; +} + +/** Gets whether the given remote URL refers to an Angular-owned repository. */ +export function isAngularOwnedRemote(url: string) { + return angularOrganizationRemoteUrl.test(url); +} diff --git a/ng-dev/utils/git/authenticated-git-client.ts b/ng-dev/utils/git/authenticated-git-client.ts index 09e9c1937..a975e2ff6 100644 --- a/ng-dev/utils/git/authenticated-git-client.ts +++ b/ng-dev/utils/git/authenticated-git-client.ts @@ -35,8 +35,8 @@ export class AuthenticatedGitClient extends GitClient { /** The OAuth scopes available for the provided Github token. */ private _cachedOauthScopes: Promise | null = null; - /** Cached found fork of the configured project. */ - private _cachedForkRepo: GithubRepo | null = null; + /** Cached fork repositories of the authenticated user. */ + private _cachedForkRepositories: GithubRepo[] | null = null; /** Instance of an authenticated github client. */ override readonly github = new AuthenticatedGithubClient(this.githubToken); @@ -85,28 +85,35 @@ export class AuthenticatedGitClient extends GitClient { return {error}; } + /** Gets an owned fork for the configured project of the authenticated user. */ + async getForkOfAuthenticatedUser(): Promise { + const forks = await this.getAllForksOfAuthenticatedUser(); + + if (forks.length === 0) { + throw Error('Unable to find fork a for currently authenticated user.'); + } + + return forks[0]; + } + /** - * Gets an owned fork for the configured project of the authenticated user, caching the determined - * fork repository as the authenticated user cannot change during action execution. + * Finds all forks owned by the currently authenticated user in the Git client, + * + * The determined fork repositories are cached as we assume that the authenticated + * user will not change during execution, or that no new forks are created. */ - async getForkOfAuthenticatedUser(): Promise { - if (this._cachedForkRepo !== null) { - return this._cachedForkRepo; + async getAllForksOfAuthenticatedUser(): Promise { + if (this._cachedForkRepositories !== null) { + return this._cachedForkRepositories; } const {owner, name} = this.remoteConfig; const result = await this.github.graphql(findOwnedForksOfRepoQuery, {owner, name}); - const forks = result.repository.forks.nodes; - - if (forks.length === 0) { - throw Error( - 'Unable to find fork for currently authenticated user. Please ensure you created a fork ' + - ` of: ${owner}/${name}.`, - ); - } - const fork = forks[0]; - return (this._cachedForkRepo = {owner: fork.owner.login, name: fork.name}); + return (this._cachedForkRepositories = result.repository.forks.nodes.map((node) => ({ + owner: node.owner.login, + name: node.name, + }))); } /** Fetch the OAuth scopes for the loaded Github token. */ diff --git a/ng-dev/utils/git/github-yargs.ts b/ng-dev/utils/git/github-yargs.ts index d089fb9af..6d4c7b952 100644 --- a/ng-dev/utils/git/github-yargs.ts +++ b/ng-dev/utils/git/github-yargs.ts @@ -6,10 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {Argv} from 'yargs'; - import {error, red, yellow} from '../console'; +import {Argv} from 'yargs'; import {AuthenticatedGitClient} from './authenticated-git-client'; import {GITHUB_TOKEN_GENERATE_URL} from './github-urls'; @@ -26,7 +25,7 @@ export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken { type: 'string', description: 'Github token. If not set, token is retrieved from the environment variables.', coerce: (token: string) => { - const githubToken = token || process.env.GITHUB_TOKEN || process.env.TOKEN; + const githubToken = token ?? findGithubTokenInEnvironment(); if (!githubToken) { error(red('No Github token set. Please set the `GITHUB_TOKEN` environment variable.')); error(red('Alternatively, pass the `--github-token` command line flag.')); @@ -44,3 +43,11 @@ export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken { .default('github-token' as 'githubToken', '', '') ); } + +/** + * Finds a non-explicitly provided Github token in the local environment. + * The function looks for `GITHUB_TOKEN` or `TOKEN` in the environment variables. + */ +export function findGithubTokenInEnvironment(): string | undefined { + return process.env.GITHUB_TOKEN ?? process.env.TOKEN; +} diff --git a/tools/local-actions/changelog/main.js b/tools/local-actions/changelog/main.js index 9de4a9c84..823913667 100644 --- a/tools/local-actions/changelog/main.js +++ b/tools/local-actions/changelog/main.js @@ -54672,7 +54672,7 @@ var require_authenticated_git_client = __commonJS({ this.githubToken = githubToken; this._githubTokenRegex = new RegExp(this.githubToken, "g"); this._cachedOauthScopes = null; - this._cachedForkRepo = null; + this._cachedForkRepositories = null; this.github = new github_12.AuthenticatedGithubClient(this.githubToken); } sanitizeConsoleOutput(value) { @@ -54698,17 +54698,22 @@ Alternatively, a new token can be created at: ${github_urls_1.GITHUB_TOKEN_GENER return { error }; } async getForkOfAuthenticatedUser() { - if (this._cachedForkRepo !== null) { - return this._cachedForkRepo; + const forks = await this.getForksForAuthenticatedUser(); + if (forks.length === 0) { + throw Error("Unable to find fork a for currently authenticated user."); + } + return forks[0]; + } + async getForksForAuthenticatedUser() { + if (this._cachedForkRepositories !== null) { + return this._cachedForkRepositories; } const { owner, name } = this.remoteConfig; const result = await this.github.graphql(graphql_queries_1.findOwnedForksOfRepoQuery, { owner, name }); - const forks = result.repository.forks.nodes; - if (forks.length === 0) { - throw Error(`Unable to find fork for currently authenticated user. Please ensure you created a fork of: ${owner}/${name}.`); - } - const fork = forks[0]; - return this._cachedForkRepo = { owner: fork.owner.login, name: fork.name }; + return this._cachedForkRepositories = result.repository.forks.nodes.map((node) => ({ + owner: node.owner.login, + name: node.name + })); } _fetchAuthScopesForToken() { if (this._cachedOauthScopes !== null) {