Skip to content

Commit

Permalink
feat(cascading): option to bypass non-latest minor branch
Browse files Browse the repository at this point in the history
  • Loading branch information
kpanot committed Nov 15, 2024
1 parent 0042a21 commit 167e161
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 14 deletions.
1 change: 1 addition & 0 deletions .github/.cascadingrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "../apps/github-cascading-app/schemas/config.schema.json",
"bypassReviewers": true,
"labels": ["cascading"],
"onlyCascadeOnHighestMinors": true,
"ignoredPatterns": [
"-next$"
]
Expand Down
5 changes: 5 additions & 0 deletions apps/github-cascading-app/schemas/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"description": "Pattern determining if the branch is part of the cascading strategy",
"default": "^releases?/\\d+\\.\\d+"
},
"onlyCascadeOnHighestMinors": {
"type": "boolean",
"description": "Determine if the branches for which a higher minor version exists should be skipped during the cascading",
"default": false
},
"versionCapturePattern": {
"type": "string",
"description": "Pattern containing a capture to extract the version of a cascading branch",
Expand Down
87 changes: 87 additions & 0 deletions apps/github-cascading-app/src/cascading/cascading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,93 @@ describe('Cascading Application', () => {
expect(logger.info).toHaveBeenCalledWith('The branch test-cascading/1.0 is the last branch of the cascading. The process will stop.');
});

describe('on onlyCascadeOnHighestMinors options', () => {

it('should ignore branches not to the latest minor when true', async () => {
customization.loadConfiguration = customization.loadConfiguration.mockResolvedValue({
...DEFAULT_CONFIGURATION,
cascadingBranchesPattern: 'test-cascading/.*',
onlyCascadeOnHighestMinors: true,
ignoredPatterns: []
});
customization.getBranches = customization.getBranches.mockResolvedValue([
'test-cascading/1.0',
'test-cascading/1.1',
'test-cascading/1.2',
'test-cascading/2.0'
]);
customization.isBranchAhead = customization.isBranchAhead.mockResolvedValue(true);
customization.createBranch = customization.createBranch.mockResolvedValue();
customization.getPullRequests = customization.getPullRequests.mockResolvedValue([]);
customization.createPullRequest = customization.createPullRequest.mockResolvedValue({
id: 1,
originBranchName: '',
isOpen: true,
mergeable: true,
body: render(mockBasicTemplate, { isConflicting: false, targetBranch: 'main', currentBranch: 'release/0.1', bypassReviewers: true }, { async: false })
});
await expect(customization.cascade('test-cascading/1.0')).resolves.not.toThrow();
expect(logger.info).toHaveBeenCalledWith('Cascading plugin execution');
expect(customization.isBranchAhead).toHaveBeenCalledWith('test-cascading/1.0', 'test-cascading/1.2');
});

it('should ignore branches until latest if needed', async () => {
customization.loadConfiguration = customization.loadConfiguration.mockResolvedValue({
...DEFAULT_CONFIGURATION,
cascadingBranchesPattern: 'test-cascading/.*',
onlyCascadeOnHighestMinors: true,
defaultBranch: 'main',
ignoredPatterns: []
});
customization.getBranches = customization.getBranches.mockResolvedValue([
'test-cascading/1.0',
'main'
]);
customization.isBranchAhead = customization.isBranchAhead.mockResolvedValue(true);
customization.createBranch = customization.createBranch.mockResolvedValue();
customization.getPullRequests = customization.getPullRequests.mockResolvedValue([]);
customization.createPullRequest = customization.createPullRequest.mockResolvedValue({
id: 1,
originBranchName: '',
isOpen: true,
mergeable: true,
body: render(mockBasicTemplate, { isConflicting: false, targetBranch: 'main', currentBranch: 'release/0.1', bypassReviewers: true }, { async: false })
});
await expect(customization.cascade('test-cascading/1.0')).resolves.not.toThrow();
expect(logger.info).toHaveBeenCalledWith('Cascading plugin execution');
expect(customization.isBranchAhead).toHaveBeenCalledWith('test-cascading/1.0', 'main');
});

it('should consider branches not to the latest minor when false', async () => {
customization.loadConfiguration = customization.loadConfiguration.mockResolvedValue({
...DEFAULT_CONFIGURATION,
cascadingBranchesPattern: 'test-cascading/.*',
onlyCascadeOnHighestMinors: false,
ignoredPatterns: []
});
customization.getBranches = customization.getBranches.mockResolvedValue([
'test-cascading/1.0',
'test-cascading/1.1',
'test-cascading/1.2',
'test-cascading/2.0'
]);
customization.isBranchAhead = customization.isBranchAhead.mockResolvedValue(true);
customization.createBranch = customization.createBranch.mockResolvedValue();
customization.getPullRequests = customization.getPullRequests.mockResolvedValue([]);
customization.createPullRequest = customization.createPullRequest.mockResolvedValue({
id: 1,
originBranchName: '',
isOpen: true,
mergeable: true,
body: render(mockBasicTemplate, { isConflicting: false, targetBranch: 'main', currentBranch: 'release/0.1', bypassReviewers: true }, { async: false })
});
await expect(customization.cascade('test-cascading/1.0')).resolves.not.toThrow();
expect(logger.info).toHaveBeenCalledWith('Cascading plugin execution');
expect(customization.isBranchAhead).toHaveBeenCalledWith('test-cascading/1.0', 'test-cascading/1.1');
});

});

it('should skip ignored branch if not ahead', async () => {
customization.loadConfiguration = customization.loadConfiguration.mockResolvedValue({
...DEFAULT_CONFIGURATION,
Expand Down
54 changes: 41 additions & 13 deletions apps/github-cascading-app/src/cascading/cascading.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { coerce, compare, parse, valid } from 'semver';
import { coerce, compare, lte, parse, type SemVer, valid } from 'semver';
import { BaseLogger, CascadingConfiguration, CascadingPullRequestInfo, CheckConclusion, PullRequestContext } from './interfaces';
import { renderFile } from 'ejs';
import { resolve } from 'node:path';
Expand All @@ -15,6 +15,9 @@ export const CASCADING_BRANCH_PREFIX = 'cascading';
/** Time (in ms) to wait before re-checking the mergeable status of a PR */
export const RETRY_MERGEAGLE_STATUS_CHECK_TIMING = 3000;

/** Object representing a branch with the version determine from its name */
type BranchObject = { branch: string; semver: SemVer | undefined };

/**
* Handles the cascading to the next branch
*/
Expand Down Expand Up @@ -171,7 +174,8 @@ export abstract class Cascading {
};
}
})
.filter(({branch, semver}) => {
.filter((branchObject): branchObject is BranchObject => {
const { branch, semver } = branchObject;
if (semver === null) {
this.logger.warn(`Failed to parse the branch ${branch}, it will be skipped from cascading`);
return false;
Expand All @@ -193,7 +197,7 @@ export abstract class Cascading {
}

/**
* Generate teh cascading branch name
* Generate the cascading branch name
* @param baseVersion Version extracted from the base branch
* @param targetVersion Version extracted from the target branch
* @param configurations
Expand Down Expand Up @@ -340,6 +344,36 @@ export abstract class Cascading {
return !checkboxLine?.[0]?.match(/^ *- \[x]/i);
}

protected getTargetBranch(cascadingBranches: BranchObject[], currentBranchName: string, config: CascadingConfiguration) {
const branchIndex = cascadingBranches.findIndex(({ branch }) => branch === currentBranchName);
if (branchIndex < 0) {
this.logger.error(`The branch ${currentBranchName} is not part of the list of cascading branch. The process will stop.`);
return;
}

if (branchIndex === cascadingBranches.length - 1) {
this.logger.info(`The branch ${currentBranchName} is the last branch of the cascading. The process will stop.`);
return;
}

const targetBranchIndex = branchIndex + 1;
if (!config.onlyCascadeOnHighestMinors) {
return cascadingBranches[targetBranchIndex];
}

for (let i = targetBranchIndex; i < cascadingBranches.length; i++) {
const targetBranch = cascadingBranches[i];
const { semver } = targetBranch;
if (!semver) {
return targetBranch;
}

if (cascadingBranches.slice(i + 1).every((otherBranch) => otherBranch.semver?.major !== semver.major || (otherBranch.semver && lte(otherBranch.semver, semver)))) {
return targetBranch;
}
}
}

/**
* Launch the cascading process
* @param currentBranchName name of the branch to cascade (ex: release/8.0)
Expand All @@ -365,20 +399,14 @@ export abstract class Cascading {
this.logger.info('Cascading plugin execution');
const branches = await this.getBranches();
const cascadingBranches = this.getOrderedCascadingBranches(branches, config);
const branchIndex = cascadingBranches.findIndex(({ branch }) => branch === currentBranchName);
const targetBranch = this.getTargetBranch(cascadingBranches, currentBranchName, config);

if (branchIndex < 0) {
this.logger.error(`The branch ${currentBranchName} is not part of the list of cascading branch. The process will stop.`);
return;
}

if (branchIndex === cascadingBranches.length - 1) {
this.logger.info(`The branch ${currentBranchName} is the last branch of the cascading. The process will stop.`);
if (!targetBranch) {
this.logger.info(`No target branch found for the cascading from ${currentBranchName}. The process will stop.`);
return;
}

const currentBranch = cascadingBranches[branchIndex];
const targetBranch = cascadingBranches[branchIndex + 1];
const currentBranch = cascadingBranches.find(({ branch }) => branch === currentBranchName)!;
const cascadingBranch = this.determineCascadingBranchName(currentBranch.semver?.format() || currentBranch.branch, targetBranch.semver?.format() || targetBranch.branch, config);
const isAhead = await this.isBranchAhead(currentBranch.branch, targetBranch.branch);

Expand Down
8 changes: 7 additions & 1 deletion apps/github-cascading-app/src/cascading/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface CascadingConfiguration {
defaultBranch: string;
/** Pattern determining if the branch is part of the cascading strategy */
cascadingBranchesPattern: string;
/** Determine if the branches for which a higher minor version exists should be skipped during the cascading */
onlyCascadeOnHighestMinors: boolean;
/** Pattern containing a capture to extract the version of a cascading branch */
versionCapturePattern: string;
/** Bypass the reviewers validation for the pull request, only the CI checks will be executed */
Expand All @@ -34,7 +36,10 @@ export interface PullRequestContext {
currentBranch: string;
/** Cascading Pull Request Target Branch */
targetBranch: string;
/** Determine if the reviewers are bypassed */
/**
* Determine if the reviewers are bypassed
* Note: This option is not supported on Github anymore due to Github Api change.
**/
bypassReviewers: boolean;
/** Is the an update of the {@link currentBranch} conflicting */
isConflicting: boolean;
Expand Down Expand Up @@ -68,6 +73,7 @@ export const DEFAULT_CONFIGURATION: Readonly<CascadingConfiguration> = {
ignoredPatterns: [] as string[],
defaultBranch: '',
cascadingBranchesPattern: '^releases?/\\d+\\.\\d+',
onlyCascadeOnHighestMinors: false,
versionCapturePattern: '/((?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:\\.0-[^ ]+)?)$',
bypassReviewers: false,
labels: [] as string[],
Expand Down

0 comments on commit 167e161

Please sign in to comment.