diff --git a/.github/.cascadingrc.json b/.github/.cascadingrc.json index afe46cb215..3c679a6e6a 100644 --- a/.github/.cascadingrc.json +++ b/.github/.cascadingrc.json @@ -2,6 +2,7 @@ "$schema": "../apps/github-cascading-app/schemas/config.schema.json", "bypassReviewers": true, "labels": ["cascading"], + "onlyCascadeOnHighestMinors": true, "ignoredPatterns": [ "-next$" ] diff --git a/apps/github-cascading-app/schemas/config.schema.json b/apps/github-cascading-app/schemas/config.schema.json index f05ee2c5db..ac91146d9c 100644 --- a/apps/github-cascading-app/schemas/config.schema.json +++ b/apps/github-cascading-app/schemas/config.schema.json @@ -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", diff --git a/apps/github-cascading-app/src/cascading/cascading.spec.ts b/apps/github-cascading-app/src/cascading/cascading.spec.ts index c3495467db..f2d82d3493 100644 --- a/apps/github-cascading-app/src/cascading/cascading.spec.ts +++ b/apps/github-cascading-app/src/cascading/cascading.spec.ts @@ -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, diff --git a/apps/github-cascading-app/src/cascading/cascading.ts b/apps/github-cascading-app/src/cascading/cascading.ts index 8e738721a8..8808d0f0d3 100644 --- a/apps/github-cascading-app/src/cascading/cascading.ts +++ b/apps/github-cascading-app/src/cascading/cascading.ts @@ -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'; @@ -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 */ @@ -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; @@ -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 @@ -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) @@ -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); diff --git a/apps/github-cascading-app/src/cascading/interfaces.ts b/apps/github-cascading-app/src/cascading/interfaces.ts index d4d4add4d6..0b8e99d168 100644 --- a/apps/github-cascading-app/src/cascading/interfaces.ts +++ b/apps/github-cascading-app/src/cascading/interfaces.ts @@ -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 */ @@ -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; @@ -68,6 +73,7 @@ export const DEFAULT_CONFIGURATION: Readonly = { 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[],