feat(dev-infra): add support for `targetLabelExemptScopes` for merging (#41459)

Add a property, `targetLabelExemptScopes`, to the merge configuration allowing certain
scopes to be exempted from requirements for features and breaking changes only included
in PRs targetting certain labels.

PR Close #41459
This commit is contained in:
Joey Perrott 2021-04-06 08:18:44 -07:00 committed by atscott
parent 7dba0711c2
commit c7d86bca21
4 changed files with 67 additions and 34 deletions

View File

@ -3355,13 +3355,13 @@ var PullRequestFailure = /** @class */ (function () {
}; };
PullRequestFailure.hasBreakingChanges = function (label) { PullRequestFailure.hasBreakingChanges = function (label) {
var message = "Cannot merge into branch for \"" + label.pattern + "\" as the pull request has " + var message = "Cannot merge into branch for \"" + label.pattern + "\" as the pull request has " +
"breaking changes. Breaking changes can only be merged with the \"target: major\" label."; "breaking changes. Breaking changes can only be merged with the \"target: major\" label.";
return new this(message); return new this(message);
}; };
PullRequestFailure.hasFeatureCommits = function (label) { PullRequestFailure.hasFeatureCommits = function (label) {
var message = "Cannot merge into branch for \"" + label.pattern + "\" as the pull request has " + var message = "Cannot merge into branch for \"" + label.pattern + "\" as the pull request has " +
'commits with the "feat" type. New features can only be merged with the "target: minor" or ' + 'commits with the "feat" type. New features can only be merged with the "target: minor" ' +
'"target: major" label.'; 'or "target: major" label.';
return new this(message); return new this(message);
}; };
return PullRequestFailure; return PullRequestFailure;
@ -3398,7 +3398,7 @@ function loadAndValidatePullRequest(_a, prNumber, ignoreNonFatalFailures) {
var git = _a.git, config = _a.config; var git = _a.git, config = _a.config;
if (ignoreNonFatalFailures === void 0) { ignoreNonFatalFailures = false; } if (ignoreNonFatalFailures === void 0) { ignoreNonFatalFailures = false; }
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
var prData, labels, targetLabel, state, githubTargetBranch, requiredBaseSha, needsCommitMessageFixup, hasCaretakerNote, targetBranches, error_1; var prData, labels, targetLabel, commitMessages, state, githubTargetBranch, requiredBaseSha, needsCommitMessageFixup, hasCaretakerNote, targetBranches, error_1;
return tslib.__generator(this, function (_b) { return tslib.__generator(this, function (_b) {
switch (_b.label) { switch (_b.label) {
case 0: return [4 /*yield*/, fetchPullRequestFromGithub(git, prNumber)]; case 0: return [4 /*yield*/, fetchPullRequestFromGithub(git, prNumber)];
@ -3424,7 +3424,8 @@ function loadAndValidatePullRequest(_a, prNumber, ignoreNonFatalFailures) {
throw error; throw error;
} }
try { try {
assertCorrectTargetForChanges(prData.commits.nodes.map(function (n) { return n.commit.message; }), targetLabel); commitMessages = prData.commits.nodes.map(function (n) { return n.commit.message; });
assertChangesAllowForTargetLabel(commitMessages, targetLabel, config);
} }
catch (error) { catch (error) {
return [2 /*return*/, error]; return [2 /*return*/, error];
@ -3465,17 +3466,20 @@ function loadAndValidatePullRequest(_a, prNumber, ignoreNonFatalFailures) {
hasCaretakerNote: hasCaretakerNote, hasCaretakerNote: hasCaretakerNote,
targetBranches: targetBranches, targetBranches: targetBranches,
title: prData.title, title: prData.title,
commitCount: prData.commits.nodes.length, commitCount: prData.commits.totalCount,
}]; }];
} }
}); });
}); });
} }
/* GraphQL schema for the response body the requested PR. */ /* GraphQL schema for the response body the requested pull request. */
var PR_SCHEMA$2 = { var PR_SCHEMA$2 = {
url: typedGraphqlify.types.string, url: typedGraphqlify.types.string,
number: typedGraphqlify.types.number, number: typedGraphqlify.types.number,
commits: typedGraphqlify.params({ first: 100 }, { // Only the last 100 commits from a pull request are obtained as we likely will never see a pull
// requests with more than 100 commits.
commits: typedGraphqlify.params({ last: 100 }, {
totalCount: typedGraphqlify.types.number,
nodes: [{ nodes: [{
commit: { commit: {
status: { status: {
@ -3496,13 +3500,15 @@ var PR_SCHEMA$2 = {
/** Fetches a pull request from Github. Returns null if an error occurred. */ /** Fetches a pull request from Github. Returns null if an error occurred. */
function fetchPullRequestFromGithub(git, prNumber) { function fetchPullRequestFromGithub(git, prNumber) {
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
var e_1; var x, e_1;
return tslib.__generator(this, function (_a) { return tslib.__generator(this, function (_a) {
switch (_a.label) { switch (_a.label) {
case 0: case 0:
_a.trys.push([0, 2, , 3]); _a.trys.push([0, 2, , 3]);
return [4 /*yield*/, getPr(PR_SCHEMA$2, prNumber, git)]; return [4 /*yield*/, getPr(PR_SCHEMA$2, prNumber, git)];
case 1: return [2 /*return*/, _a.sent()]; case 1:
x = _a.sent();
return [2 /*return*/, x];
case 2: case 2:
e_1 = _a.sent(); e_1 = _a.sent();
// If the pull request could not be found, we want to return `null` so // If the pull request could not be found, we want to return `null` so
@ -3524,32 +3530,39 @@ function isPullRequest(v) {
* Assert the commits provided are allowed to merge to the provided target label, throwing a * Assert the commits provided are allowed to merge to the provided target label, throwing a
* PullRequestFailure otherwise. * PullRequestFailure otherwise.
*/ */
function assertCorrectTargetForChanges(rawCommits, label) { function assertChangesAllowForTargetLabel(rawCommits, label, config) {
/** List of ParsedCommits for all of the commits in the pull request. */ /**
var commits = rawCommits.map(parseCommitMessage); * List of commit scopes which are exempted from target label content requirements. i.e. no `feat`
* scopes in patch branches, no breaking changes in minor or patch changes.
*/
var exemptedScopes = config.targetLabelExemptScopes || [];
/** List of parsed commits which are subject to content requirements for the target label. */
var commits = rawCommits.map(parseCommitMessage).filter(function (commit) {
return !exemptedScopes.includes(commit.scope);
});
switch (label.pattern) { switch (label.pattern) {
case 'target: major': case 'target: major':
break; break;
case 'target: minor': case 'target: minor':
// Check if any commits in the PR contains a breaking change. // Check if any commits in the pull request contains a breaking change.
if (commits.some(function (commit) { return commit.breakingChanges.length !== 0; })) { if (commits.some(function (commit) { return commit.breakingChanges.length !== 0; })) {
throw PullRequestFailure.hasBreakingChanges(label); throw PullRequestFailure.hasBreakingChanges(label);
} }
break; break;
case 'target: patch': case 'target: patch':
case 'target: lts': case 'target: lts':
// Check if any commits in the PR contains a breaking change. // Check if any commits in the pull request contains a breaking change.
if (commits.some(function (commit) { return commit.breakingChanges.length !== 0; })) { if (commits.some(function (commit) { return commit.breakingChanges.length !== 0; })) {
throw PullRequestFailure.hasBreakingChanges(label); throw PullRequestFailure.hasBreakingChanges(label);
} }
// Check if any commits in the PR contains a commit type of "feat". // Check if any commits in the pull request contains a commit type of "feat".
if (commits.some(function (commit) { return commit.type === 'feat'; })) { if (commits.some(function (commit) { return commit.type === 'feat'; })) {
throw PullRequestFailure.hasFeatureCommits(label); throw PullRequestFailure.hasFeatureCommits(label);
} }
break; break;
default: default:
warn(red('WARNING: Unable to confirm all commits in the PR are eligible to be merged')); warn(red('WARNING: Unable to confirm all commits in the pull request are eligible to be'));
warn(red("into the target branch: " + label.pattern)); warn(red("merged into the target branch: " + label.pattern));
break; break;
} }
} }

View File

@ -71,6 +71,11 @@ export interface MergeConfig {
* not support this. * not support this.
*/ */
githubApiMerge: false|GithubApiMergeStrategyConfig; githubApiMerge: false|GithubApiMergeStrategyConfig;
/**
* List of commit scopes which are exempted from target label content requirements. i.e. no `feat`
* scopes in patch branches, no breaking changes in minor or patch changes.
*/
targetLabelExemptScopes?: string[];
} }
/** /**

View File

@ -77,13 +77,13 @@ export class PullRequestFailure {
static hasBreakingChanges(label: TargetLabel) { static hasBreakingChanges(label: TargetLabel) {
const message = `Cannot merge into branch for "${label.pattern}" as the pull request has ` + const message = `Cannot merge into branch for "${label.pattern}" as the pull request has ` +
`breaking changes. Breaking changes can only be merged with the "target: major" label.`; `breaking changes. Breaking changes can only be merged with the "target: major" label.`;
return new this(message); return new this(message);
} }
static hasFeatureCommits(label: TargetLabel) { static hasFeatureCommits(label: TargetLabel) {
const message = `Cannot merge into branch for "${label.pattern}" as the pull request has ` + const message = `Cannot merge into branch for "${label.pattern}" as the pull request has ` +
'commits with the "feat" type. New features can only be merged with the "target: minor" ' + 'commits with the "feat" type. New features can only be merged with the "target: minor" ' +
'or "target: major" label.'; 'or "target: major" label.';
return new this(message); return new this(message);
} }

View File

@ -12,7 +12,7 @@ import {red, warn} from '../../utils/console';
import {GitClient} from '../../utils/git/index'; import {GitClient} from '../../utils/git/index';
import {getPr} from '../../utils/github'; import {getPr} from '../../utils/github';
import {TargetLabel} from './config'; import {MergeConfig, TargetLabel} from './config';
import {PullRequestFailure} from './failures'; import {PullRequestFailure} from './failures';
import {matchesPattern} from './string-pattern'; import {matchesPattern} from './string-pattern';
@ -76,11 +76,14 @@ export async function loadAndValidatePullRequest(
} }
try { try {
assertCorrectTargetForChanges(prData.commits.nodes.map(n => n.commit.message), targetLabel); /** Commit message strings for all commits in the pull request. */
const commitMessages = prData.commits.nodes.map(n => n.commit.message);
assertChangesAllowForTargetLabel(commitMessages, targetLabel, config);
} catch (error) { } catch (error) {
return error; return error;
} }
/** The combined status of the latest commit in the pull request. */
const state = prData.commits.nodes.slice(-1)[0].commit.status.state; const state = prData.commits.nodes.slice(-1)[0].commit.status.state;
if (state === 'FAILURE' && !ignoreNonFatalFailures) { if (state === 'FAILURE' && !ignoreNonFatalFailures) {
return PullRequestFailure.failingCiJobs(); return PullRequestFailure.failingCiJobs();
@ -121,15 +124,18 @@ export async function loadAndValidatePullRequest(
hasCaretakerNote, hasCaretakerNote,
targetBranches, targetBranches,
title: prData.title, title: prData.title,
commitCount: prData.commits.nodes.length, commitCount: prData.commits.totalCount,
}; };
} }
/* GraphQL schema for the response body the requested PR. */ /* GraphQL schema for the response body the requested pull request. */
const PR_SCHEMA = { const PR_SCHEMA = {
url: graphQLTypes.string, url: graphQLTypes.string,
number: graphQLTypes.number, number: graphQLTypes.number,
commits: params({first: 100}, { // Only the last 100 commits from a pull request are obtained as we likely will never see a pull
// requests with more than 100 commits.
commits: params({last: 100}, {
totalCount: graphQLTypes.number,
nodes: [{ nodes: [{
commit: { commit: {
status: { status: {
@ -154,7 +160,8 @@ const PR_SCHEMA = {
async function fetchPullRequestFromGithub( async function fetchPullRequestFromGithub(
git: GitClient, prNumber: number): Promise<typeof PR_SCHEMA|null> { git: GitClient, prNumber: number): Promise<typeof PR_SCHEMA|null> {
try { try {
return await getPr(PR_SCHEMA, prNumber, git); const x = await getPr(PR_SCHEMA, prNumber, git);
return x;
} catch (e) { } catch (e) {
// If the pull request could not be found, we want to return `null` so // If the pull request could not be found, we want to return `null` so
// that the error can be handled gracefully. // that the error can be handled gracefully.
@ -174,32 +181,40 @@ export function isPullRequest(v: PullRequestFailure|PullRequest): v is PullReque
* Assert the commits provided are allowed to merge to the provided target label, throwing a * Assert the commits provided are allowed to merge to the provided target label, throwing a
* PullRequestFailure otherwise. * PullRequestFailure otherwise.
*/ */
function assertCorrectTargetForChanges(rawCommits: string[], label: TargetLabel) { function assertChangesAllowForTargetLabel(
/** List of ParsedCommits for all of the commits in the pull request. */ rawCommits: string[], label: TargetLabel, config: MergeConfig) {
const commits = rawCommits.map(parseCommitMessage); /**
* List of commit scopes which are exempted from target label content requirements. i.e. no `feat`
* scopes in patch branches, no breaking changes in minor or patch changes.
*/
const exemptedScopes = config.targetLabelExemptScopes || [];
/** List of parsed commits which are subject to content requirements for the target label. */
let commits = rawCommits.map(parseCommitMessage).filter(commit => {
return !exemptedScopes.includes(commit.scope);
});
switch (label.pattern) { switch (label.pattern) {
case 'target: major': case 'target: major':
break; break;
case 'target: minor': case 'target: minor':
// Check if any commits in the PR contains a breaking change. // Check if any commits in the pull request contains a breaking change.
if (commits.some(commit => commit.breakingChanges.length !== 0)) { if (commits.some(commit => commit.breakingChanges.length !== 0)) {
throw PullRequestFailure.hasBreakingChanges(label); throw PullRequestFailure.hasBreakingChanges(label);
} }
break; break;
case 'target: patch': case 'target: patch':
case 'target: lts': case 'target: lts':
// Check if any commits in the PR contains a breaking change. // Check if any commits in the pull request contains a breaking change.
if (commits.some(commit => commit.breakingChanges.length !== 0)) { if (commits.some(commit => commit.breakingChanges.length !== 0)) {
throw PullRequestFailure.hasBreakingChanges(label); throw PullRequestFailure.hasBreakingChanges(label);
} }
// Check if any commits in the PR contains a commit type of "feat". // Check if any commits in the pull request contains a commit type of "feat".
if (commits.some(commit => commit.type === 'feat')) { if (commits.some(commit => commit.type === 'feat')) {
throw PullRequestFailure.hasFeatureCommits(label); throw PullRequestFailure.hasFeatureCommits(label);
} }
break; break;
default: default:
warn(red('WARNING: Unable to confirm all commits in the PR are eligible to be merged')); warn(red('WARNING: Unable to confirm all commits in the pull request are eligible to be'));
warn(red(`into the target branch: ${label.pattern}`)); warn(red(`merged into the target branch: ${label.pattern}`));
break; break;
} }
} }