From 281624cff1b85a2a13cf202a44334edd1aa63e55 Mon Sep 17 00:00:00 2001 From: Dan Adajian Date: Wed, 30 Nov 2022 12:56:02 -0600 Subject: [PATCH] fix(check-merge-safety): simplify check merge safety helper (#298) * fix(check-merge-safety): simplify check * improve tests * update readme * update docs * fix code and tests * fix tests * clarify * add another test for globs * add success log * improve error logging * clean up * add space * simplify * fix log --- README.md | 6 +- dist/180.index.js | 31 +++-- dist/180.index.js.map | 2 +- src/helpers/check-merge-safety.ts | 29 +++-- test/helpers/check-merge-safety.test.ts | 160 ++++++++++-------------- 5 files changed, 109 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 1d7a9e920..e59aeedfe 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,11 @@ This workflow should be run on both `pull_request` and `push` events: The following parameters can be used for additional control over when it is safe to merge a PR: -* `paths` defines file paths to all of a repo's co-dependent projects which could be affected by a PR +* `paths`: These are the file paths to all of a repo's projects (usually paths to standalone packages) * This is useful for monorepos with multiple projects which are decoupled from each other but are affected by global dependencies. -* `override_filter_paths` defines file paths that, if out of date on a PR, will prevent merge no matter what files the PR is changing +* `override_filter_paths`: These are the file paths that, if out of date on a PR, will prevent merge no matter what files the PR is changing * example: `override_filter_paths: package.json,package-lock.json` -* `override_filter_globs` defines glob patterns for `override_filter_paths` +* `override_filter_globs`: These are glob patterns for `override_filter_paths` ### [create-pr](.github/workflows/create-pr.yml) * Opens a pull request diff --git a/dist/180.index.js b/dist/180.index.js index b6ea073be..7499c433f 100644 --- a/dist/180.index.js +++ b/dist/180.index.js @@ -11,7 +11,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ "CheckMergeSafety": () => (/* binding */ CheckMergeSafety), /* harmony export */ "checkMergeSafety": () => (/* binding */ checkMergeSafety) /* harmony export */ }); -/* harmony import */ var _types_generated__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(3476); +/* harmony import */ var _types_generated__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(3476); /* harmony import */ var _actions_github__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5438); /* harmony import */ var _actions_github__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_actions_github__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _octokit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6161); @@ -21,6 +21,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var bluebird__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(8710); /* harmony import */ var bluebird__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(bluebird__WEBPACK_IMPORTED_MODULE_4__); /* harmony import */ var _set_commit_status__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(2209); +/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(2186); +/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_actions_core__WEBPACK_IMPORTED_MODULE_6__); /* Copyright 2021 Expedia, Inc. Licensed under the Apache License, Version 2.0 (the "License"); @@ -49,7 +51,8 @@ var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _argume -class CheckMergeSafety extends _types_generated__WEBPACK_IMPORTED_MODULE_6__/* .HelperInputs */ .s { + +class CheckMergeSafety extends _types_generated__WEBPACK_IMPORTED_MODULE_7__/* .HelperInputs */ .s { } const checkMergeSafety = (inputs) => __awaiter(void 0, void 0, void 0, function* () { const isPrWorkflow = Boolean(_actions_github__WEBPACK_IMPORTED_MODULE_0__.context.issue.number); @@ -61,6 +64,7 @@ const checkMergeSafety = (inputs) => __awaiter(void 0, void 0, void 0, function* if (message) { throw new Error(message); } + _actions_core__WEBPACK_IMPORTED_MODULE_6__.info('This branch is safe to merge!'); }); const handlePushWorkflow = (inputs) => __awaiter(void 0, void 0, void 0, function* () { const pullRequests = yield (0,_utils_paginate_open_pull_requests__WEBPACK_IMPORTED_MODULE_3__/* .paginateAllOpenPullRequests */ .P)(); @@ -75,22 +79,29 @@ const getMergeSafetyMessage = (pullRequest, { paths, override_filter_paths, over const { base: { repo: { default_branch, owner: { login: baseOwner } } }, head: { ref, user: { login: username } } } = pullRequest; const { data: { files: filesWhichBranchIsBehindOn } } = yield _octokit__WEBPACK_IMPORTED_MODULE_1__/* .octokit.repos.compareCommitsWithBasehead */ .K.repos.compareCommitsWithBasehead(Object.assign(Object.assign({}, _actions_github__WEBPACK_IMPORTED_MODULE_0__.context.repo), { basehead: `${username}:${ref}...${baseOwner}:${default_branch}` })); const fileNamesWhichBranchIsBehindOn = (_a = filesWhichBranchIsBehindOn === null || filesWhichBranchIsBehindOn === void 0 ? void 0 : filesWhichBranchIsBehindOn.map(file => file.filename)) !== null && _a !== void 0 ? _a : []; - const shouldOverrideSafetyCheck = override_filter_globs - ? micromatch__WEBPACK_IMPORTED_MODULE_2___default()(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\n')).length > 0 + const globalFilesOutdatedOnBranch = override_filter_globs + ? micromatch__WEBPACK_IMPORTED_MODULE_2___default()(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\n')) : override_filter_paths - ? fileNamesWhichBranchIsBehindOn.some(changedFile => override_filter_paths.split(/[\n,]/).includes(changedFile)) - : false; - if (shouldOverrideSafetyCheck) { + ? fileNamesWhichBranchIsBehindOn.filter(changedFile => override_filter_paths.split(/[\n,]/).includes(changedFile)) + : []; + if (globalFilesOutdatedOnBranch.length) { + _actions_core__WEBPACK_IMPORTED_MODULE_6__.error(buildErrorMessage(globalFilesOutdatedOnBranch, 'global files')); return `This branch has one or more outdated global files. Please update with ${default_branch}.`; } const { data: { files: changedFiles } } = yield _octokit__WEBPACK_IMPORTED_MODULE_1__/* .octokit.repos.compareCommitsWithBasehead */ .K.repos.compareCommitsWithBasehead(Object.assign(Object.assign({}, _actions_github__WEBPACK_IMPORTED_MODULE_0__.context.repo), { basehead: `${baseOwner}:${default_branch}...${username}:${ref}` })); const changedFileNames = changedFiles === null || changedFiles === void 0 ? void 0 : changedFiles.map(file => file.filename); - const projectDirectories = paths === null || paths === void 0 ? void 0 : paths.split(/[\n,]/); - const isUnsafeToMerge = projectDirectories === null || projectDirectories === void 0 ? void 0 : projectDirectories.some(dir => fileNamesWhichBranchIsBehindOn.some(file => file.includes(dir)) && (changedFileNames === null || changedFileNames === void 0 ? void 0 : changedFileNames.some(file => file.includes(dir)))); - if (isUnsafeToMerge) { + const allProjectDirectories = paths === null || paths === void 0 ? void 0 : paths.split(/[\n,]/); + const changedProjectsOutdatedOnBranch = allProjectDirectories === null || allProjectDirectories === void 0 ? void 0 : allProjectDirectories.filter(dir => fileNamesWhichBranchIsBehindOn.some(file => file.includes(dir)) && (changedFileNames === null || changedFileNames === void 0 ? void 0 : changedFileNames.some(file => file.includes(dir)))); + if (changedProjectsOutdatedOnBranch === null || changedProjectsOutdatedOnBranch === void 0 ? void 0 : changedProjectsOutdatedOnBranch.length) { + _actions_core__WEBPACK_IMPORTED_MODULE_6__.error(buildErrorMessage(changedProjectsOutdatedOnBranch, 'projects')); return `This branch has one or more outdated projects. Please update with ${default_branch}.`; } }); +const buildErrorMessage = (paths, pathType) => ` +The following ${pathType} are outdated on this branch: + +${paths.map(path => `* ${path}`).join('\n')} +`; /***/ }), diff --git a/dist/180.index.js.map b/dist/180.index.js.map index e6fef1372..1432bb78c 100644 --- a/dist/180.index.js.map +++ b/dist/180.index.js.map @@ -1 +1 @@ -{"version":3,"file":"180.index.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;AAWA;;;;;;;;;;AAEA;AACA;AACA;AACA;AAEA;AACA;AACA;AAEA;AAIA;AAEA;AACA;AACA;AACA;AACA;AACA;AAEA;AAEA;AACA;AACA;AACA;AAEA;AACA;AACA;AACA;AACA;AACA;AAOA;AACA;AAEA;;AAIA;AAYA;AAMA;AAEA;AACA;AACA;AACA;AACA;AAEA;AACA;AACA;AAEA;AAMA;AACA;AACA;AAIA;AACA;AACA;AACA;;;;;;;;;;;;;;;;;;;AC1GA;;;;;;;;;;;AAWA;;;;;;;;;;AAGA;AACA;AACA;AACA;AAEA;AAAA;;AACA;AACA;AACA;AAGA;AAAA;AAEA;AACA;AAGA;AAGA;AAIA;;;;;;;;;;;;;;;;;;ACtCA;;;;;;;;;;;AAWA;AAEA;AACA;AACA;AAEA;AACA;;;;;;;;;;;AClBA;;;;;;;;;;;AAWA;AAEA;AAoCA;;;;;;;;;;;;;;ACjDA;;;;;;;;;;;AAWA;;;;;;;;;;AAGA;AACA;AAEA;AACA;AAQA;AACA;AACA;AACA;AACA","sources":["webpack://github-helpers/./src/helpers/check-merge-safety.ts","webpack://github-helpers/./src/helpers/set-commit-status.ts","webpack://github-helpers/./src/octokit.ts","webpack://github-helpers/./src/types/generated.ts","webpack://github-helpers/./src/utils/paginate-open-pull-requests.ts"],"sourcesContent":["/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { HelperInputs } from '../types/generated';\nimport { context } from '@actions/github';\nimport { octokit } from '../octokit';\nimport micromatch from 'micromatch';\nimport { PullRequest } from '../types/github';\nimport { paginateAllOpenPullRequests } from '../utils/paginate-open-pull-requests';\nimport { map } from 'bluebird';\nimport { setCommitStatus } from './set-commit-status';\n\nexport class CheckMergeSafety extends HelperInputs {\n paths?: string;\n override_filter_paths?: string;\n override_filter_globs?: string;\n}\n\nexport const checkMergeSafety = async (inputs: CheckMergeSafety) => {\n const isPrWorkflow = Boolean(context.issue.number);\n if (!isPrWorkflow) {\n return handlePushWorkflow(inputs);\n }\n const { data: pullRequest } = await octokit.pulls.get({ pull_number: context.issue.number, ...context.repo });\n\n const message = await getMergeSafetyMessage(pullRequest, inputs);\n\n if (message) {\n throw new Error(message);\n }\n};\n\nconst handlePushWorkflow = async (inputs: CheckMergeSafety) => {\n const pullRequests = await paginateAllOpenPullRequests();\n const filteredPullRequests = pullRequests.filter(({ base, draft }) => !draft && base.ref === base.repo.default_branch);\n return map(filteredPullRequests, async pullRequest => {\n const message = await getMergeSafetyMessage(pullRequest as PullRequest, inputs);\n await setCommitStatus({\n sha: pullRequest.head.sha,\n state: message ? 'failure' : 'success',\n context: 'Merge Safety',\n description: message ?? 'This branch is safe to merge!',\n ...context.repo\n });\n });\n};\n\nconst getMergeSafetyMessage = async (\n pullRequest: PullRequest,\n { paths, override_filter_paths, override_filter_globs }: CheckMergeSafety\n) => {\n const {\n base: {\n repo: {\n default_branch,\n owner: { login: baseOwner }\n }\n },\n head: {\n ref,\n user: { login: username }\n }\n } = pullRequest;\n const {\n data: { files: filesWhichBranchIsBehindOn }\n } = await octokit.repos.compareCommitsWithBasehead({\n ...context.repo,\n basehead: `${username}:${ref}...${baseOwner}:${default_branch}`\n });\n const fileNamesWhichBranchIsBehindOn = filesWhichBranchIsBehindOn?.map(file => file.filename) ?? [];\n\n const shouldOverrideSafetyCheck = override_filter_globs\n ? micromatch(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\\n')).length > 0\n : override_filter_paths\n ? fileNamesWhichBranchIsBehindOn.some(changedFile => override_filter_paths.split(/[\\n,]/).includes(changedFile))\n : false;\n\n if (shouldOverrideSafetyCheck) {\n return `This branch has one or more outdated global files. Please update with ${default_branch}.`;\n }\n\n const {\n data: { files: changedFiles }\n } = await octokit.repos.compareCommitsWithBasehead({\n ...context.repo,\n basehead: `${baseOwner}:${default_branch}...${username}:${ref}`\n });\n const changedFileNames = changedFiles?.map(file => file.filename);\n const projectDirectories = paths?.split(/[\\n,]/);\n const isUnsafeToMerge = projectDirectories?.some(\n dir => fileNamesWhichBranchIsBehindOn.some(file => file.includes(dir)) && changedFileNames?.some(file => file.includes(dir))\n );\n\n if (isUnsafeToMerge) {\n return `This branch has one or more outdated projects. Please update with ${default_branch}.`;\n }\n};\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { PipelineState } from '../types/github';\nimport { HelperInputs } from '../types/generated';\nimport { context as githubContext } from '@actions/github';\nimport { map } from 'bluebird';\nimport { octokit } from '../octokit';\n\nexport class SetCommitStatus extends HelperInputs {\n sha = '';\n context = '';\n state = '';\n description?: string;\n target_url?: string;\n}\n\nexport const setCommitStatus = async ({ sha, context, state, description, target_url }: SetCommitStatus) => {\n await map(context.split('\\n').filter(Boolean), context =>\n octokit.repos.createCommitStatus({\n sha,\n context,\n state: state as PipelineState,\n description,\n target_url,\n ...githubContext.repo\n })\n );\n};\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as core from '@actions/core';\nimport * as fetch from '@adobe/node-fetch-retry';\nimport { getOctokit } from '@actions/github';\n\nconst githubToken = core.getInput('github_token', { required: true });\nexport const { rest: octokit, graphql: octokitGraphql } = getOctokit(githubToken, { request: { fetch } });\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport class HelperInputs {\n helper?: string;\n github_token?: string;\n body?: string;\n project_name?: string;\n project_destination_column_name?: string;\n note?: string;\n project_origin_column_name?: string;\n sha?: string;\n context?: string;\n state?: string;\n description?: string;\n target_url?: string;\n environment?: string;\n environment_url?: string;\n label?: string;\n labels?: string;\n paths?: string;\n extensions?: string;\n override_filter_paths?: string;\n batches?: string;\n pattern?: string;\n teams?: string;\n login?: string;\n paths_no_filter?: string;\n slack_webhook_url?: string;\n number_of_assignees?: string;\n globs?: string;\n override_filter_globs?: string;\n title?: string;\n seconds?: string;\n pull_number?: string;\n base?: string;\n head?: string;\n days?: string;\n no_evict_upon_conflict?: string;\n}\n","/*\nCopyright 2022 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { PullRequestList } from '../types/github';\nimport { octokit } from '../octokit';\nimport { context } from '@actions/github';\n\nexport const paginateAllOpenPullRequests = async (page = 1): Promise => {\n const response = await octokit.pulls.list({\n state: 'open',\n sort: 'updated',\n direction: 'desc',\n per_page: 100,\n page,\n ...context.repo\n });\n if (!response.data.length) {\n return [];\n }\n return response.data.concat(await paginateAllOpenPullRequests(page + 1));\n};\n"],"names":[],"sourceRoot":""} \ No newline at end of file +{"version":3,"file":"180.index.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;AAWA;;;;;;;;;;AAEA;AACA;AACA;AACA;AAEA;AACA;AACA;AACA;AAEA;AAIA;AAEA;AACA;AACA;AACA;AACA;AACA;AAEA;AAEA;AACA;AACA;AAEA;AACA;AAEA;AACA;AACA;AACA;AACA;AACA;AAOA;AACA;AAEA;;AAIA;AAYA;AAMA;AAEA;AACA;AACA;AACA;AACA;AAEA;AACA;AACA;AACA;AAEA;AAMA;AACA;AAEA;AAIA;AACA;AACA;AACA;AACA;AAEA;AAEA;;AAEA;AACA;;;;;;;;;;;;;;;;;;;ACvHA;;;;;;;;;;;AAWA;;;;;;;;;;AAGA;AACA;AACA;AACA;AAEA;AAAA;;AACA;AACA;AACA;AAGA;AAAA;AAEA;AACA;AAGA;AAGA;AAIA;;;;;;;;;;;;;;;;;;ACtCA;;;;;;;;;;;AAWA;AAEA;AACA;AACA;AAEA;AACA;;;;;;;;;;;AClBA;;;;;;;;;;;AAWA;AAEA;AAoCA;;;;;;;;;;;;;;ACjDA;;;;;;;;;;;AAWA;;;;;;;;;;AAGA;AACA;AAEA;AACA;AAQA;AACA;AACA;AACA;AACA","sources":["webpack://github-helpers/./src/helpers/check-merge-safety.ts","webpack://github-helpers/./src/helpers/set-commit-status.ts","webpack://github-helpers/./src/octokit.ts","webpack://github-helpers/./src/types/generated.ts","webpack://github-helpers/./src/utils/paginate-open-pull-requests.ts"],"sourcesContent":["/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { HelperInputs } from '../types/generated';\nimport { context } from '@actions/github';\nimport { octokit } from '../octokit';\nimport micromatch from 'micromatch';\nimport { PullRequest } from '../types/github';\nimport { paginateAllOpenPullRequests } from '../utils/paginate-open-pull-requests';\nimport { map } from 'bluebird';\nimport { setCommitStatus } from './set-commit-status';\nimport * as core from '@actions/core';\n\nexport class CheckMergeSafety extends HelperInputs {\n paths?: string;\n override_filter_paths?: string;\n override_filter_globs?: string;\n}\n\nexport const checkMergeSafety = async (inputs: CheckMergeSafety) => {\n const isPrWorkflow = Boolean(context.issue.number);\n if (!isPrWorkflow) {\n return handlePushWorkflow(inputs);\n }\n const { data: pullRequest } = await octokit.pulls.get({ pull_number: context.issue.number, ...context.repo });\n\n const message = await getMergeSafetyMessage(pullRequest, inputs);\n\n if (message) {\n throw new Error(message);\n }\n\n core.info('This branch is safe to merge!');\n};\n\nconst handlePushWorkflow = async (inputs: CheckMergeSafety) => {\n const pullRequests = await paginateAllOpenPullRequests();\n const filteredPullRequests = pullRequests.filter(({ base, draft }) => !draft && base.ref === base.repo.default_branch);\n return map(filteredPullRequests, async pullRequest => {\n const message = await getMergeSafetyMessage(pullRequest as PullRequest, inputs);\n await setCommitStatus({\n sha: pullRequest.head.sha,\n state: message ? 'failure' : 'success',\n context: 'Merge Safety',\n description: message ?? 'This branch is safe to merge!',\n ...context.repo\n });\n });\n};\n\nconst getMergeSafetyMessage = async (\n pullRequest: PullRequest,\n { paths, override_filter_paths, override_filter_globs }: CheckMergeSafety\n) => {\n const {\n base: {\n repo: {\n default_branch,\n owner: { login: baseOwner }\n }\n },\n head: {\n ref,\n user: { login: username }\n }\n } = pullRequest;\n const {\n data: { files: filesWhichBranchIsBehindOn }\n } = await octokit.repos.compareCommitsWithBasehead({\n ...context.repo,\n basehead: `${username}:${ref}...${baseOwner}:${default_branch}`\n });\n const fileNamesWhichBranchIsBehindOn = filesWhichBranchIsBehindOn?.map(file => file.filename) ?? [];\n\n const globalFilesOutdatedOnBranch = override_filter_globs\n ? micromatch(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\\n'))\n : override_filter_paths\n ? fileNamesWhichBranchIsBehindOn.filter(changedFile => override_filter_paths.split(/[\\n,]/).includes(changedFile))\n : [];\n\n if (globalFilesOutdatedOnBranch.length) {\n core.error(buildErrorMessage(globalFilesOutdatedOnBranch, 'global files'));\n return `This branch has one or more outdated global files. Please update with ${default_branch}.`;\n }\n\n const {\n data: { files: changedFiles }\n } = await octokit.repos.compareCommitsWithBasehead({\n ...context.repo,\n basehead: `${baseOwner}:${default_branch}...${username}:${ref}`\n });\n const changedFileNames = changedFiles?.map(file => file.filename);\n const allProjectDirectories = paths?.split(/[\\n,]/);\n\n const changedProjectsOutdatedOnBranch = allProjectDirectories?.filter(\n dir => fileNamesWhichBranchIsBehindOn.some(file => file.includes(dir)) && changedFileNames?.some(file => file.includes(dir))\n );\n\n if (changedProjectsOutdatedOnBranch?.length) {\n core.error(buildErrorMessage(changedProjectsOutdatedOnBranch, 'projects'));\n return `This branch has one or more outdated projects. Please update with ${default_branch}.`;\n }\n};\n\nconst buildErrorMessage = (paths: string[], pathType: 'projects' | 'global files') =>\n `\nThe following ${pathType} are outdated on this branch:\n\n${paths.map(path => `* ${path}`).join('\\n')}\n`;\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { PipelineState } from '../types/github';\nimport { HelperInputs } from '../types/generated';\nimport { context as githubContext } from '@actions/github';\nimport { map } from 'bluebird';\nimport { octokit } from '../octokit';\n\nexport class SetCommitStatus extends HelperInputs {\n sha = '';\n context = '';\n state = '';\n description?: string;\n target_url?: string;\n}\n\nexport const setCommitStatus = async ({ sha, context, state, description, target_url }: SetCommitStatus) => {\n await map(context.split('\\n').filter(Boolean), context =>\n octokit.repos.createCommitStatus({\n sha,\n context,\n state: state as PipelineState,\n description,\n target_url,\n ...githubContext.repo\n })\n );\n};\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as core from '@actions/core';\nimport * as fetch from '@adobe/node-fetch-retry';\nimport { getOctokit } from '@actions/github';\n\nconst githubToken = core.getInput('github_token', { required: true });\nexport const { rest: octokit, graphql: octokitGraphql } = getOctokit(githubToken, { request: { fetch } });\n","/*\nCopyright 2021 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport class HelperInputs {\n helper?: string;\n github_token?: string;\n body?: string;\n project_name?: string;\n project_destination_column_name?: string;\n note?: string;\n project_origin_column_name?: string;\n sha?: string;\n context?: string;\n state?: string;\n description?: string;\n target_url?: string;\n environment?: string;\n environment_url?: string;\n label?: string;\n labels?: string;\n paths?: string;\n extensions?: string;\n override_filter_paths?: string;\n batches?: string;\n pattern?: string;\n teams?: string;\n login?: string;\n paths_no_filter?: string;\n slack_webhook_url?: string;\n number_of_assignees?: string;\n globs?: string;\n override_filter_globs?: string;\n title?: string;\n seconds?: string;\n pull_number?: string;\n base?: string;\n head?: string;\n days?: string;\n no_evict_upon_conflict?: string;\n}\n","/*\nCopyright 2022 Expedia, Inc.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n https://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { PullRequestList } from '../types/github';\nimport { octokit } from '../octokit';\nimport { context } from '@actions/github';\n\nexport const paginateAllOpenPullRequests = async (page = 1): Promise => {\n const response = await octokit.pulls.list({\n state: 'open',\n sort: 'updated',\n direction: 'desc',\n per_page: 100,\n page,\n ...context.repo\n });\n if (!response.data.length) {\n return [];\n }\n return response.data.concat(await paginateAllOpenPullRequests(page + 1));\n};\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/src/helpers/check-merge-safety.ts b/src/helpers/check-merge-safety.ts index d6799b77e..48f1a8e84 100644 --- a/src/helpers/check-merge-safety.ts +++ b/src/helpers/check-merge-safety.ts @@ -19,6 +19,7 @@ import { PullRequest } from '../types/github'; import { paginateAllOpenPullRequests } from '../utils/paginate-open-pull-requests'; import { map } from 'bluebird'; import { setCommitStatus } from './set-commit-status'; +import * as core from '@actions/core'; export class CheckMergeSafety extends HelperInputs { paths?: string; @@ -38,6 +39,8 @@ export const checkMergeSafety = async (inputs: CheckMergeSafety) => { if (message) { throw new Error(message); } + + core.info('This branch is safe to merge!'); }; const handlePushWorkflow = async (inputs: CheckMergeSafety) => { @@ -79,13 +82,14 @@ const getMergeSafetyMessage = async ( }); const fileNamesWhichBranchIsBehindOn = filesWhichBranchIsBehindOn?.map(file => file.filename) ?? []; - const shouldOverrideSafetyCheck = override_filter_globs - ? micromatch(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\n')).length > 0 + const globalFilesOutdatedOnBranch = override_filter_globs + ? micromatch(fileNamesWhichBranchIsBehindOn, override_filter_globs.split('\n')) : override_filter_paths - ? fileNamesWhichBranchIsBehindOn.some(changedFile => override_filter_paths.split(/[\n,]/).includes(changedFile)) - : false; + ? fileNamesWhichBranchIsBehindOn.filter(changedFile => override_filter_paths.split(/[\n,]/).includes(changedFile)) + : []; - if (shouldOverrideSafetyCheck) { + if (globalFilesOutdatedOnBranch.length) { + core.error(buildErrorMessage(globalFilesOutdatedOnBranch, 'global files')); return `This branch has one or more outdated global files. Please update with ${default_branch}.`; } @@ -96,12 +100,21 @@ const getMergeSafetyMessage = async ( basehead: `${baseOwner}:${default_branch}...${username}:${ref}` }); const changedFileNames = changedFiles?.map(file => file.filename); - const projectDirectories = paths?.split(/[\n,]/); - const isUnsafeToMerge = projectDirectories?.some( + const allProjectDirectories = paths?.split(/[\n,]/); + + const changedProjectsOutdatedOnBranch = allProjectDirectories?.filter( dir => fileNamesWhichBranchIsBehindOn.some(file => file.includes(dir)) && changedFileNames?.some(file => file.includes(dir)) ); - if (isUnsafeToMerge) { + if (changedProjectsOutdatedOnBranch?.length) { + core.error(buildErrorMessage(changedProjectsOutdatedOnBranch, 'projects')); return `This branch has one or more outdated projects. Please update with ${default_branch}.`; } }; + +const buildErrorMessage = (paths: string[], pathType: 'projects' | 'global files') => + ` +The following ${pathType} are outdated on this branch: + +${paths.map(path => `* ${path}`).join('\n')} +`; diff --git a/test/helpers/check-merge-safety.test.ts b/test/helpers/check-merge-safety.test.ts index 2f13757dd..548b241b0 100644 --- a/test/helpers/check-merge-safety.test.ts +++ b/test/helpers/check-merge-safety.test.ts @@ -19,6 +19,9 @@ import { setCommitStatus } from '../../src/helpers/set-commit-status'; import { paginateAllOpenPullRequests } from '../../src/utils/paginate-open-pull-requests'; const branchName = 'some-branch-name'; +const username = 'username'; +const baseOwner = 'owner'; +const defaultBranch = 'main'; jest.mock('../../src/utils/paginate-open-pull-requests'); jest.mock('../../src/helpers/set-commit-status'); @@ -33,8 +36,8 @@ jest.mock('@actions/github', () => ({ pulls: { get: jest.fn(() => ({ data: { - base: { repo: { default_branch: 'main', owner: { login: 'owner' } } }, - head: { ref: branchName, user: { login: 'username' } } + base: { repo: { default_branch: defaultBranch, owner: { login: baseOwner } } }, + head: { ref: branchName, user: { login: username } } } })) } @@ -42,159 +45,122 @@ jest.mock('@actions/github', () => ({ })) })); +const mockGithubRequests = (filesOutOfDate: string[], changedFilesOnPr: string[]) => { + (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { + const changedFiles = basehead === `${username}:${branchName}...${baseOwner}:${defaultBranch}` ? filesOutOfDate : changedFilesOnPr; + return { + data: { + files: changedFiles.map(file => ({ filename: file })) + } + }; + }); +}; + +const allProjectPaths = ['packages/package-1/', 'packages/package-2/', 'packages/package-3/'].join('\n'); + describe('checkMergeSafety', () => { - it('should throw error when branch is out of date on the provided project path', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'some-branch-name...main' - ? ['packages/package-1/src/file1.ts', 'packages/package-2/src/file.ts'] - : ['README.md', 'packages/package-1/src/file2.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + it('should throw error when branch is out of date for a changed project', async () => { + const filesOutOfDate = ['packages/package-1/src/another-file.ts']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1', + paths: allProjectPaths, ...context.repo }) ).rejects.toThrowError('This branch has one or more outdated projects. Please update with main.'); }); - it('should throw error when branch is out of date on a co-dependent project path', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'some-branch-name...main' ? ['packages/package-2/src/file.ts'] : ['README.md', 'packages/package-1/src/file.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + it('should not throw error when branch is only out of date for an unchanged project', async () => { + const filesOutOfDate = ['packages/package-2/src/another-file.ts']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1\npackages/package-2', + paths: allProjectPaths, ...context.repo }) - ).rejects.toThrowError('This branch has one or more outdated projects. Please update with main.'); + ).resolves.not.toThrowError(); }); it('should not throw error when branch is fully up to date', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = basehead === 'username:some-branch-name...owner:main' ? [] : ['README.md', 'packages/package-1/src/file2.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + const filesOutOfDate: string[] = []; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1', + paths: allProjectPaths, ...context.repo }) ).resolves.not.toThrowError(); }); - it('should throw error when branch is out of date on override filter paths, even when project paths are up to date', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'username:some-branch-name...owner:main' - ? ['packages/package-1/src/file1.ts', 'package.json'] - : ['README.md', 'packages/package-3/src/file3.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + it('should throw error when branch is out of date on override filter paths, even when changed project paths are up to date', async () => { + const filesOutOfDate = ['packages/package-2/src/file1.ts', 'package.json']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1', + paths: allProjectPaths, override_filter_paths: 'package.json\npackage-lock.json', ...context.repo }) ).rejects.toThrowError('This branch has one or more outdated global files. Please update with main.'); }); - it('should throw error when branch is out of date on override glob paths, even when project paths are up to date', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'username:some-branch-name...owner:main' - ? ['packages/package-1/src/file1.ts', 'package.json'] - : ['README.md', 'packages/package-3/src/file3.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + it('should throw error when branch is out of date on override glob paths, even when changed project paths are up to date', async () => { + const filesOutOfDate = ['packages/package-2/src/file1.ts', 'README.md']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1', - override_filter_globs: 'packages/**', + paths: allProjectPaths, + override_filter_globs: '**.md', ...context.repo }) ).rejects.toThrowError('This branch has one or more outdated global files. Please update with main.'); }); - it('should not throw error when project paths are up to date', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'username:some-branch-name...owner:main' - ? ['packages/package-1/src/file1.ts', 'packages/package-2/src/file2.ts'] - : ['README.md', 'packages/package-3/src/file3.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + it('should throw error when branch is out of date on override glob paths using negation glob pattern', async () => { + const filesOutOfDate = ['README.md']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); await expect( checkMergeSafety({ - paths: 'packages/package-1', + paths: allProjectPaths, + override_filter_globs: '!packages/**', ...context.repo }) - ).resolves.not.toThrowError(); + ).rejects.toThrowError('This branch has one or more outdated global files. Please update with main.'); }); it('should set merge safety commit status on all open prs', async () => { - (octokit.repos.compareCommitsWithBasehead as unknown as Mocktokit).mockImplementation(async ({ basehead }) => { - const changedFiles = - basehead === 'username:some-branch-name...owner:main' - ? ['packages/package-1/src/file1.ts', 'packages/package-2/src/file2.ts'] - : ['README.md', 'packages/package-3/src/file3.ts']; - return { - data: { - files: changedFiles.map(file => ({ filename: file })) - } - }; - }); + const filesOutOfDate = ['packages/package-2/src/file1.ts', 'packages/package-3/src/file2.ts']; + const changedFilesOnPr = ['packages/package-1/src/some-file.ts']; + mockGithubRequests(filesOutOfDate, changedFilesOnPr); // eslint-disable-next-line functional/immutable-data,@typescript-eslint/no-explicit-any context.issue.number = undefined as any; // couldn't figure out a way to mock out this issue number in a cleaner way ¯\_(ツ)_/¯ (paginateAllOpenPullRequests as jest.Mock).mockResolvedValue([ { - head: { sha: '123', user: { login: 'owner' } }, - base: { ref: 'main', repo: { default_branch: 'main', owner: { login: 'owner' } } } + head: { sha: '123', ref: branchName, user: { login: username } }, + base: { ref: defaultBranch, repo: { default_branch: defaultBranch, owner: { login: baseOwner } } } }, { - head: { sha: '456', user: { login: 'owner' } }, - base: { ref: 'main', repo: { default_branch: 'main', owner: { login: 'owner' } } } + head: { sha: '456', ref: branchName, user: { login: username } }, + base: { ref: defaultBranch, repo: { default_branch: defaultBranch, owner: { login: baseOwner } } } }, { - head: { sha: '789', user: { login: 'owner' } }, - base: { ref: 'some-other-branch', repo: { default_branch: 'main', owner: { login: 'owner' } } } + head: { sha: '789', ref: branchName, user: { login: username } }, + base: { ref: 'some-other-branch', repo: { default_branch: defaultBranch, owner: { login: baseOwner } } } }, { - head: { sha: '000', user: { login: 'owner' } }, - base: { ref: 'main', repo: { default_branch: 'main', owner: { login: 'owner' } } }, + head: { sha: '000', ref: branchName, user: { login: username } }, + base: { ref: defaultBranch, repo: { default_branch: defaultBranch, owner: { login: baseOwner } } }, draft: true } ]); await checkMergeSafety({ - paths: 'packages/package-1', + paths: allProjectPaths, ...context.repo }); expect(setCommitStatus).toHaveBeenCalledWith({