From 2bb7f731127e099d1f196e6785e992589f7c4940 Mon Sep 17 00:00:00 2001 From: Andrea Lamparelli Date: Wed, 10 Apr 2024 23:01:16 +0200 Subject: [PATCH] feat: implement error notification as pr comment (#124) * feat: implement error notification as pr comment * Update action.yml Co-authored-by: Earl Warren <109468362+earl-warren@users.noreply.github.com> * feat: implement gitlab client and surround with try catch * docs: add error notification enablment in the doc * feat: disable comment if dry-run * feat: update the default comment on error --------- Co-authored-by: Earl Warren <109468362+earl-warren@users.noreply.github.com> --- README.md | 1 + action.yml | 5 + dist/cli/index.js | 114 ++++++++++++-- dist/gha/index.js | 119 +++++++++++++-- src/service/args/args-parser.ts | 3 +- src/service/args/args-utils.ts | 2 +- src/service/args/args.types.ts | 1 + src/service/args/cli/cli-args-parser.ts | 6 +- src/service/args/gha/gha-args-parser.ts | 13 +- src/service/configs/configs.types.ts | 9 ++ .../configs/pullrequest/pr-configs-parser.ts | 13 +- src/service/git/git-client.ts | 7 + src/service/git/git.types.ts | 2 +- src/service/git/github/github-client.ts | 23 +++ src/service/git/gitlab/gitlab-client.ts | 23 +++ src/service/logger/console-logger-service.ts | 4 + src/service/logger/logger-service.ts | 2 + src/service/runner/runner-util.ts | 22 +++ src/service/runner/runner.ts | 12 +- test/service/args/cli/cli-args-parser.test.ts | 17 +++ test/service/args/gha/gha-args-parser.test.ts | 12 +- .../github-pr-configs-parser.test.ts | 26 ++++ .../gitlab-pr-configs-parser.test.ts | 26 ++++ test/service/runner/cli-github-runner.test.ts | 144 +++++++++++++++++- test/service/runner/cli-gitlab-runner.test.ts | 4 + test/service/runner/gha-github-runner.test.ts | 2 + test/service/runner/gha-gitlab-runner.test.ts | 2 + test/service/runner/runner-util.test.ts | 19 +++ 28 files changed, 594 insertions(+), 39 deletions(-) create mode 100644 src/service/runner/runner-util.ts create mode 100644 test/service/runner/runner-util.test.ts diff --git a/README.md b/README.md index d2ce79b..cdf9c1a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ This tool comes with some inputs that allow users to override the default behavi | Strategy Option | --strategy-option | N | Cherry pick merging strategy option, see [git-merge](https://git-scm.com/docs/git-merge#_merge_strategies) doc for all possible values | "theirs" | | Cherry-pick Options | --cherry-pick-options | N | Additional cherry-pick options, see [git-cherry-pick](https://git-scm.com/docs/git-cherry-pick) doc for all possible values | "theirs" | | Additional comments | --comments | N | Semicolon separated list of additional comments to be posted to the backported pull request | [] | +| Enable error notification | --enable-err-notification | N | If true, enable the error notification as comment on the original pull request | false | | Dry Run | -d, --dry-run | N | If enabled the tool does not push nor create anything remotely, use this to skip PR creation | false | > **NOTE**: `pull request` and (`target branch` or `target branch pattern`) are *mandatory*, they must be provided as CLI options or as part of the configuration file (if used). diff --git a/action.yml b/action.yml index fc166c9..d468e9e 100644 --- a/action.yml +++ b/action.yml @@ -109,6 +109,11 @@ inputs: description: > Semicolon separated list of additional comments to be posted to the backported pull request required: false + enable-err-notification: + description: > + If true, enable the error notification as comment on the original pull request + required: false + default: "false" runs: using: node20 diff --git a/dist/cli/index.js b/dist/cli/index.js index bd839f1..c9ab6b7 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -70,7 +70,8 @@ class ArgsParser { strategy: this.getOrDefault(args.strategy), strategyOption: this.getOrDefault(args.strategyOption), cherryPickOptions: this.getOrDefault(args.cherryPickOptions), - comments: this.getOrDefault(args.comments) + comments: this.getOrDefault(args.comments), + enableErrorNotification: this.getOrDefault(args.enableErrorNotification, false), }; } } @@ -108,7 +109,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getAsBooleanOrDefault = exports.getAsSemicolonSeparatedList = exports.getAsCommaSeparatedList = exports.getAsCleanedCommaSeparatedList = exports.getOrUndefined = exports.readConfigFile = exports.parseArgs = void 0; +exports.getAsBooleanOrUndefined = exports.getAsSemicolonSeparatedList = exports.getAsCommaSeparatedList = exports.getAsCleanedCommaSeparatedList = exports.getOrUndefined = exports.readConfigFile = exports.parseArgs = void 0; const fs = __importStar(__nccwpck_require__(7147)); /** * Parse the input configuation string as json object and @@ -159,11 +160,11 @@ function getAsSemicolonSeparatedList(value) { return trimmed !== "" ? trimmed.split(";").map(v => v.trim()) : undefined; } exports.getAsSemicolonSeparatedList = getAsSemicolonSeparatedList; -function getAsBooleanOrDefault(value) { +function getAsBooleanOrUndefined(value) { const trimmed = value.trim(); return trimmed !== "" ? trimmed.toLowerCase() === "true" : undefined; } -exports.getAsBooleanOrDefault = getAsBooleanOrDefault; +exports.getAsBooleanOrUndefined = getAsBooleanOrUndefined; /***/ }), @@ -204,12 +205,13 @@ class CLIArgsParser extends args_parser_1.default { .option("--no-inherit-reviewers", "if provided and reviewers option is empty then inherit them from original pull request") .option("--labels ", "comma separated list of labels to be assigned to the backported pull request", args_utils_1.getAsCommaSeparatedList) .option("--inherit-labels", "if true the backported pull request will inherit labels from the original one") - .option("--no-squash", "Backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch") - .option("--auto-no-squash", "If the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.") + .option("--no-squash", "backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch") + .option("--auto-no-squash", "if the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.") .option("--strategy ", "cherry-pick merge strategy, default to 'recursive'", undefined) .option("--strategy-option ", "cherry-pick merge strategy option, default to 'theirs'") .option("--cherry-pick-options ", "additional cherry-pick options") .option("--comments ", "semicolon separated list of additional comments to be posted to the backported pull request", args_utils_1.getAsSemicolonSeparatedList) + .option("--enable-err-notification", "if true, enable the error notification as comment on the original pull request") .option("-cf, --config-file ", "configuration file containing all valid options, the json must match Args interface"); } readArgs() { @@ -247,6 +249,7 @@ class CLIArgsParser extends args_parser_1.default { strategyOption: opts.strategyOption, cherryPickOptions: opts.cherryPickOptions, comments: opts.comments, + enableErrorNotification: opts.enableErrNotification, }; } return args; @@ -300,7 +303,9 @@ exports["default"] = ConfigsParser; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.AuthTokenId = void 0; +exports.AuthTokenId = exports.MESSAGE_TARGET_BRANCH_PLACEHOLDER = exports.MESSAGE_ERROR_PLACEHOLDER = void 0; +exports.MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; +exports.MESSAGE_TARGET_BRANCH_PLACEHOLDER = "{{target-branch}}"; var AuthTokenId; (function (AuthTokenId) { // github specific token @@ -327,6 +332,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); const args_utils_1 = __nccwpck_require__(8048); const configs_parser_1 = __importDefault(__nccwpck_require__(5799)); +const configs_types_1 = __nccwpck_require__(4753); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); class PullRequestConfigsParser extends configs_parser_1.default { constructor() { @@ -374,12 +380,20 @@ class PullRequestConfigsParser extends configs_parser_1.default { git: { user: args.gitUser ?? this.gitClient.getDefaultGitUser(), email: args.gitEmail ?? this.gitClient.getDefaultGitEmail(), - } + }, + errorNotification: { + enabled: args.enableErrorNotification ?? false, + message: this.getDefaultErrorComment(), + }, }; } getDefaultFolder() { return "bp"; } + getDefaultErrorComment() { + // TODO: fetch from arg or set default with placeholder {{error}} + return `The backport to \`${configs_types_1.MESSAGE_TARGET_BRANCH_PLACEHOLDER}\` failed. Check the latest run for more details.`; + } /** * Parse the provided labels and return a list of target branches * obtained by applying the provided pattern as regular expression extractor @@ -934,6 +948,26 @@ class GitHubClient { await Promise.all(promises); return data.html_url; } + async createPullRequestComment(prUrl, comment) { + let commentUrl = undefined; + try { + const { owner, project, id } = this.extractPullRequestData(prUrl); + const { data } = await this.octokit.issues.createComment({ + owner: owner, + repo: project, + issue_number: id, + body: comment + }); + if (!data) { + throw new Error("Pull request comment creation failed"); + } + commentUrl = data.url; + } + catch (error) { + this.logger.error(`Error creating comment on pull request ${prUrl}: ${error}`); + } + return commentUrl; + } // UTILS /** * Extract repository owner and project from the pull request url @@ -1093,7 +1127,7 @@ class GitLabClient { const projectId = this.getProjectId(namespace, repo); const { data } = await this.client.get(`/projects/${projectId}/merge_requests/${mrNumber}`); if (squash === undefined) { - squash = (0, git_util_1.inferSquash)(data.state == "opened", data.squash_commit_sha); + squash = (0, git_util_1.inferSquash)(data.state === "opened", data.squash_commit_sha); } const commits = []; if (!squash) { @@ -1175,6 +1209,25 @@ class GitLabClient { await Promise.all(promises); return mr.web_url; } + // https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note + async createPullRequestComment(mrUrl, comment) { + const commentUrl = undefined; + try { + const { namespace, project, id } = this.extractMergeRequestData(mrUrl); + const projectId = this.getProjectId(namespace, project); + const { data } = await this.client.post(`/projects/${projectId}/issues/${id}/notes`, { + body: comment, + }); + if (!data) { + throw new Error("Merge request comment creation failed"); + } + } + catch (error) { + this.logger.error(`Error creating comment on merge request ${mrUrl}: ${error}`); + } + return commentUrl; + } + // UTILS /** * Retrieve a gitlab user given its username * @param username @@ -1322,6 +1375,9 @@ class ConsoleLoggerService { setContext(newContext) { this.context = newContext; } + getContext() { + return this.context; + } clearContext() { this.context = undefined; } @@ -1398,6 +1454,39 @@ class Logger { exports["default"] = Logger; +/***/ }), + +/***/ 9632: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.injectTargetBranch = exports.injectError = void 0; +const configs_types_1 = __nccwpck_require__(4753); +/** + * Inject the error message in the provided `message`. + * This is injected in place of the MESSAGE_ERROR_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param errMsg the error message that needs to be injected + */ +const injectError = (message, errMsg) => { + return message.replace(configs_types_1.MESSAGE_ERROR_PLACEHOLDER, errMsg); +}; +exports.injectError = injectError; +/** + * Inject the target branch into the provided `message`. + * This is injected in place of the MESSAGE_TARGET_BRANCH_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param targetBranch the target branch to inject + * @returns + */ +const injectTargetBranch = (message, targetBranch) => { + return message.replace(configs_types_1.MESSAGE_TARGET_BRANCH_PLACEHOLDER, targetBranch); +}; +exports.injectTargetBranch = injectTargetBranch; + + /***/ }), /***/ 8810: @@ -1415,6 +1504,7 @@ 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)); const git_util_1 = __nccwpck_require__(9080); +const runner_util_1 = __nccwpck_require__(9632); /** * Main runner implementation, it implements the core logic flow */ @@ -1479,6 +1569,12 @@ class Runner { } catch (error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (!configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + let comment = (0, runner_util_1.injectError)(configs.errorNotification.message, error); + comment = (0, runner_util_1.injectTargetBranch)(comment, pr.base); + await gitApi.createPullRequestComment(configs.originalPullRequest.url, comment); + } failures.push(error); } } diff --git a/dist/gha/index.js b/dist/gha/index.js index eb93b89..8c9f93d 100755 --- a/dist/gha/index.js +++ b/dist/gha/index.js @@ -70,7 +70,8 @@ class ArgsParser { strategy: this.getOrDefault(args.strategy), strategyOption: this.getOrDefault(args.strategyOption), cherryPickOptions: this.getOrDefault(args.cherryPickOptions), - comments: this.getOrDefault(args.comments) + comments: this.getOrDefault(args.comments), + enableErrorNotification: this.getOrDefault(args.enableErrorNotification, false), }; } } @@ -108,7 +109,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getAsBooleanOrDefault = exports.getAsSemicolonSeparatedList = exports.getAsCommaSeparatedList = exports.getAsCleanedCommaSeparatedList = exports.getOrUndefined = exports.readConfigFile = exports.parseArgs = void 0; +exports.getAsBooleanOrUndefined = exports.getAsSemicolonSeparatedList = exports.getAsCommaSeparatedList = exports.getAsCleanedCommaSeparatedList = exports.getOrUndefined = exports.readConfigFile = exports.parseArgs = void 0; const fs = __importStar(__nccwpck_require__(7147)); /** * Parse the input configuation string as json object and @@ -159,11 +160,11 @@ function getAsSemicolonSeparatedList(value) { return trimmed !== "" ? trimmed.split(";").map(v => v.trim()) : undefined; } exports.getAsSemicolonSeparatedList = getAsSemicolonSeparatedList; -function getAsBooleanOrDefault(value) { +function getAsBooleanOrUndefined(value) { const trimmed = value.trim(); return trimmed !== "" ? trimmed.toLowerCase() === "true" : undefined; } -exports.getAsBooleanOrDefault = getAsBooleanOrDefault; +exports.getAsBooleanOrUndefined = getAsBooleanOrUndefined; /***/ }), @@ -189,7 +190,7 @@ class GHAArgsParser extends args_parser_1.default { } else { args = { - dryRun: (0, args_utils_1.getAsBooleanOrDefault)((0, core_1.getInput)("dry-run")), + dryRun: (0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("dry-run")), auth: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("auth")), pullRequest: (0, core_1.getInput)("pull-request"), targetBranch: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("target-branch")), @@ -204,15 +205,16 @@ class GHAArgsParser extends args_parser_1.default { bpBranchName: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("bp-branch-name")), reviewers: (0, args_utils_1.getAsCleanedCommaSeparatedList)((0, core_1.getInput)("reviewers")), assignees: (0, args_utils_1.getAsCleanedCommaSeparatedList)((0, core_1.getInput)("assignees")), - inheritReviewers: !(0, args_utils_1.getAsBooleanOrDefault)((0, core_1.getInput)("no-inherit-reviewers")), + inheritReviewers: !(0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("no-inherit-reviewers")), labels: (0, args_utils_1.getAsCommaSeparatedList)((0, core_1.getInput)("labels")), - inheritLabels: (0, args_utils_1.getAsBooleanOrDefault)((0, core_1.getInput)("inherit-labels")), - squash: !(0, args_utils_1.getAsBooleanOrDefault)((0, core_1.getInput)("no-squash")), - autoNoSquash: (0, args_utils_1.getAsBooleanOrDefault)((0, core_1.getInput)("auto-no-squash")), + inheritLabels: (0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("inherit-labels")), + squash: !(0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("no-squash")), + autoNoSquash: (0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("auto-no-squash")), strategy: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("strategy")), strategyOption: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("strategy-option")), cherryPickOptions: (0, args_utils_1.getOrUndefined)((0, core_1.getInput)("cherry-pick-options")), comments: (0, args_utils_1.getAsSemicolonSeparatedList)((0, core_1.getInput)("comments")), + enableErrorNotification: (0, args_utils_1.getAsBooleanOrUndefined)((0, core_1.getInput)("enable-err-notification")), }; } return args; @@ -266,7 +268,9 @@ exports["default"] = ConfigsParser; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.AuthTokenId = void 0; +exports.AuthTokenId = exports.MESSAGE_TARGET_BRANCH_PLACEHOLDER = exports.MESSAGE_ERROR_PLACEHOLDER = void 0; +exports.MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; +exports.MESSAGE_TARGET_BRANCH_PLACEHOLDER = "{{target-branch}}"; var AuthTokenId; (function (AuthTokenId) { // github specific token @@ -293,6 +297,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); const args_utils_1 = __nccwpck_require__(8048); const configs_parser_1 = __importDefault(__nccwpck_require__(5799)); +const configs_types_1 = __nccwpck_require__(4753); const git_client_factory_1 = __importDefault(__nccwpck_require__(8550)); class PullRequestConfigsParser extends configs_parser_1.default { constructor() { @@ -340,12 +345,20 @@ class PullRequestConfigsParser extends configs_parser_1.default { git: { user: args.gitUser ?? this.gitClient.getDefaultGitUser(), email: args.gitEmail ?? this.gitClient.getDefaultGitEmail(), - } + }, + errorNotification: { + enabled: args.enableErrorNotification ?? false, + message: this.getDefaultErrorComment(), + }, }; } getDefaultFolder() { return "bp"; } + getDefaultErrorComment() { + // TODO: fetch from arg or set default with placeholder {{error}} + return `The backport to \`${configs_types_1.MESSAGE_TARGET_BRANCH_PLACEHOLDER}\` failed. Check the latest run for more details.`; + } /** * Parse the provided labels and return a list of target branches * obtained by applying the provided pattern as regular expression extractor @@ -900,6 +913,26 @@ class GitHubClient { await Promise.all(promises); return data.html_url; } + async createPullRequestComment(prUrl, comment) { + let commentUrl = undefined; + try { + const { owner, project, id } = this.extractPullRequestData(prUrl); + const { data } = await this.octokit.issues.createComment({ + owner: owner, + repo: project, + issue_number: id, + body: comment + }); + if (!data) { + throw new Error("Pull request comment creation failed"); + } + commentUrl = data.url; + } + catch (error) { + this.logger.error(`Error creating comment on pull request ${prUrl}: ${error}`); + } + return commentUrl; + } // UTILS /** * Extract repository owner and project from the pull request url @@ -1059,7 +1092,7 @@ class GitLabClient { const projectId = this.getProjectId(namespace, repo); const { data } = await this.client.get(`/projects/${projectId}/merge_requests/${mrNumber}`); if (squash === undefined) { - squash = (0, git_util_1.inferSquash)(data.state == "opened", data.squash_commit_sha); + squash = (0, git_util_1.inferSquash)(data.state === "opened", data.squash_commit_sha); } const commits = []; if (!squash) { @@ -1141,6 +1174,25 @@ class GitLabClient { await Promise.all(promises); return mr.web_url; } + // https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note + async createPullRequestComment(mrUrl, comment) { + const commentUrl = undefined; + try { + const { namespace, project, id } = this.extractMergeRequestData(mrUrl); + const projectId = this.getProjectId(namespace, project); + const { data } = await this.client.post(`/projects/${projectId}/issues/${id}/notes`, { + body: comment, + }); + if (!data) { + throw new Error("Merge request comment creation failed"); + } + } + catch (error) { + this.logger.error(`Error creating comment on merge request ${mrUrl}: ${error}`); + } + return commentUrl; + } + // UTILS /** * Retrieve a gitlab user given its username * @param username @@ -1288,6 +1340,9 @@ class ConsoleLoggerService { setContext(newContext) { this.context = newContext; } + getContext() { + return this.context; + } clearContext() { this.context = undefined; } @@ -1364,6 +1419,39 @@ class Logger { exports["default"] = Logger; +/***/ }), + +/***/ 9632: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.injectTargetBranch = exports.injectError = void 0; +const configs_types_1 = __nccwpck_require__(4753); +/** + * Inject the error message in the provided `message`. + * This is injected in place of the MESSAGE_ERROR_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param errMsg the error message that needs to be injected + */ +const injectError = (message, errMsg) => { + return message.replace(configs_types_1.MESSAGE_ERROR_PLACEHOLDER, errMsg); +}; +exports.injectError = injectError; +/** + * Inject the target branch into the provided `message`. + * This is injected in place of the MESSAGE_TARGET_BRANCH_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param targetBranch the target branch to inject + * @returns + */ +const injectTargetBranch = (message, targetBranch) => { + return message.replace(configs_types_1.MESSAGE_TARGET_BRANCH_PLACEHOLDER, targetBranch); +}; +exports.injectTargetBranch = injectTargetBranch; + + /***/ }), /***/ 8810: @@ -1381,6 +1469,7 @@ 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)); const git_util_1 = __nccwpck_require__(9080); +const runner_util_1 = __nccwpck_require__(9632); /** * Main runner implementation, it implements the core logic flow */ @@ -1445,6 +1534,12 @@ class Runner { } catch (error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (!configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + let comment = (0, runner_util_1.injectError)(configs.errorNotification.message, error); + comment = (0, runner_util_1.injectTargetBranch)(comment, pr.base); + await gitApi.createPullRequestComment(configs.originalPullRequest.url, comment); + } failures.push(error); } } diff --git a/src/service/args/args-parser.ts b/src/service/args/args-parser.ts index 338cc56..344b3ec 100644 --- a/src/service/args/args-parser.ts +++ b/src/service/args/args-parser.ts @@ -48,7 +48,8 @@ export default abstract class ArgsParser { strategy: this.getOrDefault(args.strategy), strategyOption: this.getOrDefault(args.strategyOption), cherryPickOptions: this.getOrDefault(args.cherryPickOptions), - comments: this.getOrDefault(args.comments) + comments: this.getOrDefault(args.comments), + enableErrorNotification: this.getOrDefault(args.enableErrorNotification, false), }; } } \ No newline at end of file diff --git a/src/service/args/args-utils.ts b/src/service/args/args-utils.ts index 9f6165c..d270a14 100644 --- a/src/service/args/args-utils.ts +++ b/src/service/args/args-utils.ts @@ -50,7 +50,7 @@ export function getAsSemicolonSeparatedList(value: string): string[] | undefined return trimmed !== "" ? trimmed.split(";").map(v => v.trim()) : undefined; } -export function getAsBooleanOrDefault(value: string): boolean | undefined { +export function getAsBooleanOrUndefined(value: string): boolean | undefined { const trimmed = value.trim(); return trimmed !== "" ? trimmed.toLowerCase() === "true" : undefined; } \ No newline at end of file diff --git a/src/service/args/args.types.ts b/src/service/args/args.types.ts index a951c1d..78d56f1 100644 --- a/src/service/args/args.types.ts +++ b/src/service/args/args.types.ts @@ -28,4 +28,5 @@ export interface Args { strategyOption?: string, // cherry-pick merge strategy option cherryPickOptions?: string, // additional cherry-pick options comments?: string[], // additional comments to be posted + enableErrorNotification?: boolean, // enable the error notification on original pull request } \ No newline at end of file diff --git a/src/service/args/cli/cli-args-parser.ts b/src/service/args/cli/cli-args-parser.ts index 7c5d7ec..d7728ae 100644 --- a/src/service/args/cli/cli-args-parser.ts +++ b/src/service/args/cli/cli-args-parser.ts @@ -28,12 +28,13 @@ export default class CLIArgsParser extends ArgsParser { .option("--no-inherit-reviewers", "if provided and reviewers option is empty then inherit them from original pull request") .option("--labels ", "comma separated list of labels to be assigned to the backported pull request", getAsCommaSeparatedList) .option("--inherit-labels", "if true the backported pull request will inherit labels from the original one") - .option("--no-squash", "Backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch") - .option("--auto-no-squash", "If the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.") + .option("--no-squash", "backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch") + .option("--auto-no-squash", "if the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.") .option("--strategy ", "cherry-pick merge strategy, default to 'recursive'", undefined) .option("--strategy-option ", "cherry-pick merge strategy option, default to 'theirs'") .option("--cherry-pick-options ", "additional cherry-pick options") .option("--comments ", "semicolon separated list of additional comments to be posted to the backported pull request", getAsSemicolonSeparatedList) + .option("--enable-err-notification", "if true, enable the error notification as comment on the original pull request") .option("-cf, --config-file ", "configuration file containing all valid options, the json must match Args interface"); } @@ -72,6 +73,7 @@ export default class CLIArgsParser extends ArgsParser { strategyOption: opts.strategyOption, cherryPickOptions: opts.cherryPickOptions, comments: opts.comments, + enableErrorNotification: opts.enableErrNotification, }; } diff --git a/src/service/args/gha/gha-args-parser.ts b/src/service/args/gha/gha-args-parser.ts index e51106d..2bb516f 100644 --- a/src/service/args/gha/gha-args-parser.ts +++ b/src/service/args/gha/gha-args-parser.ts @@ -1,7 +1,7 @@ import ArgsParser from "@bp/service/args/args-parser"; import { Args } from "@bp/service/args/args.types"; import { getInput } from "@actions/core"; -import { getAsBooleanOrDefault, getAsCleanedCommaSeparatedList, getAsCommaSeparatedList, getAsSemicolonSeparatedList, getOrUndefined, readConfigFile } from "@bp/service/args/args-utils"; +import { getAsBooleanOrUndefined, getAsCleanedCommaSeparatedList, getAsCommaSeparatedList, getAsSemicolonSeparatedList, getOrUndefined, readConfigFile } from "@bp/service/args/args-utils"; export default class GHAArgsParser extends ArgsParser { @@ -13,7 +13,7 @@ export default class GHAArgsParser extends ArgsParser { args = readConfigFile(configFile); } else { args = { - dryRun: getAsBooleanOrDefault(getInput("dry-run")), + dryRun: getAsBooleanOrUndefined(getInput("dry-run")), auth: getOrUndefined(getInput("auth")), pullRequest: getInput("pull-request"), targetBranch: getOrUndefined(getInput("target-branch")), @@ -28,15 +28,16 @@ export default class GHAArgsParser extends ArgsParser { bpBranchName: getOrUndefined(getInput("bp-branch-name")), reviewers: getAsCleanedCommaSeparatedList(getInput("reviewers")), assignees: getAsCleanedCommaSeparatedList(getInput("assignees")), - inheritReviewers: !getAsBooleanOrDefault(getInput("no-inherit-reviewers")), + inheritReviewers: !getAsBooleanOrUndefined(getInput("no-inherit-reviewers")), labels: getAsCommaSeparatedList(getInput("labels")), - inheritLabels: getAsBooleanOrDefault(getInput("inherit-labels")), - squash: !getAsBooleanOrDefault(getInput("no-squash")), - autoNoSquash: getAsBooleanOrDefault(getInput("auto-no-squash")), + inheritLabels: getAsBooleanOrUndefined(getInput("inherit-labels")), + squash: !getAsBooleanOrUndefined(getInput("no-squash")), + autoNoSquash: getAsBooleanOrUndefined(getInput("auto-no-squash")), strategy: getOrUndefined(getInput("strategy")), strategyOption: getOrUndefined(getInput("strategy-option")), cherryPickOptions: getOrUndefined(getInput("cherry-pick-options")), comments: getAsSemicolonSeparatedList(getInput("comments")), + enableErrorNotification: getAsBooleanOrUndefined(getInput("enable-err-notification")), }; } diff --git a/src/service/configs/configs.types.ts b/src/service/configs/configs.types.ts index 5df292d..85dd1f9 100644 --- a/src/service/configs/configs.types.ts +++ b/src/service/configs/configs.types.ts @@ -2,11 +2,19 @@ import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types"; +export const MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; +export const MESSAGE_TARGET_BRANCH_PLACEHOLDER = "{{target-branch}}"; + export interface LocalGit { user: string, // local git user email: string, // local git email } +export interface ErrorNotification { + enabled: boolean, // if the error notification is enabled + message: string, // notification message, placeholder {{error}} will be replaced with actual error +} + /** * Internal configuration object */ @@ -20,6 +28,7 @@ export interface Configs { cherryPickOptions?: string, // additional cherry-pick options originalPullRequest: GitPullRequest, backportPullRequests: BackportPullRequest[], + errorNotification: ErrorNotification, } export enum AuthTokenId { diff --git a/src/service/configs/pullrequest/pr-configs-parser.ts b/src/service/configs/pullrequest/pr-configs-parser.ts index d66cccd..cb483bd 100644 --- a/src/service/configs/pullrequest/pr-configs-parser.ts +++ b/src/service/configs/pullrequest/pr-configs-parser.ts @@ -1,7 +1,7 @@ import { getAsCleanedCommaSeparatedList, getAsCommaSeparatedList } from "@bp/service/args/args-utils"; import { Args } from "@bp/service/args/args.types"; import ConfigsParser from "@bp/service/configs/configs-parser"; -import { Configs } from "@bp/service/configs/configs.types"; +import { Configs, MESSAGE_TARGET_BRANCH_PLACEHOLDER } from "@bp/service/configs/configs.types"; import GitClient from "@bp/service/git/git-client"; import GitClientFactory from "@bp/service/git/git-client-factory"; import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types"; @@ -58,7 +58,11 @@ export default class PullRequestConfigsParser extends ConfigsParser { git: { user: args.gitUser ?? this.gitClient.getDefaultGitUser(), email: args.gitEmail ?? this.gitClient.getDefaultGitEmail(), - } + }, + errorNotification: { + enabled: args.enableErrorNotification ?? false, + message: this.getDefaultErrorComment(), + }, }; } @@ -66,6 +70,11 @@ export default class PullRequestConfigsParser extends ConfigsParser { return "bp"; } + private getDefaultErrorComment(): string { + // TODO: fetch from arg or set default with placeholder {{error}} + return `The backport to \`${MESSAGE_TARGET_BRANCH_PLACEHOLDER}\` failed. Check the latest run for more details.`; + } + /** * Parse the provided labels and return a list of target branches * obtained by applying the provided pattern as regular expression extractor diff --git a/src/service/git/git-client.ts b/src/service/git/git-client.ts index 3df6a0e..93190d9 100644 --- a/src/service/git/git-client.ts +++ b/src/service/git/git-client.ts @@ -44,4 +44,11 @@ import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/ */ createPullRequest(backport: BackportPullRequest): Promise; + /** + * Create a new comment on the provided pull request + * @param prUrl pull request's URL + * @param comment comment body + */ + createPullRequestComment(prUrl: string, comment: string): Promise; + } \ No newline at end of file diff --git a/src/service/git/git.types.ts b/src/service/git/git.types.ts index 696b29d..868440c 100644 --- a/src/service/git/git.types.ts +++ b/src/service/git/git.types.ts @@ -1,7 +1,7 @@ export interface GitPullRequest { number?: number, author: string, - url?: string, + url: string, htmlUrl?: string, state?: GitRepoState, merged?: boolean, diff --git a/src/service/git/github/github-client.ts b/src/service/git/github/github-client.ts index 4a35423..6548429 100644 --- a/src/service/git/github/github-client.ts +++ b/src/service/git/github/github-client.ts @@ -158,6 +158,29 @@ export default class GitHubClient implements GitClient { return data.html_url; } + async createPullRequestComment(prUrl: string, comment: string): Promise { + let commentUrl: string | undefined = undefined; + try { + const { owner, project, id } = this.extractPullRequestData(prUrl); + const { data } = await this.octokit.issues.createComment({ + owner: owner, + repo: project, + issue_number: id, + body: comment + }); + + if (!data) { + throw new Error("Pull request comment creation failed"); + } + + commentUrl = data.url; + } catch (error) { + this.logger.error(`Error creating comment on pull request ${prUrl}: ${error}`); + } + + return commentUrl; + } + // UTILS /** diff --git a/src/service/git/gitlab/gitlab-client.ts b/src/service/git/gitlab/gitlab-client.ts index 8db5c58..da26010 100644 --- a/src/service/git/gitlab/gitlab-client.ts +++ b/src/service/git/gitlab/gitlab-client.ts @@ -162,6 +162,29 @@ export default class GitLabClient implements GitClient { return mr.web_url; } + // https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note + async createPullRequestComment(mrUrl: string, comment: string): Promise { + const commentUrl: string | undefined = undefined; + try{ + const { namespace, project, id } = this.extractMergeRequestData(mrUrl); + const projectId = this.getProjectId(namespace, project); + + const { data } = await this.client.post(`/projects/${projectId}/issues/${id}/notes`, { + body: comment, + }); + + if (!data) { + throw new Error("Merge request comment creation failed"); + } + } catch(error) { + this.logger.error(`Error creating comment on merge request ${mrUrl}: ${error}`); + } + + return commentUrl; + } + + // UTILS + /** * Retrieve a gitlab user given its username * @param username diff --git a/src/service/logger/console-logger-service.ts b/src/service/logger/console-logger-service.ts index 4a3088e..6de8020 100644 --- a/src/service/logger/console-logger-service.ts +++ b/src/service/logger/console-logger-service.ts @@ -16,6 +16,10 @@ export default class ConsoleLoggerService implements LoggerService { this.context = newContext; } + getContext(): string | undefined { + return this.context; + } + clearContext() { this.context = undefined; } diff --git a/src/service/logger/logger-service.ts b/src/service/logger/logger-service.ts index 0488ac6..477da94 100644 --- a/src/service/logger/logger-service.ts +++ b/src/service/logger/logger-service.ts @@ -5,6 +5,8 @@ export default interface LoggerService { setContext(newContext: string): void; + getContext(): string | undefined; + clearContext(): void; trace(message: string): void; diff --git a/src/service/runner/runner-util.ts b/src/service/runner/runner-util.ts new file mode 100644 index 0000000..2d5bd2a --- /dev/null +++ b/src/service/runner/runner-util.ts @@ -0,0 +1,22 @@ +import { MESSAGE_ERROR_PLACEHOLDER, MESSAGE_TARGET_BRANCH_PLACEHOLDER } from "@bp/service/configs/configs.types"; + +/** + * Inject the error message in the provided `message`. + * This is injected in place of the MESSAGE_ERROR_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param errMsg the error message that needs to be injected + */ +export const injectError = (message: string, errMsg: string): string => { + return message.replace(MESSAGE_ERROR_PLACEHOLDER, errMsg); +}; + +/** + * Inject the target branch into the provided `message`. + * This is injected in place of the MESSAGE_TARGET_BRANCH_PLACEHOLDER placeholder + * @param message string that needs to be updated + * @param targetBranch the target branch to inject + * @returns + */ +export const injectTargetBranch = (message: string, targetBranch: string): string => { + return message.replace(MESSAGE_TARGET_BRANCH_PLACEHOLDER, targetBranch); +}; \ No newline at end of file diff --git a/src/service/runner/runner.ts b/src/service/runner/runner.ts index d4ddf42..c45f993 100644 --- a/src/service/runner/runner.ts +++ b/src/service/runner/runner.ts @@ -9,6 +9,7 @@ import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/ import LoggerService from "@bp/service/logger/logger-service"; import LoggerServiceFactory from "@bp/service/logger/logger-service-factory"; import { inferGitClient, inferGitApiUrl, getGitTokenFromEnv } from "@bp/service/git/git-util"; +import { injectError, injectTargetBranch } from "./runner-util"; interface Git { gitClientType: GitClientType; @@ -92,6 +93,12 @@ export default class Runner { }); } catch(error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (!configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + let comment = injectError(configs.errorNotification.message, error as string); + comment = injectTargetBranch(comment, pr.base); + await gitApi.createPullRequestComment(configs.originalPullRequest.url, comment); + } failures.push(error as string); } } @@ -133,13 +140,12 @@ export default class Runner { // 5. create new branch from target one and checkout this.logger.debug("Creating local branch.."); - await git.gitCli.createLocalBranch(configs.folder, backportPR.head); // 6. fetch pull request remote if source owner != target owner or pull request still open if (configs.originalPullRequest.sourceRepo.owner !== configs.originalPullRequest.targetRepo.owner || configs.originalPullRequest.state === "open") { - this.logger.debug("Fetching pull request remote.."); + this.logger.debug("Fetching pull request remote.."); const prefix = git.gitClientType === GitClientType.GITLAB ? "merge-requests" : "pull" ; // default is for gitlab await git.gitCli.fetch(configs.folder, `${prefix}/${configs.originalPullRequest.number}/head:pr/${configs.originalPullRequest.number}`); } @@ -165,4 +171,4 @@ export default class Runner { this.logger.clearContext(); } -} \ No newline at end of file +} diff --git a/test/service/args/cli/cli-args-parser.test.ts b/test/service/args/cli/cli-args-parser.test.ts index b49c466..31d4c15 100644 --- a/test/service/args/cli/cli-args-parser.test.ts +++ b/test/service/args/cli/cli-args-parser.test.ts @@ -79,9 +79,11 @@ describe("cli args parser", () => { expect(args.labels).toEqual([]); expect(args.inheritLabels).toEqual(false); expect(args.squash).toEqual(true); + expect(args.autoNoSquash).toEqual(false); expect(args.strategy).toEqual(undefined); expect(args.strategyOption).toEqual(undefined); expect(args.cherryPickOptions).toEqual(undefined); + expect(args.enableErrorNotification).toEqual(false); }); test("with config file [default, short]", () => { @@ -109,9 +111,11 @@ describe("cli args parser", () => { expect(args.labels).toEqual([]); expect(args.inheritLabels).toEqual(false); expect(args.squash).toEqual(true); + expect(args.autoNoSquash).toEqual(false); expect(args.strategy).toEqual(undefined); expect(args.strategyOption).toEqual(undefined); expect(args.cherryPickOptions).toEqual(undefined); + expect(args.enableErrorNotification).toEqual(false); }); test("valid execution [default, long]", () => { @@ -521,4 +525,17 @@ describe("cli args parser", () => { expect(() => parser.parse()).toThrowError("Missing option: pull request must be provided"); }); + + test("enable error notification flag", () => { + addProcessArgs([ + "-tb", + "target, old", + "-pr", + "https://localhost/whatever/pulls/1", + "--enable-err-notification", + ]); + + const args: Args = parser.parse(); + expect(args.enableErrorNotification).toEqual(true); + }); }); \ No newline at end of file diff --git a/test/service/args/gha/gha-args-parser.test.ts b/test/service/args/gha/gha-args-parser.test.ts index 6f850bc..9039a55 100644 --- a/test/service/args/gha/gha-args-parser.test.ts +++ b/test/service/args/gha/gha-args-parser.test.ts @@ -295,7 +295,6 @@ describe("gha args parser", () => { expect(args.cherryPickOptions).toEqual(undefined); }); - test("invalid execution with empty target branch", () => { spyGetInput({ "target-branch": " ", @@ -320,4 +319,15 @@ describe("gha args parser", () => { expect(() => parser.parse()).toThrowError("Missing option: pull request must be provided"); }); + + test("enable error notification flag", () => { + spyGetInput({ + "target-branch": "target,old", + "pull-request": "https://localhost/whatever/pulls/1", + "enable-err-notification": "true" + }); + + const args: Args = parser.parse(); + expect(args.enableErrorNotification).toEqual(true); + }); }); \ No newline at end of file diff --git a/test/service/configs/pullrequest/github-pr-configs-parser.test.ts b/test/service/configs/pullrequest/github-pr-configs-parser.test.ts index 4348504..369695e 100644 --- a/test/service/configs/pullrequest/github-pr-configs-parser.test.ts +++ b/test/service/configs/pullrequest/github-pr-configs-parser.test.ts @@ -139,6 +139,10 @@ describe("github pull request config parser", () => { labels: [], comments: [], }); + expect(configs.errorNotification).toEqual({ + enabled: false, + message: "The backport to `{{target-branch}}` failed. Check the latest run for more details." + }); }); test("override folder", async () => { @@ -939,4 +943,26 @@ describe("github pull request config parser", () => { comments: ["First comment", "Second comment"], }); }); + + test("enable error notification message", async () => { + const args: Args = { + dryRun: false, + auth: "", + pullRequest: mergedPRUrl, + targetBranch: "prod", + enableErrorNotification: true, + }; + + const configs: Configs = await configParser.parseAndValidate(args); + + expect(GitHubClient.prototype.getPullRequest).toBeCalledTimes(1); + expect(GitHubClient.prototype.getPullRequest).toBeCalledWith("owner", "reponame", 2368, undefined); + expect(GitHubMapper.prototype.mapPullRequest).toBeCalledTimes(1); + expect(GitHubMapper.prototype.mapPullRequest).toBeCalledWith(expect.anything(), []); + + expect(configs.errorNotification).toEqual({ + "enabled": true, + "message": "The backport to `{{target-branch}}` failed. Check the latest run for more details." + }); + }); }); \ No newline at end of file diff --git a/test/service/configs/pullrequest/gitlab-pr-configs-parser.test.ts b/test/service/configs/pullrequest/gitlab-pr-configs-parser.test.ts index 9030cc9..bbaaf98 100644 --- a/test/service/configs/pullrequest/gitlab-pr-configs-parser.test.ts +++ b/test/service/configs/pullrequest/gitlab-pr-configs-parser.test.ts @@ -144,6 +144,10 @@ describe("gitlab merge request config parser", () => { labels: [], comments: [], }); + expect(configs.errorNotification).toEqual({ + "enabled": false, + "message": "The backport to `{{target-branch}}` failed. Check the latest run for more details." + }); }); @@ -882,4 +886,26 @@ describe("gitlab merge request config parser", () => { comments: ["First comment", "Second comment"], }); }); + + test("enable error notification message", async () => { + const args: Args = { + dryRun: false, + auth: "", + pullRequest: mergedPRUrl, + targetBranch: "prod", + enableErrorNotification: true, + }; + + const configs: Configs = await configParser.parseAndValidate(args); + + expect(GitLabClient.prototype.getPullRequest).toBeCalledTimes(1); + expect(GitLabClient.prototype.getPullRequest).toBeCalledWith("superuser", "backporting-example", 1, undefined); + expect(GitLabMapper.prototype.mapPullRequest).toBeCalledTimes(1); + expect(GitLabMapper.prototype.mapPullRequest).toBeCalledWith(expect.anything(), []); + + expect(configs.errorNotification).toEqual({ + "enabled": true, + "message": "The backport to `{{target-branch}}` failed. Check the latest run for more details.", + }); + }); }); \ No newline at end of file diff --git a/test/service/runner/cli-github-runner.test.ts b/test/service/runner/cli-github-runner.test.ts index 5c4ecf3..8d5bd43 100644 --- a/test/service/runner/cli-github-runner.test.ts +++ b/test/service/runner/cli-github-runner.test.ts @@ -30,6 +30,7 @@ const GITHUB_MERGED_PR_W_OVERRIDES_CONFIG_FILE_CONTENT = { jest.mock("@bp/service/git/git-cli"); jest.spyOn(GitHubClient.prototype, "createPullRequest"); +jest.spyOn(GitHubClient.prototype, "createPullRequestComment"); jest.spyOn(GitClientFactory, "getOrCreate"); let parser: ArgsParser; @@ -94,6 +95,7 @@ describe("cli runner", () => { expect(GitCLIService.prototype.push).toBeCalledTimes(0); expect(GitHubClient.prototype.createPullRequest).toBeCalledTimes(0); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("overriding author", async () => { @@ -287,6 +289,7 @@ describe("cli runner", () => { } ); expect(GitHubClient.prototype.createPullRequest).toReturnTimes(1); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("closed and not merged pull request", async () => { @@ -1156,6 +1159,7 @@ describe("cli runner", () => { comments: [], }); expect(GitHubClient.prototype.createPullRequest).toThrowError(); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("auth using GITHUB_TOKEN takes precedence over GIT_TOKEN env variable", async () => { @@ -1231,4 +1235,142 @@ describe("cli runner", () => { expect(GitCLIService.prototype.clone).toBeCalledTimes(1); expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "prod"); }); -}); \ No newline at end of file + + test("with multiple target branches, one failure and error notification enabled", async () => { + jest.spyOn(GitHubClient.prototype, "createPullRequest").mockImplementation((backport: BackportPullRequest) => { + throw new Error(`Mocked error: ${backport.base}`); + }); + + addProcessArgs([ + "-tb", + "v1, v2, v3", + "-pr", + "https://github.com/owner/reponame/pull/2368", + "-f", + "/tmp/folder", + "--bp-branch-name", + "custom-failure-head", + "--enable-err-notification", + ]); + + await expect(() => runner.execute()).rejects.toThrowError("Failure occurred during one of the backports: [Error: Mocked error: v1 ; Error: Mocked error: v2 ; Error: Mocked error: v3]"); + + const cwd = "/tmp/folder"; + + expect(GitClientFactory.getOrCreate).toBeCalledTimes(1); + expect(GitClientFactory.getOrCreate).toBeCalledWith(GitClientType.GITHUB, undefined, "https://api.github.com"); + + expect(GitCLIService.prototype.clone).toBeCalledTimes(3); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v1"); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v2"); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v3"); + + expect(GitCLIService.prototype.createLocalBranch).toBeCalledTimes(3); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v1"); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v2"); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v3"); + + expect(GitCLIService.prototype.fetch).toBeCalledTimes(3); + expect(GitCLIService.prototype.fetch).toBeCalledWith(cwd, "pull/2368/head:pr/2368"); + + expect(GitCLIService.prototype.cherryPick).toBeCalledTimes(3); + expect(GitCLIService.prototype.cherryPick).toBeCalledWith(cwd, "28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc", undefined, undefined, undefined); + expect(GitCLIService.prototype.cherryPick).toBeCalledWith(cwd, "28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc", undefined, undefined, undefined); + expect(GitCLIService.prototype.cherryPick).toBeCalledWith(cwd, "28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc", undefined, undefined, undefined); + + expect(GitCLIService.prototype.push).toBeCalledTimes(3); + expect(GitCLIService.prototype.push).toBeCalledWith(cwd, "custom-failure-head-v1"); + expect(GitCLIService.prototype.push).toBeCalledWith(cwd, "custom-failure-head-v2"); + expect(GitCLIService.prototype.push).toBeCalledWith(cwd, "custom-failure-head-v3"); + + expect(GitHubClient.prototype.createPullRequest).toBeCalledTimes(3); + expect(GitHubClient.prototype.createPullRequest).toBeCalledWith({ + owner: "owner", + repo: "reponame", + head: "custom-failure-head-v1", + base: "v1", + title: "[v1] PR Title", + body: "**Backport:** https://github.com/owner/reponame/pull/2368\r\n\r\nPlease review and merge", + reviewers: ["gh-user", "that-s-a-user"], + assignees: [], + labels: [], + comments: [], + }); + expect(GitHubClient.prototype.createPullRequest).toBeCalledWith({ + owner: "owner", + repo: "reponame", + head: "custom-failure-head-v2", + base: "v2", + title: "[v2] PR Title", + body: "**Backport:** https://github.com/owner/reponame/pull/2368\r\n\r\nPlease review and merge", + reviewers: ["gh-user", "that-s-a-user"], + assignees: [], + labels: [], + comments: [], + }); + expect(GitHubClient.prototype.createPullRequest).toBeCalledWith({ + owner: "owner", + repo: "reponame", + head: "custom-failure-head-v3", + base: "v3", + title: "[v3] PR Title", + body: "**Backport:** https://github.com/owner/reponame/pull/2368\r\n\r\nPlease review and merge", + reviewers: ["gh-user", "that-s-a-user"], + assignees: [], + labels: [], + comments: [], + }); + expect(GitHubClient.prototype.createPullRequest).toThrowError(); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(3); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledWith("https://api.github.com/repos/owner/reponame/pulls/2368", "The backport to `v1` failed. Check the latest run for more details."); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledWith("https://api.github.com/repos/owner/reponame/pulls/2368", "The backport to `v2` failed. Check the latest run for more details."); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledWith("https://api.github.com/repos/owner/reponame/pulls/2368", "The backport to `v3` failed. Check the latest run for more details."); + }); + + test("with some failures and dry run enabled", async () => { + jest.spyOn(GitCLIService.prototype, "cherryPick").mockImplementation((cwd: string, sha: string) => { + throw new Error(`Forced error: ${sha}`); + }); + + addProcessArgs([ + "-tb", + "v1, v2, v3", + "-pr", + "https://github.com/owner/reponame/pull/2368", + "-f", + "/tmp/folder", + "--bp-branch-name", + "custom-failure-head", + "--enable-err-notification", + "--dry-run", + ]); + + await expect(() => runner.execute()).rejects.toThrowError("Failure occurred during one of the backports: [Error: Forced error: 28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc ; Error: Forced error: 28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc ; Error: Forced error: 28f63db774185f4ec4b57cd9aaeb12dbfb4c9ecc]"); + + const cwd = "/tmp/folder"; + + expect(GitClientFactory.getOrCreate).toBeCalledTimes(1); + expect(GitClientFactory.getOrCreate).toBeCalledWith(GitClientType.GITHUB, undefined, "https://api.github.com"); + + expect(GitCLIService.prototype.clone).toBeCalledTimes(3); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v1"); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v2"); + expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "v3"); + + expect(GitCLIService.prototype.createLocalBranch).toBeCalledTimes(3); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v1"); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v2"); + expect(GitCLIService.prototype.createLocalBranch).toBeCalledWith(cwd, "custom-failure-head-v3"); + + expect(GitCLIService.prototype.fetch).toBeCalledTimes(3); + expect(GitCLIService.prototype.fetch).toBeCalledWith(cwd, "pull/2368/head:pr/2368"); + + expect(GitCLIService.prototype.cherryPick).toBeCalledTimes(3); + expect(GitCLIService.prototype.cherryPick).toThrowError(); + + expect(GitCLIService.prototype.push).toBeCalledTimes(0); + + expect(GitHubClient.prototype.createPullRequest).toBeCalledTimes(0); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(0); + }); +}); diff --git a/test/service/runner/cli-gitlab-runner.test.ts b/test/service/runner/cli-gitlab-runner.test.ts index f755f2b..5d15911 100644 --- a/test/service/runner/cli-gitlab-runner.test.ts +++ b/test/service/runner/cli-gitlab-runner.test.ts @@ -44,6 +44,7 @@ jest.mock("axios", () => { jest.mock("@bp/service/git/git-cli"); jest.spyOn(GitLabClient.prototype, "createPullRequest"); +jest.spyOn(GitLabClient.prototype, "createPullRequestComment"); jest.spyOn(GitClientFactory, "getOrCreate"); @@ -105,6 +106,7 @@ describe("cli runner", () => { expect(GitCLIService.prototype.push).toBeCalledTimes(0); expect(GitLabClient.prototype.createPullRequest).toBeCalledTimes(0); + expect(GitLabClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("dry run with relative folder", async () => { @@ -199,6 +201,7 @@ describe("cli runner", () => { ]); await expect(() => runner.execute()).rejects.toThrow("Provided pull request is closed and not merged"); + expect(GitLabClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("merged pull request", async () => { @@ -246,6 +249,7 @@ describe("cli runner", () => { comments: [], } ); + expect(GitLabClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); diff --git a/test/service/runner/gha-github-runner.test.ts b/test/service/runner/gha-github-runner.test.ts index 697e0c9..e673db7 100644 --- a/test/service/runner/gha-github-runner.test.ts +++ b/test/service/runner/gha-github-runner.test.ts @@ -30,6 +30,7 @@ const GITHUB_MERGED_PR_W_OVERRIDES_CONFIG_FILE_CONTENT = { jest.mock("@bp/service/git/git-cli"); jest.spyOn(GitHubClient.prototype, "createPullRequest"); +jest.spyOn(GitHubClient.prototype, "createPullRequestComment"); jest.spyOn(GitClientFactory, "getOrCreate"); let parser: ArgsParser; @@ -87,6 +88,7 @@ describe("gha runner", () => { expect(GitCLIService.prototype.push).toBeCalledTimes(0); expect(GitHubClient.prototype.createPullRequest).toBeCalledTimes(0); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("without dry run", async () => { diff --git a/test/service/runner/gha-gitlab-runner.test.ts b/test/service/runner/gha-gitlab-runner.test.ts index e9f739e..472df31 100644 --- a/test/service/runner/gha-gitlab-runner.test.ts +++ b/test/service/runner/gha-gitlab-runner.test.ts @@ -43,6 +43,7 @@ jest.mock("axios", () => { jest.mock("@bp/service/git/git-cli"); jest.spyOn(GitLabClient.prototype, "createPullRequest"); +jest.spyOn(GitLabClient.prototype, "createPullRequestComment"); jest.spyOn(GitClientFactory, "getOrCreate"); let parser: ArgsParser; @@ -98,6 +99,7 @@ describe("gha runner", () => { expect(GitCLIService.prototype.push).toBeCalledTimes(0); expect(GitLabClient.prototype.createPullRequest).toBeCalledTimes(0); + expect(GitLabClient.prototype.createPullRequestComment).toBeCalledTimes(0); }); test("without dry run", async () => { diff --git a/test/service/runner/runner-util.test.ts b/test/service/runner/runner-util.test.ts new file mode 100644 index 0000000..7f7d982 --- /dev/null +++ b/test/service/runner/runner-util.test.ts @@ -0,0 +1,19 @@ +import { injectError, injectTargetBranch } from "@bp/service/runner/runner-util"; + +describe("check runner utilities", () => { + test("properly inject error message", () => { + expect(injectError("Original message: {{error}}", "to inject")).toStrictEqual("Original message: to inject"); + }); + + test("missing error placeholder in the original message", () => { + expect(injectError("Original message: {{wrong}}", "to inject")).toStrictEqual("Original message: {{wrong}}"); + }); + + test("properly inject target branch into message", () => { + expect(injectTargetBranch("Original message: {{target-branch}}", "to inject")).toStrictEqual("Original message: to inject"); + }); + + test("missing target branch placeholder in the original message", () => { + expect(injectTargetBranch("Original message: {{wrong}}", "to inject")).toStrictEqual("Original message: {{wrong}}"); + }); +}); \ No newline at end of file