From d9c2500ddea2a99c28e33aefd337382ccdbe69b2 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Thu, 1 Oct 2026 22:22:48 +0200 Subject: [PATCH 1/5] feat: dedicated error type for git.push fast-forward failure --- dist/cli/index.js | 46 ++++++++++++++++++++++++++++++-- dist/gha/index.js | 46 ++++++++++++++++++++++++++++++-- src/service/git/git-cli.ts | 19 +++++++++++-- test/service/git/git-cli.test.ts | 7 ++++- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/dist/cli/index.js b/dist/cli/index.js index 2f3e50c..243b72e 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -536,13 +536,46 @@ exports["default"] = PullRequestConfigsParser; "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NonFastForwardError = void 0; const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); -const simple_git_1 = __importDefault(__nccwpck_require__(9103)); +const simple_git_1 = __importStar(__nccwpck_require__(9103)); const fs_1 = __importDefault(__nccwpck_require__(7147)); +class NonFastForwardError extends Error { + cause; + constructor(cause) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.cause = cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} +exports.NonFastForwardError = NonFastForwardError; /** * Command line git commands executor service */ @@ -706,7 +739,16 @@ class GitCLIService { if (force) { options.push("--force-with-lease"); } - await this.git(cwd).push(remote, branch, options); + try { + await this.git(cwd).push(remote, branch, options); + } + catch (err) { + // hacky, but is there a better way to detect a non-fast-forward rejection? + if (err instanceof simple_git_1.GitError && err.message.includes("[rejected] (non-fast-forward)")) { + throw new NonFastForwardError(err); + } + throw err; + } } /** * pointToSameCommit checks if two refs point the same commit diff --git a/dist/gha/index.js b/dist/gha/index.js index 9503796..ef37469 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -498,13 +498,46 @@ exports["default"] = PullRequestConfigsParser; "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NonFastForwardError = void 0; const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); -const simple_git_1 = __importDefault(__nccwpck_require__(9103)); +const simple_git_1 = __importStar(__nccwpck_require__(9103)); const fs_1 = __importDefault(__nccwpck_require__(7147)); +class NonFastForwardError extends Error { + cause; + constructor(cause) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.cause = cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} +exports.NonFastForwardError = NonFastForwardError; /** * Command line git commands executor service */ @@ -668,7 +701,16 @@ class GitCLIService { if (force) { options.push("--force-with-lease"); } - await this.git(cwd).push(remote, branch, options); + try { + await this.git(cwd).push(remote, branch, options); + } + catch (err) { + // hacky, but is there a better way to detect a non-fast-forward rejection? + if (err instanceof simple_git_1.GitError && err.message.includes("[rejected] (non-fast-forward)")) { + throw new NonFastForwardError(err); + } + throw err; + } } /** * pointToSameCommit checks if two refs point the same commit diff --git a/src/service/git/git-cli.ts b/src/service/git/git-cli.ts index 96b4b4f..8b18dea 100644 --- a/src/service/git/git-cli.ts +++ b/src/service/git/git-cli.ts @@ -1,9 +1,16 @@ import LoggerService from "@bp/service/logger/logger-service"; import LoggerServiceFactory from "@bp/service/logger/logger-service-factory"; -import simpleGit, { SimpleGit } from "simple-git"; +import simpleGit, { GitError, SimpleGit } from "simple-git"; import fs from "fs"; import { LocalGit } from "@bp/service/configs/configs.types"; +export class NonFastForwardError extends Error { + constructor(public readonly cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** * Command line git commands executor service */ @@ -187,7 +194,15 @@ export default class GitCLIService { if (force) { options.push("--force-with-lease"); } - await this.git(cwd).push(remote, branch, options); + try { + await this.git(cwd).push(remote, branch, options); + } catch (err) { + // hacky, but is there a better way to detect a non-fast-forward rejection? + if (err instanceof GitError && err.message.includes("[rejected] (non-fast-forward)")) { + throw new NonFastForwardError(err); + } + throw err; + } } /** diff --git a/test/service/git/git-cli.test.ts b/test/service/git/git-cli.test.ts index a41b7fb..d5cd620 100644 --- a/test/service/git/git-cli.test.ts +++ b/test/service/git/git-cli.test.ts @@ -1,4 +1,4 @@ -import GitCLIService from "@bp/service/git/git-cli"; +import GitCLIService, { NonFastForwardError } from "@bp/service/git/git-cli"; import { FileState, GitActionTypes, MockGithub } from "@kie/mock-github"; import { spawnSync } from "child_process"; import { assert } from "console"; @@ -125,6 +125,11 @@ describe("git cli service", () => { expect(unchanged).toBe(true); }); + test("push diverting local branch throws a dedicated error", async () => { + spawnSync("git", ["switch", "sbranch"], { cwd }); + spawnSync("git", ["commit","--amend", "-m", "bla"], { cwd }); + expect(git.push(cwd, "sbranch", "origin", false)).rejects.toBeInstanceOf(NonFastForwardError); + }); test("git clone on already created repo", async () => { await git.clone("remote", cwd, "tbranch"); From 88db07f0db14863e100b8e7b197afc27260f93c9 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Thu, 1 Oct 2026 22:23:09 +0200 Subject: [PATCH 2/5] feat: git.hasRemoteOriginalWork git.hasLocalOriginalWork --- dist/cli/index.js | 33 ++++++++++++++++++++++++ dist/gha/index.js | 33 ++++++++++++++++++++++++ src/service/git/git-cli.ts | 44 ++++++++++++++++++++++++++++++++ test/service/git/git-cli.test.ts | 6 +++++ 4 files changed, 116 insertions(+) diff --git a/dist/cli/index.js b/dist/cli/index.js index 243b72e..964f10f 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -689,6 +689,39 @@ class GitCLIService { const output = await (0, simple_git_1.default)().raw(["ls-remote", "--heads", this.remoteWithAuth(remote), branch]); return output.trim().length > 0; } + /** + * Check if the remote branch contains commits without an equivalent in + * the local branch (i.e. someone added some work to the remote branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasRemoteOriginalWork(cwd, branch, remote = "origin") { + return this._isUpstreamMissingWork(cwd, `refs/heads/${branch}`, `refs/remotes/${remote}/${branch}`); + } + /** + * Check if the local branch contains commits without an equivalent in + * the remote branch (i.e. some new work has been added to the local branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasLocalOriginalWork(cwd, branch, remote = "origin") { + return this._isUpstreamMissingWork(cwd, `refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`); + } + /** + * Check if `upstream` is missing work present in `head`. + * @param cwd repository in which the local branch lives + * @param upstream refspec to compare + * @param head reference refspec + */ + async _isUpstreamMissingWork(cwd, upstream, head) { + const stdout = await this.git(cwd).raw(["cherry", upstream, head]); + // prefix: + // - means equivalent present in both refs + // + means missing from upstream + return stdout.split("\n").some(line => line.startsWith("+")); + } /** * Get cherry-pick a specific sha * @param cwd repository in which the sha should be cherry picked to diff --git a/dist/gha/index.js b/dist/gha/index.js index ef37469..9e75ff9 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -651,6 +651,39 @@ class GitCLIService { const output = await (0, simple_git_1.default)().raw(["ls-remote", "--heads", this.remoteWithAuth(remote), branch]); return output.trim().length > 0; } + /** + * Check if the remote branch contains commits without an equivalent in + * the local branch (i.e. someone added some work to the remote branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasRemoteOriginalWork(cwd, branch, remote = "origin") { + return this._isUpstreamMissingWork(cwd, `refs/heads/${branch}`, `refs/remotes/${remote}/${branch}`); + } + /** + * Check if the local branch contains commits without an equivalent in + * the remote branch (i.e. some new work has been added to the local branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasLocalOriginalWork(cwd, branch, remote = "origin") { + return this._isUpstreamMissingWork(cwd, `refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`); + } + /** + * Check if `upstream` is missing work present in `head`. + * @param cwd repository in which the local branch lives + * @param upstream refspec to compare + * @param head reference refspec + */ + async _isUpstreamMissingWork(cwd, upstream, head) { + const stdout = await this.git(cwd).raw(["cherry", upstream, head]); + // prefix: + // - means equivalent present in both refs + // + means missing from upstream + return stdout.split("\n").some(line => line.startsWith("+")); + } /** * Get cherry-pick a specific sha * @param cwd repository in which the sha should be cherry picked to diff --git a/src/service/git/git-cli.ts b/src/service/git/git-cli.ts index 8b18dea..6e424c3 100644 --- a/src/service/git/git-cli.ts +++ b/src/service/git/git-cli.ts @@ -140,6 +140,50 @@ export default class GitCLIService { return output.trim().length > 0; } + /** + * Check if the remote branch contains commits without an equivalent in + * the local branch (i.e. someone added some work to the remote branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasRemoteOriginalWork(cwd: string, branch: string, remote = "origin"): Promise { + return this._isUpstreamMissingWork( + cwd, + `refs/heads/${branch}`, + `refs/remotes/${remote}/${branch}`, + ); + } + + /** + * Check if the local branch contains commits without an equivalent in + * the remote branch (i.e. some new work has been added to the local branch) + * @param cwd repository in which the local branch lives + * @param branch branch name to search + * @param remote remote name or URL + */ + async hasLocalOriginalWork(cwd: string, branch: string, remote = "origin"): Promise { + return this._isUpstreamMissingWork( + cwd, + `refs/remotes/${remote}/${branch}`, + `refs/heads/${branch}`, + ); + } + + /** + * Check if `upstream` is missing work present in `head`. + * @param cwd repository in which the local branch lives + * @param upstream refspec to compare + * @param head reference refspec + */ + async _isUpstreamMissingWork(cwd: string, upstream: string, head: string): Promise { + const stdout = await this.git(cwd).raw(["cherry", upstream, head]); + // prefix: + // - means equivalent present in both refs + // + means missing from upstream + return stdout.split("\n").some(line => line.startsWith("+")); + } + /** * Get cherry-pick a specific sha * @param cwd repository in which the sha should be cherry picked to diff --git a/test/service/git/git-cli.test.ts b/test/service/git/git-cli.test.ts index d5cd620..631c97b 100644 --- a/test/service/git/git-cli.test.ts +++ b/test/service/git/git-cli.test.ts @@ -129,6 +129,12 @@ describe("git cli service", () => { spawnSync("git", ["switch", "sbranch"], { cwd }); spawnSync("git", ["commit","--amend", "-m", "bla"], { cwd }); expect(git.push(cwd, "sbranch", "origin", false)).rejects.toBeInstanceOf(NonFastForwardError); + const remoteOriginal = await git.hasRemoteOriginalWork(cwd, "sbranch", "origin"); + expect(remoteOriginal).toBe(false); + const localOriginal = await git.hasLocalOriginalWork(cwd, "sbranch", "origin"); + expect(localOriginal).toBe(false); + // force push now succeeds + await git.push(cwd, "sbranch", "origin", true); }); test("git clone on already created repo", async () => { From 68ecf3b17e27a75d70c48a9113023e1fb911bd02 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Thu, 1 Oct 2026 22:24:07 +0200 Subject: [PATCH 3/5] Revert "fix: don't backport if branch already exists (#231)" This reverts commit c59a6f79fd74e40e6d8d210d6bc10e2560ccc030. --- dist/cli/index.js | 19 ------------------- dist/gha/index.js | 19 ------------------- src/service/git/git-cli.ts | 11 ----------- src/service/runner/runner.ts | 12 +----------- 4 files changed, 1 insertion(+), 60 deletions(-) diff --git a/dist/cli/index.js b/dist/cli/index.js index 964f10f..3661495 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -679,16 +679,6 @@ class GitCLIService { this.logger.info(`Fetching ${remote} ${branch}`); await this.git(cwd).fetch(remote, branch, ["--quiet"]); } - /** - * Check if a branch exists in a remote repository. - * @param remote remote name or URL - * @param branch branch name to search - */ - async remoteBranchExists(remote, branch) { - this.logger.info(`Checking if branch ${branch} exists on ${remote}`); - const output = await (0, simple_git_1.default)().raw(["ls-remote", "--heads", this.remoteWithAuth(remote), branch]); - return output.trim().length > 0; - } /** * Check if the remote branch contains commits without an equivalent in * the local branch (i.e. someone added some work to the remote branch) @@ -1930,12 +1920,6 @@ class Runner { } } async executeBackport(configs, backportPR, git) { - const remote = backportPR.headRepo?.cloneUrl ?? backportPR.cloneUrl; - const branchExists = await git.gitCli.remoteBranchExists(remote, backportPR.head); - if (branchExists) { - this.logger.warn(`Backport branch ${backportPR.head} already exists on ${remote}, skipping`); - return; - } const notifyError = !configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0; if (notifyError && await this.failureAlreadyReported(configs, backportPR, git)) { this.logger.warn(`Backport to ${backportPR.base} already failed. Delete failure comment to retry. Skipping.`); @@ -2083,9 +2067,6 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, - async remoteBranchExists(_remote, _branch) { - return false; - }, async cherryPick(_cwd, sha, strategy = "recursive", strategyOption = "theirs", cherryPickOptions, emptyCommit = "stop") { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { diff --git a/dist/gha/index.js b/dist/gha/index.js index 9e75ff9..e22838b 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -641,16 +641,6 @@ class GitCLIService { this.logger.info(`Fetching ${remote} ${branch}`); await this.git(cwd).fetch(remote, branch, ["--quiet"]); } - /** - * Check if a branch exists in a remote repository. - * @param remote remote name or URL - * @param branch branch name to search - */ - async remoteBranchExists(remote, branch) { - this.logger.info(`Checking if branch ${branch} exists on ${remote}`); - const output = await (0, simple_git_1.default)().raw(["ls-remote", "--heads", this.remoteWithAuth(remote), branch]); - return output.trim().length > 0; - } /** * Check if the remote branch contains commits without an equivalent in * the local branch (i.e. someone added some work to the remote branch) @@ -1892,12 +1882,6 @@ class Runner { } } async executeBackport(configs, backportPR, git) { - const remote = backportPR.headRepo?.cloneUrl ?? backportPR.cloneUrl; - const branchExists = await git.gitCli.remoteBranchExists(remote, backportPR.head); - if (branchExists) { - this.logger.warn(`Backport branch ${backportPR.head} already exists on ${remote}, skipping`); - return; - } const notifyError = !configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0; if (notifyError && await this.failureAlreadyReported(configs, backportPR, git)) { this.logger.warn(`Backport to ${backportPR.base} already failed. Delete failure comment to retry. Skipping.`); @@ -2045,9 +2029,6 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, - async remoteBranchExists(_remote, _branch) { - return false; - }, async cherryPick(_cwd, sha, strategy = "recursive", strategyOption = "theirs", cherryPickOptions, emptyCommit = "stop") { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { diff --git a/src/service/git/git-cli.ts b/src/service/git/git-cli.ts index 6e424c3..1ac9cee 100644 --- a/src/service/git/git-cli.ts +++ b/src/service/git/git-cli.ts @@ -129,17 +129,6 @@ export default class GitCLIService { await this.git(cwd).fetch(remote, branch, ["--quiet"]); } - /** - * Check if a branch exists in a remote repository. - * @param remote remote name or URL - * @param branch branch name to search - */ - async remoteBranchExists(remote: string, branch: string): Promise { - this.logger.info(`Checking if branch ${branch} exists on ${remote}`); - const output = await simpleGit().raw(["ls-remote", "--heads", this.remoteWithAuth(remote), branch]); - return output.trim().length > 0; - } - /** * Check if the remote branch contains commits without an equivalent in * the local branch (i.e. someone added some work to the remote branch) diff --git a/src/service/runner/runner.ts b/src/service/runner/runner.ts index 295e8db..b6841d8 100644 --- a/src/service/runner/runner.ts +++ b/src/service/runner/runner.ts @@ -14,7 +14,7 @@ import { injectError, injectTargetBranch } from "./runner-util"; interface Git { gitClientType: GitClientType; gitClientApi: Pick; - gitCli: Pick; + gitCli: Pick; } /** @@ -142,13 +142,6 @@ export default class Runner { } async executeBackport(configs: Configs, backportPR: BackportPullRequest, git: Git): Promise { - const remote = backportPR.headRepo?.cloneUrl ?? backportPR.cloneUrl; - const branchExists = await git.gitCli.remoteBranchExists(remote, backportPR.head); - if (branchExists) { - this.logger.warn(`Backport branch ${backportPR.head} already exists on ${remote}, skipping`); - return; - } - const notifyError = !configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0; if (notifyError && await this.failureAlreadyReported(configs, backportPR, git)) { @@ -309,9 +302,6 @@ async function backportScript(configs: Configs, backportPR: BackportPullRequest, async fetch(_cwd: string, branch: string, remote = "origin"): Promise { s += `git fetch ${remote} ${branch}`; }, - async remoteBranchExists(_remote: string, _branch: string): Promise { - return false; - }, async cherryPick(_cwd: string, sha: string, strategy = "recursive", strategyOption = "theirs", cherryPickOptions: string | undefined, emptyCommit = "stop"): Promise { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { From 1d540b7c96c680bfbcc34051498066ad0c982c2b Mon Sep 17 00:00:00 2001 From: oliverpool Date: Thu, 1 Oct 2026 22:24:28 +0200 Subject: [PATCH 4/5] feat: force-push-with lease if the remote branch exists without original work --- dist/cli/index.js | 52 ++++++++++++++++++++++++++++++++++-- dist/gha/index.js | 52 ++++++++++++++++++++++++++++++++++-- src/service/runner/runner.ts | 32 ++++++++++++++++++++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/dist/cli/index.js b/dist/cli/index.js index 3661495..03c1c9b 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -1798,12 +1798,35 @@ exports.injectTargetBranch = injectTargetBranch; "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); const pr_configs_parser_1 = __importDefault(__nccwpck_require__(6618)); -const git_cli_1 = __importDefault(__nccwpck_require__(7538)); +const git_cli_1 = __importStar(__nccwpck_require__(7538)); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); const git_types_1 = __nccwpck_require__(750); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); @@ -2033,7 +2056,26 @@ function* backportSteps(logger, configs, backportPR, git) { if (!configs.dryRun) { // 10. push the new branch to origin yield async () => { - await git.gitCli.push(configs.folder, backportPR.head, target_remote); + try { + await git.gitCli.push(configs.folder, backportPR.head, target_remote); + } + catch (err) { + if (!(err instanceof git_cli_1.NonFastForwardError)) { + throw err; + } + logger.info("Non-fast-forward remote branch"); + // fetch the diverting remote + await git.gitCli.fetch(configs.folder, backportPR.head, target_remote); + if (await git.gitCli.hasRemoteOriginalWork(configs.folder, backportPR.head, target_remote)) { + logger.info("Remote branch contains original work, aborting"); + // throw original error: we cannot push in non-fast-forwarding way + throw err.cause; + } + if (await git.gitCli.hasLocalOriginalWork(configs.folder, backportPR.head, target_remote)) { + logger.info("Force-pushing rebased branch"); + await git.gitCli.push(configs.folder, backportPR.head, target_remote, true); + } + } }; // 11. create pull request new branch -> target branch (using octokit) yield async () => { @@ -2067,6 +2109,12 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, + async hasRemoteOriginalWork(_cwd, _branch, _remote = "origin") { + return false; + }, + async hasLocalOriginalWork(_cwd, _branch, _remote = "origin") { + return true; + }, async cherryPick(_cwd, sha, strategy = "recursive", strategyOption = "theirs", cherryPickOptions, emptyCommit = "stop") { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { diff --git a/dist/gha/index.js b/dist/gha/index.js index e22838b..6e6f1ba 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -1760,12 +1760,35 @@ exports.injectTargetBranch = injectTargetBranch; "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); const pr_configs_parser_1 = __importDefault(__nccwpck_require__(6618)); -const git_cli_1 = __importDefault(__nccwpck_require__(7538)); +const git_cli_1 = __importStar(__nccwpck_require__(7538)); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); const git_types_1 = __nccwpck_require__(750); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); @@ -1995,7 +2018,26 @@ function* backportSteps(logger, configs, backportPR, git) { if (!configs.dryRun) { // 10. push the new branch to origin yield async () => { - await git.gitCli.push(configs.folder, backportPR.head, target_remote); + try { + await git.gitCli.push(configs.folder, backportPR.head, target_remote); + } + catch (err) { + if (!(err instanceof git_cli_1.NonFastForwardError)) { + throw err; + } + logger.info("Non-fast-forward remote branch"); + // fetch the diverting remote + await git.gitCli.fetch(configs.folder, backportPR.head, target_remote); + if (await git.gitCli.hasRemoteOriginalWork(configs.folder, backportPR.head, target_remote)) { + logger.info("Remote branch contains original work, aborting"); + // throw original error: we cannot push in non-fast-forwarding way + throw err.cause; + } + if (await git.gitCli.hasLocalOriginalWork(configs.folder, backportPR.head, target_remote)) { + logger.info("Force-pushing rebased branch"); + await git.gitCli.push(configs.folder, backportPR.head, target_remote, true); + } + } }; // 11. create pull request new branch -> target branch (using octokit) yield async () => { @@ -2029,6 +2071,12 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, + async hasRemoteOriginalWork(_cwd, _branch, _remote = "origin") { + return false; + }, + async hasLocalOriginalWork(_cwd, _branch, _remote = "origin") { + return true; + }, async cherryPick(_cwd, sha, strategy = "recursive", strategyOption = "theirs", cherryPickOptions, emptyCommit = "stop") { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { diff --git a/src/service/runner/runner.ts b/src/service/runner/runner.ts index b6841d8..74adb2c 100644 --- a/src/service/runner/runner.ts +++ b/src/service/runner/runner.ts @@ -2,7 +2,7 @@ import ArgsParser from "@bp/service/args/args-parser"; import { Args } from "@bp/service/args/args.types"; import { Configs } from "@bp/service/configs/configs.types"; import PullRequestConfigsParser from "@bp/service/configs/pullrequest/pr-configs-parser"; -import GitCLIService from "@bp/service/git/git-cli"; +import GitCLIService, { NonFastForwardError } from "@bp/service/git/git-cli"; import GitClient from "@bp/service/git/git-client"; import GitClientFactory from "@bp/service/git/git-client-factory"; import { BackportPullRequest, GitClientType } from "@bp/service/git/git.types"; @@ -14,7 +14,7 @@ import { injectError, injectTargetBranch } from "./runner-util"; interface Git { gitClientType: GitClientType; gitClientApi: Pick; - gitCli: Pick; + gitCli: Pick; } /** @@ -267,7 +267,29 @@ function* backportSteps(logger: Pick, if (!configs.dryRun) { // 10. push the new branch to origin yield async () => { + try { await git.gitCli.push(configs.folder, backportPR.head, target_remote); + } catch (err) { + if (!(err instanceof NonFastForwardError)) { + throw err; + } + logger.info("Non-fast-forward remote branch"); + // fetch the diverting remote + await git.gitCli.fetch(configs.folder, backportPR.head, target_remote); + if ( + await git.gitCli.hasRemoteOriginalWork(configs.folder, backportPR.head, target_remote) + ) { + logger.info("Remote branch contains original work, aborting"); + // throw original error: we cannot push in non-fast-forwarding way + throw err.cause; + } + if ( + await git.gitCli.hasLocalOriginalWork(configs.folder, backportPR.head, target_remote) + ) { + logger.info("Force-pushing rebased branch"); + await git.gitCli.push(configs.folder, backportPR.head, target_remote, true); + } + } }; // 11. create pull request new branch -> target branch (using octokit) @@ -302,6 +324,12 @@ async function backportScript(configs: Configs, backportPR: BackportPullRequest, async fetch(_cwd: string, branch: string, remote = "origin"): Promise { s += `git fetch ${remote} ${branch}`; }, + async hasRemoteOriginalWork(_cwd: string, _branch: string, _remote = "origin"): Promise { + return false; + }, + async hasLocalOriginalWork(_cwd: string, _branch: string, _remote = "origin"): Promise { + return true; + }, async cherryPick(_cwd: string, sha: string, strategy = "recursive", strategyOption = "theirs", cherryPickOptions: string | undefined, emptyCommit = "stop"): Promise { s += `git cherry-pick -m 1 --strategy=${strategy} --strategy-option=${strategyOption} --empty=${emptyCommit} `; if (cherryPickOptions !== undefined) { From 4190247c8ad24731337141484a0703ceacb0f29f Mon Sep 17 00:00:00 2001 From: oliverpool Date: Thu, 1 Oct 2026 22:24:50 +0200 Subject: [PATCH 5/5] feat: ignore existing pull request the content will have been updated by the force-push --- dist/cli/index.js | 192 ++++++++++++------ dist/gha/index.js | 192 ++++++++++++------ src/service/git/git-client.ts | 11 + src/service/git/github/github-client.ts | 53 +++-- src/service/runner/runner.ts | 17 +- .../runner/cli-codeberg-runner.test.ts | 55 ++++- 6 files changed, 382 insertions(+), 138 deletions(-) diff --git a/dist/cli/index.js b/dist/cli/index.js index 03c1c9b..2288fde 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -849,6 +849,31 @@ class GitClientFactory { exports["default"] = GitClientFactory; +/***/ }), + +/***/ 8127: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PullRequestExists = void 0; +class PullRequestExists extends Error { + url; + constructor(url) { + let msg = "Pull request already exists"; + if (url) { + msg += ": " + url; + } + super(msg); + this.url = url; + Object.setPrototypeOf(this, new.target.prototype); + } +} +exports.PullRequestExists = PullRequestExists; +; + + /***/ }), /***/ 9080: @@ -1010,11 +1035,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +const git_client_1 = __nccwpck_require__(8127); const git_util_1 = __nccwpck_require__(9080); const git_types_1 = __nccwpck_require__(750); const github_mapper_1 = __importDefault(__nccwpck_require__(5764)); const octokit_factory_1 = __importDefault(__nccwpck_require__(4257)); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); +const request_error_1 = __nccwpck_require__(6999); class GitHubClient { logger; apiUrl; @@ -1171,16 +1198,37 @@ class GitHubClient { async createPullRequest(backport) { this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}`); this.logger.info(`${JSON.stringify(backport, null, 2)}`); - const { data } = await this.octokit.pulls.create({ - owner: backport.owner, - repo: backport.repo, - head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, - ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), - base: backport.base, - title: backport.title, - body: backport.body, - maintainer_can_modify: true, - }); + let data; + try { + const resp = await this.octokit.pulls.create({ + owner: backport.owner, + repo: backport.repo, + head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, + ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), + base: backport.base, + title: backport.title, + body: backport.body, + maintainer_can_modify: true, + }); + data = resp.data; + } + catch (err) { + if (err instanceof request_error_1.RequestError) { + if (this.compatibleForge && err.status === 409) { + // Forgejo returns unstructured message: + // pull request already exists for these targets [id: .., issue_id: .., head_repo_id: .., base_repo_id: .., head_branch: bp-..., base_branch: ...] + this.logger.warn(err.message); + throw new git_client_1.PullRequestExists(); + } + if (!this.compatibleForge && err.status === 422) { + // GitHub returns a structured message without the URL + // {"resource":"PullRequest","code":"custom","message":"A pull request already exists for lampajr:bp-prod-6353aff."} + this.logger.warn(err.message); + throw new git_client_1.PullRequestExists(); + } + } + throw err; + } if (!data) { throw new Error("Pull request creation failed"); } @@ -1337,7 +1385,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); -const rest_1 = __nccwpck_require__(184); +const rest_1 = __nccwpck_require__(1942); /** * Singleton factory class for {Octokit} instance */ @@ -1827,6 +1875,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); const pr_configs_parser_1 = __importDefault(__nccwpck_require__(6618)); const git_cli_1 = __importStar(__nccwpck_require__(7538)); +const git_client_1 = __nccwpck_require__(8127); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); const git_types_1 = __nccwpck_require__(750); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); @@ -2079,8 +2128,21 @@ function* backportSteps(logger, configs, backportPR, git) { }; // 11. create pull request new branch -> target branch (using octokit) yield async () => { - const prUrl = await git.gitClientApi.createPullRequest(backportPR); - logger.info(`Pull request created: ${prUrl}`); + try { + const prUrl = await git.gitClientApi.createPullRequest(backportPR); + logger.info(`Pull request created: ${prUrl}`); + } + catch (err) { + if (!(err instanceof git_client_1.PullRequestExists)) { + throw err; + } + if (err.url) { + logger.info(`Pull request updated: ${err.url}`); + } + else { + logger.info("The existing pull request has been updated"); + } + } }; } else { @@ -22689,7 +22751,58 @@ module.exports = axios; /***/ }), -/***/ 184: +/***/ 6999: +/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __nccwpck_require__) => { + +"use strict"; +__nccwpck_require__.r(__webpack_exports__); +/* harmony export */ __nccwpck_require__.d(__webpack_exports__, { +/* harmony export */ "RequestError": () => (/* binding */ RequestError) +/* harmony export */ }); +class RequestError extends Error { + name; + /** + * http status code + */ + status; + /** + * Request options that lead to the error. + */ + request; + /** + * Response object if a response was received + */ + response; + constructor(message, statusCode, options) { + super(message, { cause: options.cause }); + this.name = "HttpError"; + this.status = Number.parseInt(statusCode); + if (Number.isNaN(this.status)) { + this.status = 0; + } + /* v8 ignore else -- @preserve -- Bug with vitest coverage where it sees an else branch that doesn't exist */ + if ("response" in options) { + this.response = options.response; + } + const requestCopy = Object.assign({}, options.request); + if (options.request.headers.authorization) { + requestCopy.headers = Object.assign({}, options.request.headers, { + authorization: options.request.headers.authorization.replace( + /(? { "use strict"; @@ -23239,47 +23352,8 @@ function node_modules_universal_user_agent_getUserAgent() { // EXTERNAL MODULE: ./node_modules/fast-content-type-parse/index.js var fast_content_type_parse = __nccwpck_require__(7263); -;// CONCATENATED MODULE: ./node_modules/@octokit/request-error/dist-src/index.js -class RequestError extends Error { - name; - /** - * http status code - */ - status; - /** - * Request options that lead to the error. - */ - request; - /** - * Response object if a response was received - */ - response; - constructor(message, statusCode, options) { - super(message, { cause: options.cause }); - this.name = "HttpError"; - this.status = Number.parseInt(statusCode); - if (Number.isNaN(this.status)) { - this.status = 0; - } - /* v8 ignore else -- @preserve -- Bug with vitest coverage where it sees an else branch that doesn't exist */ - if ("response" in options) { - this.response = options.response; - } - const requestCopy = Object.assign({}, options.request); - if (options.request.headers.authorization) { - requestCopy.headers = Object.assign({}, options.request.headers, { - authorization: options.request.headers.authorization.replace( - /(?= 400) { octokitResponse.data = await getResponseData(fetchResponse); - throw new RequestError(toErrorMessage(octokitResponse.data), status, { + throw new dist_src.RequestError(toErrorMessage(octokitResponse.data), status, { response: octokitResponse, request: requestOptions }); diff --git a/dist/gha/index.js b/dist/gha/index.js index 6e6f1ba..d3c337f 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -811,6 +811,31 @@ class GitClientFactory { exports["default"] = GitClientFactory; +/***/ }), + +/***/ 8127: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PullRequestExists = void 0; +class PullRequestExists extends Error { + url; + constructor(url) { + let msg = "Pull request already exists"; + if (url) { + msg += ": " + url; + } + super(msg); + this.url = url; + Object.setPrototypeOf(this, new.target.prototype); + } +} +exports.PullRequestExists = PullRequestExists; +; + + /***/ }), /***/ 9080: @@ -972,11 +997,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +const git_client_1 = __nccwpck_require__(8127); const git_util_1 = __nccwpck_require__(9080); const git_types_1 = __nccwpck_require__(750); const github_mapper_1 = __importDefault(__nccwpck_require__(5764)); const octokit_factory_1 = __importDefault(__nccwpck_require__(4257)); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); +const request_error_1 = __nccwpck_require__(6999); class GitHubClient { logger; apiUrl; @@ -1133,16 +1160,37 @@ class GitHubClient { async createPullRequest(backport) { this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}`); this.logger.info(`${JSON.stringify(backport, null, 2)}`); - const { data } = await this.octokit.pulls.create({ - owner: backport.owner, - repo: backport.repo, - head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, - ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), - base: backport.base, - title: backport.title, - body: backport.body, - maintainer_can_modify: true, - }); + let data; + try { + const resp = await this.octokit.pulls.create({ + owner: backport.owner, + repo: backport.repo, + head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, + ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), + base: backport.base, + title: backport.title, + body: backport.body, + maintainer_can_modify: true, + }); + data = resp.data; + } + catch (err) { + if (err instanceof request_error_1.RequestError) { + if (this.compatibleForge && err.status === 409) { + // Forgejo returns unstructured message: + // pull request already exists for these targets [id: .., issue_id: .., head_repo_id: .., base_repo_id: .., head_branch: bp-..., base_branch: ...] + this.logger.warn(err.message); + throw new git_client_1.PullRequestExists(); + } + if (!this.compatibleForge && err.status === 422) { + // GitHub returns a structured message without the URL + // {"resource":"PullRequest","code":"custom","message":"A pull request already exists for lampajr:bp-prod-6353aff."} + this.logger.warn(err.message); + throw new git_client_1.PullRequestExists(); + } + } + throw err; + } if (!data) { throw new Error("Pull request creation failed"); } @@ -1299,7 +1347,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); -const rest_1 = __nccwpck_require__(184); +const rest_1 = __nccwpck_require__(1942); /** * Singleton factory class for {Octokit} instance */ @@ -1789,6 +1837,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); const pr_configs_parser_1 = __importDefault(__nccwpck_require__(6618)); const git_cli_1 = __importStar(__nccwpck_require__(7538)); +const git_client_1 = __nccwpck_require__(8127); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); const git_types_1 = __nccwpck_require__(750); const logger_service_factory_1 = __importDefault(__nccwpck_require__(8936)); @@ -2041,8 +2090,21 @@ function* backportSteps(logger, configs, backportPR, git) { }; // 11. create pull request new branch -> target branch (using octokit) yield async () => { - const prUrl = await git.gitClientApi.createPullRequest(backportPR); - logger.info(`Pull request created: ${prUrl}`); + try { + const prUrl = await git.gitClientApi.createPullRequest(backportPR); + logger.info(`Pull request created: ${prUrl}`); + } + catch (err) { + if (!(err instanceof git_client_1.PullRequestExists)) { + throw err; + } + if (err.url) { + logger.info(`Pull request updated: ${err.url}`); + } + else { + logger.info("The existing pull request has been updated"); + } + } }; } else { @@ -22792,7 +22854,58 @@ module.exports = axios; /***/ }), -/***/ 184: +/***/ 6999: +/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __nccwpck_require__) => { + +"use strict"; +__nccwpck_require__.r(__webpack_exports__); +/* harmony export */ __nccwpck_require__.d(__webpack_exports__, { +/* harmony export */ "RequestError": () => (/* binding */ RequestError) +/* harmony export */ }); +class RequestError extends Error { + name; + /** + * http status code + */ + status; + /** + * Request options that lead to the error. + */ + request; + /** + * Response object if a response was received + */ + response; + constructor(message, statusCode, options) { + super(message, { cause: options.cause }); + this.name = "HttpError"; + this.status = Number.parseInt(statusCode); + if (Number.isNaN(this.status)) { + this.status = 0; + } + /* v8 ignore else -- @preserve -- Bug with vitest coverage where it sees an else branch that doesn't exist */ + if ("response" in options) { + this.response = options.response; + } + const requestCopy = Object.assign({}, options.request); + if (options.request.headers.authorization) { + requestCopy.headers = Object.assign({}, options.request.headers, { + authorization: options.request.headers.authorization.replace( + /(? { "use strict"; @@ -23342,47 +23455,8 @@ function node_modules_universal_user_agent_getUserAgent() { // EXTERNAL MODULE: ./node_modules/fast-content-type-parse/index.js var fast_content_type_parse = __nccwpck_require__(7263); -;// CONCATENATED MODULE: ./node_modules/@octokit/request-error/dist-src/index.js -class RequestError extends Error { - name; - /** - * http status code - */ - status; - /** - * Request options that lead to the error. - */ - request; - /** - * Response object if a response was received - */ - response; - constructor(message, statusCode, options) { - super(message, { cause: options.cause }); - this.name = "HttpError"; - this.status = Number.parseInt(statusCode); - if (Number.isNaN(this.status)) { - this.status = 0; - } - /* v8 ignore else -- @preserve -- Bug with vitest coverage where it sees an else branch that doesn't exist */ - if ("response" in options) { - this.response = options.response; - } - const requestCopy = Object.assign({}, options.request); - if (options.request.headers.authorization) { - requestCopy.headers = Object.assign({}, options.request.headers, { - authorization: options.request.headers.authorization.replace( - /(?= 400) { octokitResponse.data = await getResponseData(fetchResponse); - throw new RequestError(toErrorMessage(octokitResponse.data), status, { + throw new dist_src.RequestError(toErrorMessage(octokitResponse.data), status, { response: octokitResponse, request: requestOptions }); diff --git a/src/service/git/git-client.ts b/src/service/git/git-client.ts index 6ee7d52..b01913d 100644 --- a/src/service/git/git-client.ts +++ b/src/service/git/git-client.ts @@ -1,5 +1,16 @@ import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/git/git.types"; +export class PullRequestExists extends Error { + constructor(public readonly url?: string) { + let msg = "Pull request already exists"; + if (url) { + msg += ": " + url; + } + super(msg); + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** * Git management service interface, which provides a common API for interacting * with several git management services like GitHub, Gitlab or Bitbucket. diff --git a/src/service/git/github/github-client.ts b/src/service/git/github/github-client.ts index 0bfea64..cafd391 100644 --- a/src/service/git/github/github-client.ts +++ b/src/service/git/github/github-client.ts @@ -1,4 +1,4 @@ -import GitClient from "@bp/service/git/git-client"; +import GitClient, { PullRequestExists } from "@bp/service/git/git-client"; import { inferSquash } from "@bp/service/git/git-util"; import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/git/git.types"; import GitHubMapper from "@bp/service/git/github/github-mapper"; @@ -6,6 +6,7 @@ import OctokitFactory from "@bp/service/git/github/octokit-factory"; import LoggerService from "@bp/service/logger/logger-service"; import LoggerServiceFactory from "@bp/service/logger/logger-service-factory"; import { Octokit } from "@octokit/rest"; +import { RequestError } from "@octokit/request-error"; import { PullRequest } from "@octokit/webhooks-types"; export default class GitHubClient implements GitClient { @@ -192,16 +193,36 @@ export default class GitHubClient implements GitClient { this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}`); this.logger.info(`${JSON.stringify(backport, null, 2)}`); - const { data } = await this.octokit.pulls.create({ - owner: backport.owner, - repo: backport.repo, - head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, - ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), - base: backport.base, - title: backport.title, - body: backport.body, - maintainer_can_modify: true, - }); + let data: PullRequest; + try { + const resp = await this.octokit.pulls.create({ + owner: backport.owner, + repo: backport.repo, + head: backport.headRepo ? `${backport.headRepo.owner}:${backport.head}` : backport.head, + ...(backport.headRepo ? { head_repo: backport.headRepo.project } : {}), + base: backport.base, + title: backport.title, + body: backport.body, + maintainer_can_modify: true, + }); + data = resp.data as PullRequest; + } catch (err) { + if (err instanceof RequestError) { + if (this.compatibleForge && err.status === 409) { + // Forgejo returns unstructured message: + // pull request already exists for these targets [id: .., issue_id: .., head_repo_id: .., base_repo_id: .., head_branch: bp-..., base_branch: ...] + this.logger.warn(err.message); + throw new PullRequestExists(); + } + if (!this.compatibleForge && err.status === 422) { + // GitHub returns a structured message without the URL + // {"resource":"PullRequest","code":"custom","message":"A pull request already exists for lampajr:bp-prod-6353aff."} + this.logger.warn(err.message); + throw new PullRequestExists(); + } + } + throw err; + } if (!data) { throw new Error("Pull request creation failed"); @@ -214,7 +235,7 @@ export default class GitHubClient implements GitClient { this.octokit.issues.addLabels({ owner: backport.owner, repo: backport.repo, - issue_number: (data as PullRequest).number, + issue_number: data.number, labels: backport.labels, }).catch(error => this.logger.error(`Error setting labels: ${error}`)) ); @@ -225,7 +246,7 @@ export default class GitHubClient implements GitClient { this.octokit.pulls.requestReviewers({ owner: backport.owner, repo: backport.repo, - pull_number: (data as PullRequest).number, + pull_number: data.number, reviewers: backport.reviewers, }).catch(error => this.logger.error(`Error requesting reviewers: ${error}`)) ); @@ -236,7 +257,7 @@ export default class GitHubClient implements GitClient { this.octokit.issues.addAssignees({ owner: backport.owner, repo: backport.repo, - issue_number: (data as PullRequest).number, + issue_number: data.number, assignees: backport.assignees, }).catch(error => this.logger.error(`Error setting assignees: ${error}`)) ); @@ -248,7 +269,7 @@ export default class GitHubClient implements GitClient { this.octokit.issues.createComment({ owner: backport.owner, repo: backport.repo, - issue_number: (data as PullRequest).number, + issue_number: data.number, body: c, }).catch(error => this.logger.error(`Error posting comment: ${error}`)) ); @@ -298,4 +319,4 @@ export default class GitHubClient implements GitClient { id: parseInt(prUrl.substring(prUrl.lastIndexOf("/") + 1, prUrl.length)), }; } -} \ No newline at end of file +} diff --git a/src/service/runner/runner.ts b/src/service/runner/runner.ts index 74adb2c..4ef4ea0 100644 --- a/src/service/runner/runner.ts +++ b/src/service/runner/runner.ts @@ -3,7 +3,7 @@ import { Args } from "@bp/service/args/args.types"; import { Configs } from "@bp/service/configs/configs.types"; import PullRequestConfigsParser from "@bp/service/configs/pullrequest/pr-configs-parser"; import GitCLIService, { NonFastForwardError } from "@bp/service/git/git-cli"; -import GitClient from "@bp/service/git/git-client"; +import GitClient, { PullRequestExists } from "@bp/service/git/git-client"; import GitClientFactory from "@bp/service/git/git-client-factory"; import { BackportPullRequest, GitClientType } from "@bp/service/git/git.types"; import LoggerService from "@bp/service/logger/logger-service"; @@ -294,8 +294,19 @@ function* backportSteps(logger: Pick, // 11. create pull request new branch -> target branch (using octokit) yield async () => { - const prUrl = await git.gitClientApi.createPullRequest(backportPR); - logger.info(`Pull request created: ${prUrl}`); + try{ + const prUrl = await git.gitClientApi.createPullRequest(backportPR); + logger.info(`Pull request created: ${prUrl}`); + } catch (err) { + if (!(err instanceof PullRequestExists)) { + throw err; + } + if (err.url){ + logger.info(`Pull request updated: ${err.url}`); + } else { + logger.info("The existing pull request has been updated"); + } + } }; } else { yield async () => { diff --git a/test/service/runner/cli-codeberg-runner.test.ts b/test/service/runner/cli-codeberg-runner.test.ts index 35a1e50..5b59320 100644 --- a/test/service/runner/cli-codeberg-runner.test.ts +++ b/test/service/runner/cli-codeberg-runner.test.ts @@ -1,6 +1,6 @@ import ArgsParser from "@bp/service/args/args-parser"; import Runner from "@bp/service/runner/runner"; -import GitCLIService from "@bp/service/git/git-cli"; +import GitCLIService, { NonFastForwardError } from "@bp/service/git/git-cli"; import GitHubClient from "@bp/service/git/github/github-client"; import CLIArgsParser from "@bp/service/args/cli/cli-args-parser"; import { addProcessArgs, createTestFile, removeTestFile, resetEnvTokens, resetProcessArgs } from "../../support/utils"; @@ -8,6 +8,7 @@ import { mockCodebergClient } from "../../support/mock/git-client-mock-support"; import GitClientFactory from "@bp/service/git/git-client-factory"; import { BackportPullRequest, GitClientType } from "@bp/service/git/git.types"; import { AuthTokenId } from "@bp/service/configs/configs.types"; +import { PullRequestExists } from "@bp/service/git/git-client"; const GITHUB_MERGED_PR_W_OVERRIDES_CONFIG_FILE_CONTENT_PATHNAME = "./cli-codeberg-runner-pr-merged-with-overrides.json"; const GITHUB_MERGED_PR_W_OVERRIDES_CONFIG_FILE_CONTENT = { @@ -1459,4 +1460,56 @@ The backport to ${"`v3`"} will not be retried until this comment is deleted. cherryPickSpy.mockReset(); }); + + test("with existing branch and pull request", async () => { + const cherryPickSpy = jest.spyOn(GitCLIService.prototype, "push").mockImplementation(async (_cwd: string, _branch: string, _remote = "origin", force = false) => { + if (!force) { + throw new NonFastForwardError("existing branch"); + } + }); + const localWorkSpy = jest.spyOn(GitCLIService.prototype, "hasLocalOriginalWork").mockImplementation(async (_cwd: string, _branch: string, _remote = "origin") => { + return true; + }); + const createPullRequestSpy = jest.spyOn(GitHubClient.prototype, "createPullRequest").mockImplementation(async (_backport: BackportPullRequest) => { + throw new PullRequestExists(); + }); + + addProcessArgs([ + "-tb", + "v1", + "-pr", + "https://codeberg.org/owner/reponame/pulls/2368", + "-f", + "/tmp/folder", + "--bp-branch-name", + "custom-failure-head", + ]); + + await runner.execute(); + + const cwd = "/tmp/folder"; + + expect(GitClientFactory.getOrCreate).toHaveBeenCalledTimes(1); + expect(GitClientFactory.getOrCreate).toHaveBeenCalledWith(GitClientType.CODEBERG, undefined, "https://codeberg.org/api/v1"); + + expect(GitCLIService.prototype.clone).toHaveBeenCalledTimes(1); + expect(GitCLIService.prototype.clone).toHaveBeenCalledWith("https://codeberg.org/owner/reponame.git", cwd, "v1"); + + expect(GitCLIService.prototype.createLocalBranch).toHaveBeenCalledTimes(1); + expect(GitCLIService.prototype.createLocalBranch).toHaveBeenCalledWith(cwd, "custom-failure-head"); + + expect(GitCLIService.prototype.fetch).toHaveBeenCalledTimes(2); + expect(GitCLIService.prototype.fetch).toHaveBeenCalledWith(cwd, "pull/2368/head:pr/2368"); + + expect(GitCLIService.prototype.cherryPick).toHaveBeenCalledTimes(1); + + expect(GitCLIService.prototype.push).toHaveBeenCalledTimes(2); + + expect(GitHubClient.prototype.createPullRequest).toHaveBeenCalledTimes(1); + expect(GitHubClient.prototype.createPullRequestComment).toHaveBeenCalledTimes(0); + + cherryPickSpy.mockReset(); + localWorkSpy.mockReset(); + createPullRequestSpy.mockReset(); + }); });