diff --git a/action.yml b/action.yml index fc166c9..2794254 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 + no-squash: + 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..6f6ab5c 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,8 @@ exports["default"] = ConfigsParser; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.AuthTokenId = void 0; +exports.AuthTokenId = exports.MESSAGE_ERROR_PLACEHOLDER = void 0; +exports.MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; var AuthTokenId; (function (AuthTokenId) { // github specific token @@ -327,6 +331,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 +379,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 `Backporting failed: ${configs_types_1.MESSAGE_ERROR_PLACEHOLDER}`; + } /** * Parse the provided labels and return a list of target branches * obtained by applying the provided pattern as regular expression extractor @@ -934,6 +947,19 @@ class GitHubClient { await Promise.all(promises); return data.html_url; } + async createPullRequestComment(prUrl, comment) { + 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"); + } + return data.url; + } // UTILS /** * Extract repository owner and project from the pull request url @@ -1093,7 +1119,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 +1201,11 @@ class GitLabClient { await Promise.all(promises); return mr.web_url; } + // TODO: implement createPullRequestComment + async createPullRequestComment(prUrl, comment) { + throw new Error("Method not implemented."); + } + // UTILS /** * Retrieve a gitlab user given its username * @param username @@ -1322,6 +1353,9 @@ class ConsoleLoggerService { setContext(newContext) { this.context = newContext; } + getContext() { + return this.context; + } clearContext() { this.context = undefined; } @@ -1398,6 +1432,28 @@ class Logger { exports["default"] = Logger; +/***/ }), + +/***/ 9632: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.injectError = void 0; +const configs_types_1 = __nccwpck_require__(4753); +/** + * Inject the error message in the provided `message`. + * This is inject 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; + + /***/ }), /***/ 8810: @@ -1415,6 +1471,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 +1536,11 @@ class Runner { } catch (error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + const comment = (0, runner_util_1.injectError)(configs.errorNotification.message, error); + gitApi.createPullRequestComment(configs.originalPullRequest.url, comment); + } failures.push(error); } } diff --git a/dist/gha/index.js b/dist/gha/index.js index eb93b89..209d2fb 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,8 @@ exports["default"] = ConfigsParser; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.AuthTokenId = void 0; +exports.AuthTokenId = exports.MESSAGE_ERROR_PLACEHOLDER = void 0; +exports.MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; var AuthTokenId; (function (AuthTokenId) { // github specific token @@ -293,6 +296,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 +344,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 `Backporting failed: ${configs_types_1.MESSAGE_ERROR_PLACEHOLDER}`; + } /** * Parse the provided labels and return a list of target branches * obtained by applying the provided pattern as regular expression extractor @@ -900,6 +912,19 @@ class GitHubClient { await Promise.all(promises); return data.html_url; } + async createPullRequestComment(prUrl, comment) { + 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"); + } + return data.url; + } // UTILS /** * Extract repository owner and project from the pull request url @@ -1059,7 +1084,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 +1166,11 @@ class GitLabClient { await Promise.all(promises); return mr.web_url; } + // TODO: implement createPullRequestComment + async createPullRequestComment(prUrl, comment) { + throw new Error("Method not implemented."); + } + // UTILS /** * Retrieve a gitlab user given its username * @param username @@ -1288,6 +1318,9 @@ class ConsoleLoggerService { setContext(newContext) { this.context = newContext; } + getContext() { + return this.context; + } clearContext() { this.context = undefined; } @@ -1364,6 +1397,28 @@ class Logger { exports["default"] = Logger; +/***/ }), + +/***/ 9632: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.injectError = void 0; +const configs_types_1 = __nccwpck_require__(4753); +/** + * Inject the error message in the provided `message`. + * This is inject 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; + + /***/ }), /***/ 8810: @@ -1381,6 +1436,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 +1501,11 @@ class Runner { } catch (error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + const comment = (0, runner_util_1.injectError)(configs.errorNotification.message, error); + 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..06646d6 100644 --- a/src/service/configs/configs.types.ts +++ b/src/service/configs/configs.types.ts @@ -2,11 +2,18 @@ import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types"; +export const MESSAGE_ERROR_PLACEHOLDER = "{{error}}"; + 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 +27,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..971e7ff 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_ERROR_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 `Backporting failed: ${MESSAGE_ERROR_PLACEHOLDER}`; + } + /** * 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..3174636 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..4e8277d 100644 --- a/src/service/git/github/github-client.ts +++ b/src/service/git/github/github-client.ts @@ -158,6 +158,22 @@ export default class GitHubClient implements GitClient { return data.html_url; } + async createPullRequestComment(prUrl: string, comment: string): Promise { + 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"); + } + + return data.url; + } + // UTILS /** diff --git a/src/service/git/gitlab/gitlab-client.ts b/src/service/git/gitlab/gitlab-client.ts index 8db5c58..28a92ba 100644 --- a/src/service/git/gitlab/gitlab-client.ts +++ b/src/service/git/gitlab/gitlab-client.ts @@ -162,6 +162,13 @@ export default class GitLabClient implements GitClient { return mr.web_url; } + // TODO: implement createPullRequestComment + async createPullRequestComment(prUrl: string, comment: string): Promise { + throw new Error("Method not implemented."); + } + + // 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..21d2959 --- /dev/null +++ b/src/service/runner/runner-util.ts @@ -0,0 +1,11 @@ +import { MESSAGE_ERROR_PLACEHOLDER } from "@bp/service/configs/configs.types"; + +/** + * Inject the error message in the provided `message`. + * This is inject 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); +}; \ No newline at end of file diff --git a/src/service/runner/runner.ts b/src/service/runner/runner.ts index d4ddf42..a536fa7 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 } from "./runner-util"; interface Git { gitClientType: GitClientType; @@ -92,6 +93,11 @@ export default class Runner { }); } catch(error) { this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`); + if (configs.errorNotification.enabled && configs.errorNotification.message.length > 0) { + // notify the failure as comment in the original pull request + const comment = injectError(configs.errorNotification.message, error as string); + gitApi.createPullRequestComment(configs.originalPullRequest.url, comment); + } failures.push(error as string); } } 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..d07a5f5 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: "Backporting failed: {{error}}" + }); }); 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": "Backporting failed: {{error}}" + }); + }); }); \ 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..709824e 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": "Backporting failed: {{error}}" + }); }); @@ -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": "Backporting failed: {{error}}", + }); + }); }); \ 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..153a232 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,96 @@ describe("cli runner", () => { expect(GitCLIService.prototype.clone).toBeCalledTimes(1); expect(GitCLIService.prototype.clone).toBeCalledWith("https://github.com/owner/reponame.git", cwd, "prod"); }); + + 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", "Backporting failed: Error: Mocked error: v1"); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledWith("https://api.github.com/repos/owner/reponame/pulls/2368", "Backporting failed: Error: Mocked error: v2"); + expect(GitHubClient.prototype.createPullRequestComment).toBeCalledWith("https://api.github.com/repos/owner/reponame/pulls/2368", "Backporting failed: Error: Mocked error: v3"); + }); }); \ No newline at end of file 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..f26f2ce --- /dev/null +++ b/test/service/runner/runner-util.test.ts @@ -0,0 +1,11 @@ +import { injectError } 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 placeholder in the original message", () => { + expect(injectError("Original message: {{wrong}}", "to inject")).toStrictEqual("Original message: {{wrong}}"); + }); +}); \ No newline at end of file