diff --git a/dist/cli/index.js b/dist/cli/index.js index 2f3e50c..2288fde 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 */ @@ -647,14 +680,37 @@ class GitCLIService { await this.git(cwd).fetch(remote, branch, ["--quiet"]); } /** - * Check if a branch exists in a remote repository. + * 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 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; + 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 @@ -706,7 +762,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 @@ -784,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: @@ -945,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; @@ -1106,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"); } @@ -1272,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 */ @@ -1733,12 +1846,36 @@ 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_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)); @@ -1855,12 +1992,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.`); @@ -1974,12 +2105,44 @@ 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 () => { - 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 { @@ -2008,9 +2171,12 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, - async remoteBranchExists(_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) { @@ -22585,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"; @@ -23135,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 9503796..d3c337f 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 */ @@ -609,14 +642,37 @@ class GitCLIService { await this.git(cwd).fetch(remote, branch, ["--quiet"]); } /** - * Check if a branch exists in a remote repository. + * 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 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; + 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 @@ -668,7 +724,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 @@ -746,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: @@ -907,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; @@ -1068,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"); } @@ -1234,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 */ @@ -1695,12 +1808,36 @@ 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_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)); @@ -1817,12 +1954,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.`); @@ -1936,12 +2067,44 @@ 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 () => { - 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 { @@ -1970,9 +2133,12 @@ async function backportScript(configs, backportPR, git, failed) { async fetch(_cwd, branch, remote = "origin") { s += `git fetch ${remote} ${branch}`; }, - async remoteBranchExists(_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) { @@ -22688,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"; @@ -23238,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-cli.ts b/src/service/git/git-cli.ts index 96b4b4f..1ac9cee 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 */ @@ -123,14 +130,47 @@ export default class GitCLIService { } /** - * Check if a branch exists in a remote repository. + * 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 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; + 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("+")); } /** @@ -187,7 +227,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/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 295e8db..4ef4ea0 100644 --- a/src/service/runner/runner.ts +++ b/src/service/runner/runner.ts @@ -2,8 +2,8 @@ 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 GitClient from "@bp/service/git/git-client"; +import GitCLIService, { NonFastForwardError } from "@bp/service/git/git-cli"; +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"; @@ -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)) { @@ -274,13 +267,46 @@ 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) 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 () => { @@ -309,9 +335,12 @@ 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 { + 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) { diff --git a/test/service/git/git-cli.test.ts b/test/service/git/git-cli.test.ts index a41b7fb..631c97b 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,17 @@ 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); + 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 () => { await git.clone("remote", cwd, "tbranch"); 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(); + }); });