Skip to content

Commit f0766a4

Browse files
devversionAndrewKushnir
authored andcommitted
feat(dev-infra): provide organization-wide merge-tool label configuration (angular#38223)
Previously, each Angular repository had its own strategy/configuration for merging pull requests and cherry-picking. We worked out a new strategy for labeling/branching/versioning that should be the canonical strategy for all actively maintained projects in the Angular organization. This PR provides a `ng-dev` merge configuration that implements the labeling/branching/merging as per the approved proposal. See the following document for the proposal this commit is based on for the merge script labeling/branching: https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU The merge tool label configuration can be conveniently accesed within each `.ng-dev` configuration, and can also be extended if there are special labels on individual projects. This is one of the reasons why the labels are not directly built into the merge script. The script should remain unopinionated and flexible. The configuration is conceptually powerful enough to achieve the procedures as outlined in the versioning/branching/labeling proposal. PR Close angular#38223
1 parent 6f0f0d3 commit f0766a4

File tree

11 files changed

+955
-72
lines changed

11 files changed

+955
-72
lines changed

dev-infra/pr/merge/BUILD.bazel

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
load("@npm_bazel_typescript//:index.bzl", "ts_library")
2+
load("//tools:defaults.bzl", "jasmine_node_test")
23

34
ts_library(
45
name = "merge",
5-
srcs = glob(["**/*.ts"]),
6+
srcs = glob(
7+
["**/*.ts"],
8+
exclude = ["**/*.spec.ts"],
9+
),
610
module_name = "@angular/dev-infra-private/pr/merge",
711
visibility = ["//dev-infra:__subpackages__"],
812
deps = [
@@ -11,8 +15,37 @@ ts_library(
1115
"@npm//@octokit/rest",
1216
"@npm//@types/inquirer",
1317
"@npm//@types/node",
18+
"@npm//@types/node-fetch",
1419
"@npm//@types/semver",
1520
"@npm//@types/yargs",
1621
"@npm//chalk",
1722
],
1823
)
24+
25+
ts_library(
26+
name = "test_lib",
27+
testonly = True,
28+
srcs = glob(["**/*.spec.ts"]),
29+
deps = [
30+
":merge",
31+
"//dev-infra/utils",
32+
"@npm//@types/jasmine",
33+
"@npm//@types/node",
34+
"@npm//@types/node-fetch",
35+
"@npm//nock",
36+
],
37+
)
38+
39+
jasmine_node_test(
40+
name = "test",
41+
# Disable the Bazel patched module resolution. It always loads ".mjs" files first. This
42+
# breaks NodeJS execution for "node-fetch" as it uses experimental modules which are not
43+
# enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional.
44+
# https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa.
45+
args = ["--nobazel_patch_module_resolver"],
46+
deps = [
47+
":test_lib",
48+
"@npm//node-fetch",
49+
"@npm//semver",
50+
],
51+
)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as semver from 'semver';
10+
import {GithubClient} from '../../../utils/git/github';
11+
12+
/** Type describing a Github repository with corresponding API client. */
13+
export interface GithubRepo {
14+
/** API client that can access the repository. */
15+
api: GithubClient;
16+
/** Owner login of the repository. */
17+
owner: string;
18+
/** Name of the repository. */
19+
repo: string;
20+
/**
21+
* NPM package representing this repository. Angular repositories usually contain
22+
* multiple packages in a monorepo scheme, but packages commonly are released with
23+
* the same versions. This means that a single package can be used for querying
24+
* NPM about previously published versions (e.g. to determine active LTS versions).
25+
* */
26+
npmPackageName: string;
27+
}
28+
29+
/** Type describing a version-branch. */
30+
export interface VersionBranch {
31+
/** Name of the branch in Git. e.g. `10.0.x`. */
32+
name: string;
33+
/**
34+
* Parsed SemVer version for the version-branch. Version branches technically do
35+
* not follow the SemVer format, but we can have representative SemVer versions
36+
* that can be used for comparisons, sorting and other checks.
37+
*/
38+
parsed: semver.SemVer;
39+
}
40+
41+
/** Branch name for the `next` branch. */
42+
export const nextBranchName = 'master';
43+
44+
/** Regular expression that matches version-branches for a release-train. */
45+
const releaseTrainBranchNameRegex = /(\d+)\.(\d+)\.x/;
46+
47+
/**
48+
* Fetches the active release train and its branches for the specified major version. i.e.
49+
* the latest active release-train branch name is resolved and an optional version-branch for
50+
* a currently active feature-freeze/release-candidate release-train.
51+
*/
52+
export async function fetchActiveReleaseTrainBranches(
53+
repo: GithubRepo, nextVersion: semver.SemVer): Promise<{
54+
/**
55+
* Name of the currently active release-candidate branch. Null if no
56+
* feature-freeze/release-candidate is currently active.
57+
*/
58+
releaseCandidateBranch: string | null,
59+
/** Name of the latest non-prerelease version branch (i.e. the patch branch). */
60+
latestVersionBranch: string
61+
}> {
62+
const majorVersionsToConsider: number[] = [];
63+
let expectedReleaseCandidateMajor: number;
64+
65+
// If the `next` branch (i.e. `master` branch) is for an upcoming major version, we know
66+
// that there is no patch branch or feature-freeze/release-candidate branch for this major
67+
// digit. If the current `next` version is the first minor of a major version, we know that
68+
// the feature-freeze/release-candidate branch can only be the actual major branch. The
69+
// patch branch is based on that, either the actual major branch or the last minor from the
70+
// preceding major version. In all other cases, the patch branch and feature-freeze or
71+
// release-candidate branch are part of the same major version. Consider the following:
72+
//
73+
// CASE 1. next: 11.0.0-next.0: patch and feature-freeze/release-candidate can only be
74+
// most recent `10.<>.x` branches. The FF/RC branch can only be the last-minor of v10.
75+
// CASE 2. next: 11.1.0-next.0: patch can be either `11.0.x` or last-minor in v10 based
76+
// on whether there is a feature-freeze/release-candidate branch (=> `11.0.x`).
77+
// CASE 3. next: 10.6.0-next.0: patch can be either `10.5.x` or `10.4.x` based on whether
78+
// there is a feature-freeze/release-candidate branch (=> `10.5.x`)
79+
if (nextVersion.minor === 0) {
80+
expectedReleaseCandidateMajor = nextVersion.major - 1;
81+
majorVersionsToConsider.push(nextVersion.major - 1);
82+
} else if (nextVersion.minor === 1) {
83+
expectedReleaseCandidateMajor = nextVersion.major;
84+
majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1);
85+
} else {
86+
expectedReleaseCandidateMajor = nextVersion.major;
87+
majorVersionsToConsider.push(nextVersion.major);
88+
}
89+
90+
// Collect all version-branches that should be considered for the latest version-branch,
91+
// or the feature-freeze/release-candidate.
92+
const branches = (await getBranchesForMajorVersions(repo, majorVersionsToConsider));
93+
const {latestVersionBranch, releaseCandidateBranch} =
94+
await findActiveVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor);
95+
96+
if (latestVersionBranch === null) {
97+
throw Error(
98+
`Unable to determine the latest release-train. The following branches ` +
99+
`have been considered: [${branches.join(', ')}]`);
100+
}
101+
102+
return {releaseCandidateBranch, latestVersionBranch};
103+
}
104+
105+
/** Gets the version of a given branch by reading the `package.json` upstream. */
106+
export async function getVersionOfBranch(
107+
repo: GithubRepo, branchName: string): Promise<semver.SemVer> {
108+
const {data} =
109+
await repo.api.repos.getContents({...repo, path: '/package.json', ref: branchName});
110+
const {version} = JSON.parse(Buffer.from(data.content, 'base64').toString());
111+
const parsedVersion = semver.parse(version);
112+
if (parsedVersion === null) {
113+
throw Error(`Invalid version detected in following branch: ${branchName}.`);
114+
}
115+
return parsedVersion;
116+
}
117+
118+
/** Whether the given branch corresponds to a release-train branch. */
119+
export function isReleaseTrainBranch(branchName: string): boolean {
120+
return releaseTrainBranchNameRegex.test(branchName);
121+
}
122+
123+
/**
124+
* Converts a given version-branch into a SemVer version that can be used with SemVer
125+
* utilities. e.g. to determine semantic order, extract major digit, compare.
126+
*
127+
* For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not
128+
* relevant but needed for parsing. SemVer does not allow `x` as patch digit.
129+
*/
130+
export function getVersionForReleaseTrainBranch(branchName: string): semver.SemVer|null {
131+
// Convert a given version-branch into a SemVer version that can be used
132+
// with the SemVer utilities. i.e. to determine semantic order.
133+
return semver.parse(branchName.replace(releaseTrainBranchNameRegex, '$1.$2.0'));
134+
}
135+
136+
/**
137+
* Gets the version branches for the specified major versions in descending
138+
* order. i.e. latest version branches first.
139+
*/
140+
export async function getBranchesForMajorVersions(
141+
repo: GithubRepo, majorVersions: number[]): Promise<VersionBranch[]> {
142+
const {data: branchData} = await repo.api.repos.listBranches({...repo, protected: true});
143+
const branches: VersionBranch[] = [];
144+
145+
for (const {name} of branchData) {
146+
if (!isReleaseTrainBranch(name)) {
147+
continue;
148+
}
149+
// Convert the version-branch into a SemVer version that can be used with the
150+
// SemVer utilities. e.g. to determine semantic order, compare versions.
151+
const parsed = getVersionForReleaseTrainBranch(name);
152+
// Collect all version-branches that match the specified major versions.
153+
if (parsed !== null && majorVersions.includes(parsed.major)) {
154+
branches.push({name, parsed});
155+
}
156+
}
157+
158+
// Sort captured version-branches in descending order.
159+
return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed));
160+
}
161+
162+
export async function findActiveVersionBranches(
163+
repo: GithubRepo, nextVersion: semver.SemVer, branches: VersionBranch[],
164+
expectedReleaseCandidateMajor: number): Promise<{
165+
latestVersionBranch: string | null,
166+
releaseCandidateBranch: string | null,
167+
}> {
168+
let latestVersionBranch: string|null = null;
169+
let releaseCandidateBranch: string|null = null;
170+
171+
// Iterate through the captured branches and find the latest non-prerelease branch and a
172+
// potential release candidate branch. From the collected branches we iterate descending
173+
// order (most recent semantic version-branch first). The first branch is either the latest
174+
// active version branch (i.e. patch) or a feature-freeze/release-candidate branch. A FF/RC
175+
// branch cannot be older than the latest active version-branch, so we stop iterating once
176+
// we found such a branch. Otherwise, if we found a FF/RC branch, we continue looking for the
177+
// next version-branch as that one is supposed to be the latest active version-branch. If it
178+
// is not, then an error will be thrown due to two FF/RC branches existing at the same time.
179+
for (const {name, parsed} of branches) {
180+
// It can happen that version branches that are more recent than the version in the next
181+
// branch (i.e. `master`) have been created. We could ignore such branches silently, but
182+
// it might actually be symptomatic for an outdated version in the `next` branch, or an
183+
// accidentally created branch by the caretaker. In either way we want to raise awareness.
184+
if (semver.gte(parsed, nextVersion)) {
185+
throw Error(
186+
`Discovered unexpected version-branch that is representing a minor ` +
187+
`version more recent than the one in the "${nextBranchName}" branch. Consider ` +
188+
`deleting the branch, or check if the version in "${nextBranchName}" is outdated.`);
189+
}
190+
191+
const version = await getVersionOfBranch(repo, name);
192+
const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next';
193+
if (isPrerelease) {
194+
if (releaseCandidateBranch !== null) {
195+
throw Error(
196+
`Unable to determine latest release-train. Found two consecutive ` +
197+
`branches in feature-freeze/release-candidate phase. Did not expect both "${name}" ` +
198+
`and "${releaseCandidateBranch}" to be in feature-freeze/release-candidate mode.`);
199+
} else if (version.major !== expectedReleaseCandidateMajor) {
200+
throw Error(
201+
`Discovered unexpected old feature-freeze/release-candidate branch. Expected no ` +
202+
`version-branch in feature-freeze/release-candidate mode for v${version.major}.`);
203+
}
204+
releaseCandidateBranch = name;
205+
} else {
206+
latestVersionBranch = name;
207+
break;
208+
}
209+
}
210+
211+
return {releaseCandidateBranch, latestVersionBranch};
212+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './labels';
10+
export * from './branches';
11+
export * from './lts-branch';

0 commit comments

Comments
 (0)