diff --git a/index.ts b/index.ts index 3dea103..3f00f0f 100644 --- a/index.ts +++ b/index.ts @@ -1,12 +1,19 @@ -import { Logger, executeBackmerge, getBranches } from "./lib/backmerge" +import { Branch, checkout, fetch, ls, version } from "./lib/git" +import { Logger, backmerge, filter } from "./lib/backmerge" import { PlatformHandler, newPlatformHandler } from "./lib/platform-handler" import { SuccessContext, VerifyConditionsContext } from 'semantic-release' import { ensureDefault, verifyConfig } from "./lib/verify-config" import SemanticReleaseError from "@semantic-release/error" +import debug from "debug" import { BackmergeConfig } from "./lib/models/config" -import { version } from "./lib/git" + +/** + * prefix needs to be semantic-release: + * @see https://github.com/semantic-release/semantic-release/blob/8940f32ccce455a01a4e32c101bb0f4a809ab00d/cli.js#L52 + */ +const deblog = debug("semantic-release:backmerge") /** * verifyConditions is the exported function for semantic-release for verifyConditions lifecycle. @@ -49,15 +56,51 @@ export const verifyConditions = async (globalConfig: BackmergeConfig, context: V * @param context the semantic-release context. */ export const success = async (globalConfig: BackmergeConfig, context: SuccessContext) => { - const [config, platformHandler] = await verifyConditions(globalConfig, context) + const logger = context.logger as Logger + const [config, handler] = await verifyConditions(globalConfig, context) + + // filter targets to find if the current released branch needs to be backmerged into others + const targets = config.targets.filter(branch => context.branch.name.match(branch.from)) + if (targets.length === 0) { + logger.log(`Current branch '${context.branch.name}' doesn't match any configured backmerge targets.`) + return + } + logger.log(`Current branch '${context.branch.name}' matches following configured backmerge targets: '${JSON.stringify(targets)}'.`) + + // fetch remote and retrieve all remote branches + const branches: Branch[] = [] + try { + // ensure at any time and any moment that the fetch'ed remote url is the same as there + // https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69 + // it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits + await fetch(config.repositoryUrl, context.cwd, context.env) + branches.push(...await ls(config.repositoryUrl, context.cwd, context.env)) + } catch (error) { + throw new SemanticReleaseError("Failed to fetch git remote or list all branches.", "EFECTHLISTREMOTE", String(error)) + } + + // retrieve current released branch informations + const release = branches.find(branch => branch.name === context.branch.name) + if (!release) { + deblog("released branch is not present in branches fetched from git '%j'", branches) + throw new SemanticReleaseError("Failed to retrieve released branch last commit hash. This shouldn't happen.", "ELISTREMOTE") + } + // filter targets branch with the known remote branches + // and with different business rules like vX.X, etc. + const mergeables = filter(release, targets, branches) + if (mergeables.length === 0) { // released branch is contained in branches + logger.log("No configured target is present in remote origin, no backmerge to be done.") + return // stop treatment since there's no branch to backmerge + } + + // checkout to ensure released branch is up to date with last fetch'ed remote url try { - const branches = await getBranches(context, config, platformHandler) - await executeBackmerge(context, config, platformHandler, branches) + await checkout(release, context.cwd, context.env) } catch (error) { - if (error instanceof AggregateError || error instanceof SemanticReleaseError) { - throw error // don't wrap error in case it's already an acceptable error by semantic-release - } - throw new SemanticReleaseError("Failed to list or backmerge branches.", "EBACKMERGE", String(error)) + throw new SemanticReleaseError(`Failed to checkout released branch '${release.name}'.`, "EFETCHCHECKOUT", String(error)) } + + logger.log(`Performing backmerge of '${release.name}' on following branches: '${JSON.stringify(mergeables)}'.`) + await backmerge(context, config, handler, release, mergeables) } \ No newline at end of file diff --git a/lib/backmerge.ts b/lib/backmerge.ts index f9dea39..973928a 100644 --- a/lib/backmerge.ts +++ b/lib/backmerge.ts @@ -1,5 +1,5 @@ +import { Branch, authModificator, checkout, merge, push } from "./git" import { LastRelease, NextRelease } from "semantic-release" -import { authModificator, checkout, fetch, ls, merge, push } from "./git" import AggregateError from "aggregate-error" import SemanticReleaseError from "@semantic-release/error" @@ -7,7 +7,7 @@ import debug from "debug" import parse from "git-url-parse" import semver from "semver" -import { BackmergeConfig } from "./models/config" +import { BackmergeConfig, Target } from "./models/config" import { PlatformHandler } from "./platform-handler" import { template } from "lodash" @@ -39,58 +39,32 @@ export interface Context { } /** - * getBranches returns the slice of branches that can be backmerged. + * filter removes from input branches the ones not appropriates for backmerging with the input released branch. * - * To retrieve them, it takes into account their existence in remote repository, their presence in input targets, - * and their semver version value related to the appropriate target (for instance, a branch v1.0 won't be returned if target.from is v1.1). + * Not being appropriate is not being configured in the input targets list + * or being too old in case of maintenance branches. * - * @param context with logger, released branch, current directory and environment. - * @param config the semantic-release-backmerge plugin configuration. - * @param platformHandler the interface to handle current git platform API calls. - * - * @throws an error in case the input remote can't be fetched or the branches can be retrieved with git. + * @param release the released branch. + * @param targets the input targets configurations in semantic-release-backmerge. + * @param branches the list of branches potentially backmergeable. * - * @returns the slice of branches where the context.branch.name must be backmerged into. + * @returns the list of branches to backmerge the released one into. */ -export const getBranches = async (context: Context, config: BackmergeConfig, platformHandler: PlatformHandler) => { - const releaseBranch = context.branch.name - - const appropriates = config.targets.filter(branch => releaseBranch.match(branch.from)) - if (appropriates.length === 0) { - context.logger.log(`Current branch '${releaseBranch}' doesn't match any configured backmerge targets.`) - return [] - } - context.logger.log(`Current branch '${releaseBranch}' matches following configured backmerge targets: '${JSON.stringify(appropriates)}'. Performing backmerge.`) - - const url = parse(config.repositoryUrl) - const authRemote = authModificator(url, platformHandler.gitUser(), config.token) - - let branches: string[] = [] // eslint-disable-line no-useless-assignment - try { - // ensure at any time and any moment that the fetch'ed remote url is the same as there - // https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69 - // it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits - await fetch(authRemote, context.cwd, context.env) - - branches = await ls(config.repositoryUrl, context.cwd, context.env) - } catch (error) { - throw new SemanticReleaseError("Failed to fetch git remote or list all branches.", "EFECTHLIST", String(error)) - } - +export const filter = (release: Branch, targets: Target[], branches: Branch[]): Branch[] => { deblog("filtering branch to backmerge from '%j'", branches) - const filteredBranches = branches. + const mergeables = branches. // don't keep the released branch - filter(branch => releaseBranch !== branch). + filter(branch => branch.name !== release.name). // don't keep branches that doesn't match 'to' regexp - filter(branch => appropriates.map(target => target.to).find(target => branch.match(target))). + filter(branch => targets.map(target => target.to).find(target => branch.name.match(target))). // only keep upper version when it's a semver released branch // for instance v1 must not backmerge into anyone - // for instance v1.5 must backmerge into v1.6, v1.7, etc. + // for instance v1.5 must only backmerge into v1.6, v1.7, etc. filter(branch => { - const releaseMaintenance = semver.valid(semver.coerce(releaseBranch)) - const branchMaintenance = semver.valid(semver.coerce(branch)) + const releaseMaintenance = semver.valid(semver.coerce(release.name)) + const branchMaintenance = semver.valid(semver.coerce(branch.name)) if (releaseMaintenance && branchMaintenance) { // don't keep branches of other major versions @@ -104,96 +78,83 @@ export const getBranches = async (context: Context, config: BackmergeConfig, pla // don't merge into older versions if (semver.lt(branchMaintenance, releaseMaintenance)) { - deblog(`not backmerging into '${branch}' since semver version is before '${releaseBranch}'`) + deblog(`not backmerging into '${branch.name}' since semver version is before '${release.name}'`) return false } // don't merge minor versions into next majors versions if (semver.gte(branchMaintenance, nextMajor!)) { - deblog(`not backmerging into '${branch}' since semver major version is after '${releaseBranch}'`) + deblog(`not backmerging into '${branch.name}' since semver major version is after '${release.name}'`) return false } } return true }) - if (filteredBranches.length === 0) { - context.logger.log("No configured target is present in remote origin, no backmerge to be done.") - return [] - } - deblog(`retrieved branches present in remote origin: '${JSON.stringify(filteredBranches)}'`) - return filteredBranches + deblog(`retrieved branches present in remote origin: '${JSON.stringify(mergeables)}'`) + return mergeables } /** - * executeBackmerge runs a backmerge from context.branch.name into all input branches. + * backmerge runs a backmerge from context.branch.name into all input branches. + * * For that, it runs a fetch of input remote, then a checkout of the released branch (to ensure all commits are up to date) * and then merge released branch into each branch from branches. + * * If a merge fails, it tries to create a pull request. * * @param context input context with the logger, released branch, etc. * @param config the semantic-release-backmerge plugin configuration. - * @param platformHandler the interface to handle current git platform API calls. - * @param branches slice of branches to be backmerged with released branch commits. + * @param handler the interface to handle current git platform API calls. + * @param mergeables slice of branches to be backmerged with released branch commits. * * @throws AggregateError of SemanticReleaseError(s) for each branch that couldn't be backmerged. */ -export const executeBackmerge = async (context: Context, config: BackmergeConfig, platformHandler: PlatformHandler, branches: string[]) => { - const releaseBranch = context.branch.name - +export const backmerge = async (context: Context, config: BackmergeConfig, handler: PlatformHandler, release: Branch, mergeables: Branch[]) => { const url = parse(config.repositoryUrl) - const authRemote = authModificator(url, platformHandler.gitUser(), config.token) - - try { - // ensure at any time and any moment that the fetch'ed remote url is the same as there - // https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69 - // it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits - await fetch(authRemote, context.cwd, context.env) - - // checkout to ensure released branch is up to date with last fetch'ed remote url - await checkout(releaseBranch, context.cwd, context.env) - } catch (error) { - throw new SemanticReleaseError(`Failed to fetch or checkout released branch '${releaseBranch}'.`, "EFETCHCHECKOUT", String(error)) - } + const authRemote = authModificator(url, handler.gitUser(), config.token) const errors: SemanticReleaseError[] = [] - for (const branch of branches) { // keep await in loop since git actions aren't thread safe + for (const branch of mergeables) { // keep await in loop since git actions aren't thread safe const templateData = { - from: releaseBranch, + from: release.name, lastRelease: context.lastRelease, nextRelease: context.nextRelease, - to: branch, + to: branch.name, } + // try to merge with git the released branch into the current loop branch try { - await merge(releaseBranch, branch, template(config.commit)(templateData), context.cwd, context.env) + await checkout(branch) + await merge(release.name, template(config.commit)(templateData), context.cwd, context.env) if (config.dryRun) { - context.logger.log(`Running with --dry-run, push to '${branch}' will not update remote state.`) + context.logger.log(`Running with --dry-run, push to '${branch.name}' will not update remote state.`) } - await push(authRemote, branch, config.dryRun, context.cwd, context.env) + await push(authRemote, branch.name, config.dryRun, context.cwd, context.env) } catch (error) { - context.logger.error(`Failed to backmerge '${releaseBranch}' into '${branch}', opening pull request.`, error) + context.logger.error(`Failed to backmerge '${release.name}' into '${branch.name}', opening pull request.`, error) if (config.dryRun) { - context.logger.log(`Running with --dry-run, created pull request would have been from '${releaseBranch}' into '${branch}'.`) + context.logger.log(`Running with --dry-run, created pull request would have been from '${release.name}' into '${branch.name}'.`) continue } + // in case of merge error with git, create a pull request with the platform handler try { - const exists = await platformHandler.hasPull(url.owner, url.name, releaseBranch, branch) + const exists = await handler.hasPull(url.owner, url.name, release.name, branch.name) if (exists) { - context.logger.log(`A pull request already exists between '${releaseBranch}' and '${branch}'. Not creating another.`) + context.logger.log(`A pull request already exists between '${release.name}' and '${branch.name}'. Not creating another.`) continue } const title = template(config.title)(templateData) - await platformHandler.createPull(url.owner, url.name, { + await handler.createPull(url.owner, url.name, { body: context.nextRelease.notes ?? "", - from: releaseBranch, + from: release.name, title, - to: branch + to: branch.name }) } catch (prerror) { - errors.push(new SemanticReleaseError(`Failed to create pull request from '${releaseBranch.name}' to '${branch.name}'.`, "EPULLREQUEST", String(prerror))) + errors.push(new SemanticReleaseError(`Failed to create pull request from '${release.name}' to '${branch.name}'.`, "EPULLREQUEST", String(prerror))) } } } diff --git a/lib/git.ts b/lib/git.ts index 05644e9..c37d61a 100644 --- a/lib/git.ts +++ b/lib/git.ts @@ -3,6 +3,15 @@ import { execa } from "execa" import debug from "debug" +/** + * Branch represents a simplified interface for a branch + * and its remote commit hash. + */ +export interface Branch { + hash: string + name: string +} + /** * prefix needs to be semantic-release: * @see https://github.com/semantic-release/semantic-release/blob/8940f32ccce455a01a4e32c101bb0f4a809ab00d/cli.js#L52 @@ -68,12 +77,19 @@ export const ls = async (remote: string, cwd?: string, env?: Record branch.split("\t")). - flat(). - filter(branch => branch.startsWith("refs/heads/")). - map(branch => branch.replace("refs/heads/", "")) + filter(branch => branch.includes("refs/heads/")). + map(branch => branch.replace("refs/heads/", "")). + map(branch => { + const parts = branch.split("\t") // ls-remote lists elements with the form "\t" + if (parts.length !== 2) { + deblog("retrieved invalid branch in git ls-remote: %s", branch) + return { hash: "", name: "" } + } + return { hash: parts[0], name: parts[1] } + }). + filter(branch => branch.name !== "" && branch.hash !== "") // filter unparseable branches return [...new Set(branches)] } @@ -88,10 +104,14 @@ export const ls = async (remote: string, cwd?: string, env?: Record) => { - deblog("executing git checkout command") - const { stderr } = await execa("git", ["checkout", "-B", branch], { cwd, env }) +export const checkout = async (branch: Branch, cwd?: string, env?: Record) => { + deblog("executing git checkout command with branch '%j'", branch) + const { stderr } = await execa("git", ["checkout", "-B", branch.name, branch.hash], { cwd, env }) if (stderr !== "") { + deblog("received stderr text from git checkout with branch '%j': %s", branch, stderr) + } +} + /** * current returns the current commit where the current branch is at. * @@ -128,17 +148,14 @@ export const fetch = async (remote: string, cwd?: string, env?: Record) => { - await checkout(to) - +export const merge = async (from: string, commit: string, cwd?: string, env?: Record) => { try { deblog("executing git merge command with branch '%s'", from) const { stderr } = await execa("git", ["merge", `${from}`, "--ff", "-m", commit], { cwd, env }) diff --git a/test/backmerge.test.ts b/test/backmerge.test.ts index 169ed52..3c2f42a 100644 --- a/test/backmerge.test.ts +++ b/test/backmerge.test.ts @@ -1,9 +1,10 @@ import * as git from "../lib/git" -import { Context, executeBackmerge, getBranches } from "../lib/backmerge" +import { Context, backmerge, filter } from "../lib/backmerge" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { Platform } from "../lib/models/config" +import { Branch } from "../lib/git" +import { Target } from "../lib/models/config" import { TestPlatformHandler } from "./platform-handler.test" import { ensureDefault } from "../lib/verify-config" @@ -28,321 +29,189 @@ const getContext = (name: string): Context => ({ } }) -describe("getBranches", () => { - afterEach(() => mock.restore()) - - test("should not return any branch", async () => { - // Arrange - const context = getContext("main") - const config = ensureDefault({ - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "staging", to: "develop" }], - }, {}) - - // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) - - // Assert - expect(branches).toBeEmpty() - }) - - test("should fail to fetch", () => { - // Arrange - spyOn(git, "fetch").mockImplementation(() => { throw new Error("an error message") }) - - const context = getContext("main") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "main", to: "staging" }], - }, {}) - - // Act - const matcher = expect(async () => await getBranches(context, config, new TestPlatformHandler())) - - // Assert - matcher.toThrowError("Failed to fetch git remote or list all branches.") - }) - - test("should fail to ls branches", () => { +describe("filter", () => { + test("should retrieve some branches", () => { // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "ls").mockImplementation(() => { throw new Error("an error message") }) - - const context = getContext("main") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "main", to: "staging" }], - }, {}) + const release: Branch = { hash: "", name: "main" } + const targets: Target[] = [{ from: "main", to: "(develop|staging)" }] + const branches: Branch[] = [ + { hash: "", name: "main" }, + { hash: "", name: "develop" }, + { hash: "", name: "staging" }, + ] // Act - const matcher = expect(async () => await getBranches(context, config, new TestPlatformHandler())) + const mergeables = filter(release, targets, branches) // Assert - matcher.toThrowError("Failed to fetch git remote or list all branches.") + expect(mergeables).toEqual([ + { hash: "", name: "develop" }, + { hash: "", name: "staging" }, + ]) }) - test("should not retrieve older semver branches", async () => { + test("should retrieve more recent semver branches but only same major version", () => { // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - const spy = spyOn(git, "ls").mockImplementation(async () => ["v1.0", "v1.1", "v1.2"]) - - const context = getContext("v1.2") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "v[0-9]+(.[0-9]+)?", to: "v[0-9]+(.[0-9]+)?" }], - }, {}) + const release: Branch = { hash: "", name: "v1.2" } + const targets: Target[] = [{ from: "v[0-9]+(.[0-9]+)?", to: "v[0-9]+(.[0-9]+)?" }] + const branches: Branch[] = [ + { hash: "", name: "v1.0" }, + { hash: "", name: "v1.1" }, + { hash: "", name: "v1.2" }, + { hash: "", name: "v1.3" }, + { hash: "", name: "v1.4" }, + { hash: "", name: "v2.0" }, + { hash: "", name: "v2.6" }, + ] // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) + const mergeables = filter(release, targets, branches) // Assert - expect(spy).toHaveBeenCalled() - expect(branches).toBeEmpty() + expect(mergeables).toEqual([ + { hash: "", name: "v1.3" }, + { hash: "", name: "v1.4" }, + ]) }) - test("should retrieve some branches", async () => { + test("should retrieve more recent semver branches but only same major version (.x case)", () => { // Arrange - const expectedURL = "https://test-user:some-token@github.com/kilianpaquier/semantic-release-backmerge.git" - - let fetchRemote = "" - let lsRemote = "" - spyOn(git, "fetch").mockImplementation(async (remote) => { fetchRemote = remote }) - spyOn(git, "ls").mockImplementation(async (remote) => { - lsRemote = remote - return ["develop", "staging"] - }) - - const context = getContext("main") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "main", to: "(develop|staging)" }], - }, { GITHUB_TOKEN: "some-token" }) + const release: Branch = { hash: "", name: "v1.2.x" } + const targets: Target[] = [{ from: "v[0-9]+(.{[0-9]+,x})?", to: "v[0-9]+(.{[0-9]+,x})?" }] + const branches: Branch[] = [ + { hash: "", name: "v1.0.x" }, + { hash: "", name: "v1.1.x" }, + { hash: "", name: "v1.2.x" }, + { hash: "", name: "v1.3.x" }, + { hash: "", name: "v1.4.x" }, + { hash: "", name: "v2.0.x" }, + { hash: "", name: "v2.6.x" } + ] // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) + const mergeables = filter(release, targets, branches) // Assert - expect(fetchRemote).toEqual(expectedURL) - expect(lsRemote).toEqual(config.repositoryUrl) - expect(branches).toEqual(["develop", "staging"]) + expect(mergeables).toEqual([ + { hash: "", name: "v1.3.x" }, + { hash: "", name: "v1.4.x" }, + ]) }) - test("should retrieve more recent semver branches but only same major version", async () => { + test("should retrieve no version since it's the major one", () => { // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "ls").mockImplementation(async () => ["v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v2.0", "v2.6"]) - - const context = getContext("v1.2") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "v[0-9]+(.[0-9]+)?", to: "v[0-9]+(.[0-9]+)?" }], - }, {}) + const release: Branch = { hash: "", name: "v1" } + const targets: Target[] = [{ from: "v[0-9]+(.[0-9]+)?", to: "v[0-9]+(.[0-9]+)?" }] + const branches: Branch[] = [ + { hash: "", name: "v1.0" }, + { hash: "", name: "v1.1" }, + { hash: "", name: "v1.2" }, + { hash: "", name: "v1.3" }, + { hash: "", name: "v1.4" }, + { hash: "", name: "v2.0" }, + { hash: "", name: "v2.6" }, + ] // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) + const mergeables = filter(release, targets, branches) // Assert - expect(branches).toEqual(["v1.3", "v1.4"]) + expect(mergeables).toBeEmpty() }) - test("should retrieve more recent semver branches but only same major version (.x case)", async () => { + test("should retrieve no version since it's the major one (.x case)", () => { // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "ls").mockImplementation(async () => ["v1.0.x", "v1.1.x", "v1.2.x", "v1.3.x", "v1.4.x", "v2.0.x", "v2.6.x"]) - - const context = getContext("v1.2.x") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "v[0-9]+(.{[0-9]+,x})?", to: "v[0-9]+(.{[0-9]+,x})?" }], - }, {}) + const release: Branch = { hash: "", name: "v1.x" } + const targets: Target[] = [{ from: "v[0-9]+(.{[0-9]+,x})?", to: "v[0-9]+(.{[0-9]+,x})?" }] + const branches: Branch[] = [ + { hash: "", name: "v1.0.x" }, + { hash: "", name: "v1.1.x" }, + { hash: "", name: "v1.2.x" }, + { hash: "", name: "v1.3.x" }, + { hash: "", name: "v1.4.x" }, + { hash: "", name: "v2.0.x" }, + { hash: "", name: "v2.6.x" } + ] // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) + const mergeables = filter(release, targets, branches) // Assert - expect(branches).toEqual(["v1.3.x", "v1.4.x"]) - }) - - test("should retrieve no version since it's the major one", async () => { - // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "ls").mockImplementation(async () => ["v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v2.0", "v2.6"]) - - const context = getContext("v1") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "v[0-9]+(.[0-9]+)?", to: "v[0-9]+(.[0-9]+)?" }], - }, {}) - - // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) - - // Assert - expect(branches).toBeEmpty() - }) - - test("should retrieve no version since it's the major one (.x case)", async () => { - // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "ls").mockImplementation(async () => ["v1.0.x", "v1.1.x", "v1.2.x", "v1.3.x", "v1.4.x", "v2.0.x", "v2.6.x"]) - - const context = getContext("v1.x") - const config = ensureDefault({ - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - targets: [{ from: "v[0-9]+(.{[0-9]+,x})?", to: "v[0-9]+(.{[0-9]+,x})?" }], - }, {}) - - // Act - const branches = await getBranches(context, config, new TestPlatformHandler()) - - // Assert - expect(branches).toBeEmpty() + expect(mergeables).toBeEmpty() }) }) -describe("executeBackmerge", () => { +describe("backmerge", () => { afterEach(() => mock.restore()) - test("should fail to fetch remote", () => { - // Arrange - spyOn(git, "fetch").mockImplementation(() => { throw new Error("an error message") }) - - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) - - // Act - const matcher = expect(async () => await executeBackmerge(context, config, new TestPlatformHandler(), [])) - - // Assert - matcher.toThrowError("Failed to fetch or checkout released branch 'main'.") - }) - - test("should fail to checkout released branch", () => { - // Arrange - spyOn(git, "fetch").mockImplementation(async () => {}) - spyOn(git, "checkout").mockImplementation(() => { throw new Error("an error message") }) - - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) - - // Act - const matcher = expect(async () => await executeBackmerge(context, config, new TestPlatformHandler(), [])) - - // Assert - matcher.toThrowError("Failed to fetch or checkout released branch 'main'.") - }) + const context = getContext("main") + const release: Branch = { hash: "", name: "main" } + const branches: Branch[] = [{ hash: "", name: "staging" }] test("should fail to merge branch and create pull request", async () => { // Arrange - const has = async (): Promise => false let called = false - const create = async (): Promise => { called = true } + const handler = new TestPlatformHandler(async (): Promise => { called = true }, async (): Promise => false) - spyOn(git, "fetch").mockImplementation(async () => {}) spyOn(git, "checkout").mockImplementation(async () => {}) spyOn(git, "merge").mockImplementation(() => { throw new Error("an error message") }) spyOn(git, "push").mockImplementation(() => { throw new Error("shouldn't be called") }) - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) + const config = ensureDefault({ repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git" }, { GITHUB_TOKEN: "some-token" }) // Act - await executeBackmerge(context, config, new TestPlatformHandler(create, has), ["staging"]) + await backmerge(context, config, handler, release, branches) // Assert expect(called).toBeTrue() }) - test("should fail to merge branch and not create a pull request since it exists", async () => { + test("should fail to merge branch and not create a pull request since it exists", () => { // Arrange - const has = async (): Promise => true - let called = false - const create = async (): Promise => { called = true } + const handler = new TestPlatformHandler(async (): Promise => { throw new Error("shouldn't be called") }, async (): Promise => true) - spyOn(git, "fetch").mockImplementation(async () => {}) spyOn(git, "checkout").mockImplementation(async () => {}) spyOn(git, "merge").mockImplementation(() => { throw new Error("an error message") }) spyOn(git, "push").mockImplementation(() => { throw new Error("shouldn't be called") }) - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) + const config = ensureDefault({ repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git" }, { GITHUB_TOKEN: "some-token" }) // Act - await executeBackmerge(context, config, new TestPlatformHandler(create, has), ["staging"]) + const matcher = expect(async () => await backmerge(context, config, handler, release, branches)) // Assert - expect(called).toBeFalse() + matcher.not.toThrow() }) - test("should fail to merge branch but not create pull request dry run", async () => { + test("should fail to merge branch but not create pull request dry run", () => { // Arrange - const has = async (): Promise => false - let called = false - const create = async (): Promise => { called = true } + const handler = new TestPlatformHandler(async (): Promise => { throw new Error("shouldn't be called") }, async (): Promise => false) - spyOn(git, "fetch").mockImplementation(async () => {}) spyOn(git, "checkout").mockImplementation(async () => {}) spyOn(git, "merge").mockImplementation(() => { throw new Error("an error message") }) spyOn(git, "push").mockImplementation(() => { throw new Error("shouldn't be called") }) - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - dryRun: true, - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) + const config = ensureDefault({ dryRun: true, repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git" }, { GITHUB_TOKEN: "some-token" }) // Act - await executeBackmerge(context, config, new TestPlatformHandler(create, has), ["staging"]) + const matcher = expect(async () => await backmerge(context, config, handler, release, branches)) // Assert - expect(called).toBeFalse() + matcher.not.toThrow() }) test("should fail to push branch and fail to create pull request", () => { // Arrange - const has = async (): Promise => false - const create = async (): Promise => { throw new Error("pull request error") } + const handler = new TestPlatformHandler(async (): Promise => { throw new Error("pull request error") }, async (): Promise => false) - spyOn(git, "fetch").mockImplementation(async () => {}) spyOn(git, "checkout").mockImplementation(async () => {}) spyOn(git, "merge").mockImplementation(async () => {}) spyOn(git, "push").mockImplementation(() => { throw new Error("an error message") }) - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, {}) + const config = ensureDefault({ repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git" }, { GITHUB_TOKEN: "some-token" }) // Act - const matcher = expect(async () => await executeBackmerge(context, config, new TestPlatformHandler(create, has), ["staging"])) + const matcher = expect(async () => await backmerge(context, config, handler, release, branches)) // Assert matcher.toThrowError("Failed to create pull request from 'main' to 'staging'.") @@ -350,40 +219,23 @@ describe("executeBackmerge", () => { test("should succeed to merge and push a branch", async () => { // Arrange - const expectedURL = "https://test-user:some-token@github.com/kilianpaquier/semantic-release-backmerge.git" + const expectedUrl = "https://test-user:some-token@github.com/kilianpaquier/semantic-release-backmerge.git" - let fetchRemote = "" - let checkoutBranch = "" - let merge: { commit?: string, from?: string, to?: string } = {} + const checkouts: Branch[] = [] + let merge: { commit?: string, from?: string } = {} let push: { branch?: string, dryRun?: boolean, remote?: string } = {} - spyOn(git, "fetch").mockImplementation(async (remote: string) => { - fetchRemote = remote - }) - spyOn(git, "checkout").mockImplementation(async (branch: string) => { - checkoutBranch = branch - }) - spyOn(git, "merge").mockImplementation(async (from: string, to: string, commit: string) => { - merge = { commit, from, to } - }) - spyOn(git, "push").mockImplementation(async (remote: string, branch: string, dryRun?: boolean) => { - push = { branch, dryRun, remote } - }) - - const context = getContext("main") - const config = ensureDefault({ - baseUrl: "https://example.com", - dryRun: true, - platform: Platform.GITHUB, - repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git", - }, { GITHUB_TOKEN: "some-token" }) + spyOn(git, "checkout").mockImplementation(async (branch: Branch) => { checkouts.push(branch) }) + spyOn(git, "merge").mockImplementation(async (from: string, commit: string) => { merge = { commit, from } }) + spyOn(git, "push").mockImplementation(async (remote: string, branch: string, dryRun?: boolean) => { push = { branch, dryRun, remote } }) + + const config = ensureDefault({ repositoryUrl: "git@github.com:kilianpaquier/semantic-release-backmerge.git" }, { GITHUB_TOKEN: "some-token" }) // Act - await executeBackmerge(context, config, new TestPlatformHandler(), ["staging"]) + await backmerge(context, config, new TestPlatformHandler(), release, branches) // Assert - expect(fetchRemote).toEqual(expectedURL) - expect(checkoutBranch).toEqual("main") - expect(merge).toEqual({ commit: "chore(release): merge branch main into staging [skip ci]", from: "main", to: "staging" }) - expect(push).toEqual({ branch: "staging", dryRun: true, remote: expectedURL }) + expect(checkouts).toEqual(branches) + expect(merge).toEqual({ commit: "chore(release): merge branch main into staging [skip ci]", from: "main" }) + expect(push).toEqual({ branch: "staging", dryRun: false, remote: expectedUrl }) }) }) \ No newline at end of file diff --git a/test/git.test.ts b/test/git.test.ts index b000587..e614a38 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -78,6 +78,10 @@ describe("ls", () => { const branches = await ls("origin") // a small test to ensure execa works // Assert + const main = branches.filter(branch => branch.name === "main") + expect(main).not.toBeUndefined() + }) +}) describe("version", () => { test("should return the current git version", async () => { diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..944fca9 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,254 @@ +import * as backmerge from "../lib/backmerge" +import * as git from "../lib/git" +import * as index from "../index" +import * as platform from "../lib/platform-handler" +import * as verify from "../lib/verify-config" + +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { success, verifyConditions } from "../index" + +import { BackmergeConfig } from "../lib/models/config" +import { SuccessContext } from "semantic-release" +import { TestPlatformHandler } from "./platform-handler.test" +import { ensureDefault } from "../lib/verify-config" + +const getContext = (name: string): SuccessContext => ({ + branch: { name }, + branches: [], + commits: [], + env: {}, + envCi: { + branch: "", + commit: "", + isCi: false, + }, + lastRelease: { + channels: [], + gitHead: "", + gitTag: "v0.0.0", + name: "last_release", + version: "v0.0.0", + }, + logger: console, + nextRelease: { + channel: "", + gitHead: "", + gitTag: "v0.0.0", + name: "next_release", + type: "major", + version: "v0.0.0", + }, + releases: [], + // @ts-expect-error unused + stderr: undefined, // eslint-disable-line no-undefined + // @ts-expect-error unused + stdout: undefined, // eslint-disable-line no-undefined +}) + +describe("verifyConditions", () => { + afterEach(() => mock.restore()) + + const context = getContext("main") + + test("should fail with git version unverifiable", () => { + // Arrange + spyOn(git, "version").mockImplementation(async () => { throw new Error("an error message") }) + const config = ensureDefault({}, {}) + + // Act + const matcher = expect(async () => await verifyConditions(config, context)) + + // Assert + matcher.toThrowError("Failed to ensure git is spwanable by backmerge process.") + }) + + test("should fail with at least one invalid config field", () => { + // Arrange + spyOn(git, "version").mockImplementation(async () => "git version ") + spyOn(verify, "verifyConfig").mockImplementation(() => { throw new Error("an error message") }) + const config = ensureDefault({}, {}) + + // Act + const matcher = expect(async () => await verifyConditions(config, context)) + + // Assert + matcher.toThrowError("an error message") + }) + + test("should fail when platform handler cannot be created", () => { + // Arrange + spyOn(git, "version").mockImplementation(async () => "git version ") + spyOn(verify, "verifyConfig").mockImplementation(() => {}) + spyOn(platform, "newPlatformHandler").mockImplementation(() => { throw new Error("an error message") }) + const config = ensureDefault({}, {}) + + // Act + const matcher = expect(async () => await verifyConditions(config, context)) + + // Assert + matcher.toThrowError("an error message") + }) + + test("should return configuration and platform handler", async () => { + // Arrange + spyOn(git, "version").mockImplementation(async () => "git version ") + spyOn(verify, "verifyConfig").mockImplementation(() => {}) + spyOn(platform, "newPlatformHandler").mockImplementation(() => new TestPlatformHandler()) + const config = ensureDefault({}, {}) + + // Act + const [actual, handler] = await verifyConditions(config, context) + + // Assert + expect(actual).toEqual(config) + expect(handler).toEqual(new TestPlatformHandler()) + }) +}) + +describe("success", () => { + afterEach(() => mock.restore()) + + const context = getContext("main") + + test("should fail with invalid configuration", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async () => { throw new Error("an error message") }) + + const config = ensureDefault({}, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("an error message") + }) + + test("should not have any target configured", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(git, "fetch").mockImplementation(async () => { throw new Error("shouldn't be called") }) + + const config = ensureDefault({ targets: [{ from: "staging", to: "develop" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.not.toThrow() + }) + + test("should fail to fetch remote", async () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(git, "fetch").mockImplementation(async () => { throw new Error("an error message") }) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("Failed to fetch git remote or list all branches.") + }) + + test("should fail to list remote", async () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => { throw new Error("an error message") }) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("Failed to fetch git remote or list all branches.") + }) + + test("should not find released branch in remote branches", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => [{ hash: "", name: "staging" }]) + spyOn(git, "checkout").mockImplementation(async () => { throw new Error("an error message") }) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("Failed to retrieve released branch last commit hash. This shouldn't happen.") + }) + + test("should not have any mergeable remote branches", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(backmerge, "filter").mockImplementation(() => []) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => [{ hash: "", name: "main" }]) + spyOn(git, "checkout").mockImplementation(async () => { throw new Error("shouldn't be called") }) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.not.toThrow() + }) + + test("should fail to checkout released branch", async () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(backmerge, "filter").mockImplementation(() => [{ hash: "", name: "staging" }]) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => [{ hash: "", name: "main" }]) + spyOn(git, "checkout").mockImplementation(async () => { throw new Error("an error message") }) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("Failed to checkout released branch 'main'.") + }) + + test("should fail to backmerge", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(backmerge, "filter").mockImplementation(() => [{ hash: "", name: "staging" }]) + spyOn(backmerge, "backmerge").mockImplementation(async () => { throw new Error("an error message") }) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => [{ hash: "", name: "main" }]) + spyOn(git, "checkout").mockImplementation(async () => {}) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.toThrowError("an error message") + }) + + test("should perform backmerge", () => { + // Arrange + spyOn(index, "verifyConditions").mockImplementation(async (config: BackmergeConfig) => [config, new TestPlatformHandler()]) + spyOn(backmerge, "filter").mockImplementation(() => [{ hash: "", name: "staging" }]) + spyOn(backmerge, "backmerge").mockImplementation(async () => {}) + spyOn(git, "fetch").mockImplementation(async () => {}) + spyOn(git, "ls").mockImplementation(async () => [{ hash: "", name: "main" }]) + spyOn(git, "checkout").mockImplementation(async () => {}) + + const config = ensureDefault({ targets: [{ from: "main", to: "staging" }] }, {}) + + // Act + const matcher = expect(async () => await success(config, context)) + + // Assert + matcher.not.toThrow() + }) +}) \ No newline at end of file diff --git a/test/platform-handler.test.ts b/test/platform-handler.test.ts index b43735f..203d9cc 100644 --- a/test/platform-handler.test.ts +++ b/test/platform-handler.test.ts @@ -1,5 +1,5 @@ import { PlatformHandler, Pull, newPlatformHandler, token } from "../lib/platform-handler" -import { describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Platform } from "../lib/models/config" import { getConfigError } from "../lib/error" @@ -183,6 +183,8 @@ describe("token", () => { }) describe("hasPull", () => { + afterEach(() => mock.restore()) + const failure = (platform: Platform) => { // Arrange const handler = newPlatformHandler(platform, "baseURL", "prefix", "some-token", {}) @@ -376,6 +378,8 @@ describe("hasPull", () => { }) describe("createPull", () => { + afterEach(() => mock.restore()) + const failure = (platform: Platform) => { // Arrange const pull = { diff --git a/test/verify-config.test.ts b/test/verify-config.test.ts index 603a1e9..fcd7730 100644 --- a/test/verify-config.test.ts +++ b/test/verify-config.test.ts @@ -176,7 +176,7 @@ describe("verifyConfig", () => { const matcher = expect(() => verifyConfig(config)) // Assert - matcher.not.toThrowError() + matcher.not.toThrow() }) test("should be fine with some inputs", () => { @@ -196,6 +196,6 @@ describe("verifyConfig", () => { const matcher = expect(() => verifyConfig(config)) // Assert - matcher.not.toThrowError() + matcher.not.toThrow() }) }) \ No newline at end of file