From e097d1c0896ac03336b27eb91f0ba767848ac3a5 Mon Sep 17 00:00:00 2001 From: Olabode Lawal-Shittabey Date: Mon, 2 Sep 2024 17:46:01 +0100 Subject: [PATCH] feat: allow conditional skip on success and fail comments (#874) * feat: add `failCommentCondition` to `fail` script * feat: add `successCommentCondition` and `failCommentCondition` to `resolve-config` * feat: add `successCommentCondition` and `failCommentCondition` to `success` scipt * fix(build): lint * test: add `fail` case `Does not post comments if "failCommentCondition" is "false"` * test: add `fail` case for `Does not post comments on existing issues when "failCommentCondition" is "false"` * fix(build): lint * test(fail): add case for `Post new issue if none exists yet, but don't comment on existing issues when "failCommentCondition" is disallows it` * test(success): add case `Does not comment on issues/PR if "successCommentCondition" is "false"` * Update test/fail.test.js Co-authored-by: Jonas Schubert * test(success): add case for `Add comment and label to found issues/associatedPR using the "successCommentCondition"` * nits * test(success): add case for `Does not comment/label found associatedPR when "successCommentCondition" disables it` * test(success): improve case `Does not comment/label found associatedPR when "successCommentCondition" disables it` * doc: add documentation for `successCommentCondition` and `failCommentCondition` * Update lib/success.js Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com> * refactor: modify `failTitle`, `failComment` and `successComment` false deprecation message * refator: implement early return in `fail` and `success` lifecyccle helper function * Update README.md Co-authored-by: Jonas Schubert * remove `failCommentCondition` example wrong description * build: fix lint * feat: add validators for `successCommentCondition` and `failCommentCondition` * feat: add `buildAssociatedPRs` to create pr object in form of issue object with pull_request property * doc: update README.md * build: fix lint * build: fix failing test `Add custom comment and labels` * feat: request more field for `associatedPRs` via graphql and improve `buildAssociatedPRs` response object with * test: modify integration tests * test: modify `success` unit tests * build: fix lint * build: fix failing tests * feat: add `__typename` to `issue.user` object * test: add new case `Does not comment/label associatedPR created by "Bots"` * test: modify `pull_request` mock value to `boolean` * chore(test): clean debug comments * feat: re-integrate `buildAssociatedPRs` * feat: re-introduced and modifed `loadSingleCommitAssociatedPRs` * refactor: introduce `parsedIssues` as returned value from `prs**.body` and `commits**.message` * feat: added `buildRelatedIssuesQuery` util graphql query builder * feat: implement computation for `responseRelatedIssues` * fix: correct `number` arg type in `buildRelatedIssuesQuery` * refactor: extract common field accross graphql queries to `baseFields` * refactor: transform `buildAssociatedPRs` to `buildIssuesOrPRsFromResponseNode` with ability to build both `PRs` and `Issues` object * feat: integrate `buildIssuesOrPRsFromResponseNode` * feat: implement `issueOrPR` for correctly addressing issues and pr in logs * feat: implement improved chunk operation helper `inChunks` and integrate in pr and issues fetch * build: fix lints * test: update `integrations` test * test: address PR and Issue naming in logs in `success` * refactor: why the `Promise.all()`? Removed it haha * feat: set default `type` param in `buildIssuesOrPRsFromResponseNode` * feat: address edge cases * test: fixed matchers in graphql request in `success` units * build: lint * docs: add ignore bots pr/issues example * fix: user issue `number` over `id` * test: improve case `'Does not comment/label associatedPR and relatedIssues created by "Bots"'` * doc: modify `buildIssuesOrPRsFromResponseNode` documentation --------- Co-authored-by: Jonas Schubert Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com> --- README.md | 82 +- lib/fail.js | 18 +- lib/resolve-config.js | 4 + lib/success.js | 295 +++++++- lib/verify.js | 2 + test/fail.test.js | 146 ++++ test/integration.test.js | 10 +- test/success.test.js | 1518 ++++++++++++++++++++++++++++++++++---- 8 files changed, 1873 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index e03e0ab9..b72b0d22 100644 --- a/README.md +++ b/README.md @@ -80,24 +80,26 @@ When using the _GITHUB_TOKEN_, the **minimum required permissions** are: ### Options -| Option | Description | Default | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `githubUrl` | The GitHub server endpoint. | `GH_URL` or `GITHUB_URL` environment variable. | -| `githubApiPathPrefix` | The GitHub API prefix, relative to `githubUrl`. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. | -| `githubApiUrl` | The GitHub API endpoint. Note that this overwrites `githubApiPathPrefix`. | `GITHUB_API_URL` environment variable. | -| `proxy` | The proxy to use to access the GitHub API. Set to `false` to disable usage of proxy. See [proxy](#proxy). | `HTTP_PROXY` environment variable. | -| `assets` | An array of files to upload to the release. See [assets](#assets). | - | -| `successComment` | The comment to add to each issue and pull request resolved by the release. Set to `false` to disable commenting on issues and pull requests. See [successComment](#successcomment). | `:tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on [GitHub release]()` | -| `failComment` | The content of the issue created when a release fails. Set to `false` to disable opening an issue when a release fails. See [failComment](#failcomment). | Friendly message with links to **semantic-release** documentation and support, with the list of errors that caused the release to fail. | -| `failTitle` | The title of the issue created when a release fails. Set to `false` to disable opening an issue when a release fails. | `The automated release is failing 🚨` | -| `labels` | The [labels](https://help.github.com/articles/about-labels) to add to the issue created when a release fails. Set to `false` to not add any label. | `['semantic-release']` | -| `assignees` | The [assignees](https://help.github.com/articles/assigning-issues-and-pull-requests-to-other-github-users) to add to the issue created when a release fails. | - | -| `releasedLabels` | The [labels](https://help.github.com/articles/about-labels) to add to each issue and pull request resolved by the release. Set to `false` to not add any label. See [releasedLabels](#releasedlabels). | `['released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>']- | -| `addReleases` | Will add release links to the GitHub Release. Can be `false`, `"bottom"` or `"top"`. See [addReleases](#addReleases). | `false` | -| `draftRelease` | A boolean indicating if a GitHub Draft Release should be created instead of publishing an actual GitHub Release. | `false` | -| `releaseNameTemplate` | A [Lodash template](https://lodash.com/docs#template) to customize the github release's name | `<%= nextRelease.name %>` | -| `releaseBodyTemplate` | A [Lodash template](https://lodash.com/docs#template) to customize the github release's body | `<%= nextRelease.notes %>` | -| `discussionCategoryName` | The category name in which to create a linked discussion to the release. Set to `false` to disable creating discussion for a release. | `false` | +| Option | Description | Default | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `githubUrl` | The GitHub server endpoint. | `GH_URL` or `GITHUB_URL` environment variable. | +| `githubApiPathPrefix` | The GitHub API prefix, relative to `githubUrl`. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. | +| `githubApiUrl` | The GitHub API endpoint. Note that this overwrites `githubApiPathPrefix`. | `GITHUB_API_URL` environment variable. | +| `proxy` | The proxy to use to access the GitHub API. Set to `false` to disable usage of proxy. See [proxy](#proxy). | `HTTP_PROXY` environment variable. | +| `assets` | An array of files to upload to the release. See [assets](#assets). | - | +| `successComment` | The comment to add to each issue and pull request resolved by the release. Set to `false` to disable commenting on issues and pull requests. See [successComment](#successcomment). | `:tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on [GitHub release]()` | +| `successCommentCondition` | Use this as condition, when to comment on issues or pull requests. See [successCommentCondition](#successCommentCondition) | - | +| `failComment` | The content of the issue created when a release fails. Set to `false` to disable opening an issue when a release fails. See [failComment](#failcomment). | Friendly message with links to **semantic-release** documentation and support, with the list of errors that caused the release to fail. | +| `failTitle` | The title of the issue created when a release fails. Set to `false` to disable opening an issue when a release fails. | `The automated release is failing 🚨` | +| `failCommentCondition` | Use this as condition, when to comment on or create an issues in case of failures. See [failCommentCondition](#failCommentCondition). | - | +| `labels` | The [labels](https://help.github.com/articles/about-labels) to add to the issue created when a release fails. Set to `false` to not add any label. | `['semantic-release']` | +| `assignees` | The [assignees](https://help.github.com/articles/assigning-issues-and-pull-requests-to-other-github-users) to add to the issue created when a release fails. | - | +| `releasedLabels` | The [labels](https://help.github.com/articles/about-labels) to add to each issue and pull request resolved by the release. Set to `false` to not add any label. See [releasedLabels](#releasedlabels). | `['released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>']- | +| `addReleases` | Will add release links to the GitHub Release. Can be `false`, `"bottom"` or `"top"`. See [addReleases](#addReleases). | `false` | +| `draftRelease` | A boolean indicating if a GitHub Draft Release should be created instead of publishing an actual GitHub Release. | `false` | +| `releaseNameTemplate` | A [Lodash template](https://lodash.com/docs#template) to customize the github release's name | `<%= nextverison.name %>` | +| `releaseBodyTemplate` | A [Lodash template](https://lodash.com/docs#template) to customize the github release's body | `<%= nextverison.notes %>` | +| `discussionCategoryName` | The category name in which to create a linked discussion to the release. Set to `false` to disable creating discussion for a release. | `false` | #### proxy @@ -181,6 +183,29 @@ The `successComment` `This ${issue.pull_request ? 'pull request' : 'issue'} is i > This pull request is included in version 1.0.0 +#### successCommentCondition + +The message for the issue comments is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: + +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `branch` | `Object` with `name`, `type`, `channel`, `range` and `prerelease` properties of the branch from which the release is done. | +| `lastRelease` | `Object` with `version`, `channel`, `gitTag` and `gitHead` of the last release. | +| `nextRelease` | `Object` with `version`, `channel`, `gitTag`, `gitHead` and `notes` of the release being done. | +| `commits` | `Array` of commit `Object`s with `hash`, `subject`, `body` `message` and `author`. | +| `releases` | `Array` with a release `Object`s for each release published, with optional release data such as `name` and `url`. | +| `issue` | A [GitHub API Pull Request object](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request) for pull requests related to a commit, or an `Object` with the `number` property for issues resolved via [keywords](https://help.github.com/articles/closing-issues-using-keywords) | + +##### successCommentCondition example + +- do not create any comments at all: set to `false` or templating: `"<% return false; %>"` +- to only comment on issues: `"<% return !issue.pull_request; %>"` +- to only comment on pull requests: `"<% return issue.pull_request; %>"` +- to avoid comment on PRs or issues created by Bots: `"<% return issue.user.type !== 'Bot'; %>"` +- you can use labels to filter issues: `"<% return issue.labels?.includes('semantic-release-relevant'); %>"` + +> check the [GitHub API issue object](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#get-an-issue) for properties which can be used for the filter + #### failComment The message for the issue content is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: @@ -199,6 +224,27 @@ The `failComment` `This release from branch ${branch.name} had failed due to the > - Error message 1 > - Error message 2 +#### failCommentCondition + +The message for the issue content is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: + +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `branch` | `Object` with `name`, `type`, `channel`, `range` and `prerelease` properties of the branch from which the release is done. | +| `lastRelease` | `Object` with `version`, `channel`, `gitTag` and `gitHead` of the last release. | +| `nextRelease` | `Object` with `version`, `channel`, `gitTag`, `gitHead` and `notes` of the release being done. | +| `commits` | `Array` of commit `Object`s with `hash`, `subject`, `body` `message` and `author`. | +| `releases` | `Array` with a release `Object`s for each release published, with optional release data such as `name` and `url`. | +| `issue` | A [GitHub API pull request object](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request) for pull requests related to a commit, or an `Object` with the `number` property for issues resolved via [keywords](https://help.github.com/articles/closing-issues-using-keywords) | + +##### failCommentCondition example + +- do not create any comments at all: set to `false` or templating: `"<% return false; %>"` +- to only comment on main branch: `"<% return branch.name === 'main' %>"` +- you can use labels to filter issues, i.e. to not comment if the issue is labeled with `wip`: `"<% return !issue.labels?.includes('wip') %>"` + +> check the [GitHub API Pull Request Object](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request) for properties which can be used for the filter + #### releasedLabels Each label name is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: diff --git a/lib/fail.js b/lib/fail.js index 9439b87a..070b076e 100644 --- a/lib/fail.js +++ b/lib/fail.js @@ -23,14 +23,21 @@ export default async function fail(pluginConfig, context, { Octokit }) { githubApiPathPrefix, githubApiUrl, proxy, - failComment, failTitle, + failComment, + failCommentCondition, labels, assignees, } = resolveConfig(pluginConfig, context); if (failComment === false || failTitle === false) { logger.log("Skip issue creation."); + // TODO: use logger.warn() instead of logger.log() + logger.log( + `DEPRECATION: 'false' for 'failComment' or 'failTitle' is deprecated and will be removed in a future major version. Use 'failCommentCondition' instead.`, + ); + } else if (failCommentCondition === false) { + logger.log("Skip issue creation."); } else { const octokit = new Octokit( toOctokitOptions({ @@ -52,6 +59,15 @@ export default async function fail(pluginConfig, context, { Octokit }) { : getFailComment(branch, errors); const [srIssue] = await findSRIssues(octokit, failTitle, owner, repo); + const canCommentOnOrCreateIssue = failCommentCondition + ? template(failCommentCondition)({ ...context, issue: srIssue }) + : true; + + if (!canCommentOnOrCreateIssue) { + logger.log("Skip commenting on or creating an issue."); + return; + } + if (srIssue) { logger.log("Found existing semantic-release issue #%d.", srIssue.number); const comment = { owner, repo, issue_number: srIssue.number, body }; diff --git a/lib/resolve-config.js b/lib/resolve-config.js index b8081c88..578a945e 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -8,8 +8,10 @@ export default function resolveConfig( proxy, assets, successComment, + successCommentCondition, failTitle, failComment, + failCommentCondition, labels, assignees, releasedLabels, @@ -30,10 +32,12 @@ export default function resolveConfig( proxy: isNil(proxy) ? env.http_proxy || env.HTTP_PROXY || false : proxy, assets: assets ? castArray(assets) : assets, successComment, + successCommentCondition, failTitle: isNil(failTitle) ? "The automated release is failing 🚨" : failTitle, failComment, + failCommentCondition, labels: isNil(labels) ? ["semantic-release"] : labels === false diff --git a/lib/success.js b/lib/success.js index 59ab8d0f..3479560c 100644 --- a/lib/success.js +++ b/lib/success.js @@ -1,4 +1,4 @@ -import { isNil, uniqBy, template, flatten, isEmpty } from "lodash-es"; +import { isNil, uniqBy, template, flatten, isEmpty, merge } from "lodash-es"; import pFilter from "p-filter"; import AggregateError from "aggregate-error"; import issueParser from "issue-parser"; @@ -29,8 +29,10 @@ export default async function success(pluginConfig, context, { Octokit }) { githubApiUrl, proxy, successComment, - failComment, + successCommentCondition, failTitle, + failComment, + failCommentCondition, releasedLabels, addReleases, } = resolveConfig(pluginConfig, context); @@ -59,6 +61,12 @@ export default async function success(pluginConfig, context, { Octokit }) { logger.log("No commits found in release"); } logger.log("Skip commenting on issues and pull requests."); + // TODO: use logger.warn() instead of logger.log() + logger.log( + `DEPRECATION: 'false' for 'successComment' is deprecated and will be removed in a future major version. Use 'successCommentCondition' instead.`, + ); + } else if (successCommentCondition === false) { + logger.log("Skip commenting on issues and pull requests."); } else { const parser = issueParser( "github", @@ -67,16 +75,9 @@ export default async function success(pluginConfig, context, { Octokit }) { const releaseInfos = releases.filter((release) => Boolean(release.name)); const shas = commits.map(({ hash }) => hash); - const associatedPRs = []; - - // Split commit shas into chunks of 100 shas - const chunkSize = 100; - const shasChunks = []; - for (let i = 0; i < shas.length; i += chunkSize) { - const chunk = shas.slice(i, i + chunkSize); - shasChunks.push(chunk); - } - for (const chunk of shasChunks) { + // Get associatedPRs + const associatedPRs = await inChunks(shas, 100, async (chunk) => { + const responsePRs = []; const { repository } = await octokit.graphql( buildAssociatedPRsQuery(chunk), { owner, repo }, @@ -85,7 +86,9 @@ export default async function success(pluginConfig, context, { Octokit }) { (item) => item.associatedPullRequests, ); for (const { nodes, pageInfo } of responseAssociatedPRs) { - associatedPRs.push(nodes); + if (nodes.length === 0) continue; + + responsePRs.push(...buildIssuesOrPRsFromResponseNode(nodes, "PR")); if (pageInfo.hasNextPage) { let cursor = pageInfo.endCursor; let hasNextPage = true; @@ -95,7 +98,12 @@ export default async function success(pluginConfig, context, { Octokit }) { { owner, repo, sha: response.commit.oid, cursor }, ); const { associatedPullRequests } = repository.commit; - associatedPRs.push(associatedPullRequests.nodes); + responsePRs.push( + ...buildIssuesOrPRsFromResponseNode( + associatedPullRequests.nodes, + "PR", + ), + ); if (associatedPullRequests.pageInfo.hasNextPage) { cursor = associatedPullRequests.pageInfo.endCursor; } else { @@ -104,7 +112,8 @@ export default async function success(pluginConfig, context, { Octokit }) { } } } - } + return responsePRs; + }); const uniqueAssociatedPRs = uniqBy(flatten(associatedPRs), "number"); @@ -137,7 +146,7 @@ export default async function success(pluginConfig, context, { Octokit }) { ); // Parse the release commits message and PRs body to find resolved issues/PRs via comment keyworkds - const issues = [ + const parsedIssues = [ ...prs.map((pr) => pr.body), ...commits.map((commit) => commit.message), ].reduce( @@ -157,10 +166,42 @@ export default async function success(pluginConfig, context, { Octokit }) { [], ); - debug("found issues via comments: %O", issues); + let issues = []; + + if (!isEmpty(parsedIssues)) { + const uniqueParsedIssues = uniqBy(flatten(parsedIssues), "number"); + + // Get relatedIssues + issues = await inChunks(uniqueParsedIssues, 100, async (chunk) => { + const { repository } = await octokit.graphql( + buildRelatedIssuesQuery(chunk.map((issue) => issue.number)), + { owner, repo }, + ); + const responseRelatedIssues = Object.values(repository).map( + (issue) => issue, + ); + return buildIssuesOrPRsFromResponseNode(responseRelatedIssues); + }); + + debug( + "found related issues via PRs and Commits: %O", + issues.map((issue) => issue.number), + ); + } await Promise.all( uniqBy([...prs, ...issues], "number").map(async (issue) => { + const issueOrPR = issue.pull_request ? "PR" : "issue"; + + const canCommentOnIssue = successCommentCondition + ? template(successCommentCondition)({ ...context, issue }) + : true; + + if (!canCommentOnIssue) { + logger.log(`Skip commenting on ${issueOrPR} #%d.`, issue.number); + return; + } + const body = successComment ? template(successComment)({ ...context, issue }) : getSuccessComment(issue, releaseInfos, nextRelease); @@ -173,7 +214,11 @@ export default async function success(pluginConfig, context, { Octokit }) { "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", comment, ); - logger.log("Added comment to issue #%d: %s", issue.number, url); + logger.log( + `Added comment to ${issueOrPR} #%d: %s`, + issue.number, + url, + ); if (releasedLabels) { const labels = releasedLabels.map((label) => @@ -188,23 +233,27 @@ export default async function success(pluginConfig, context, { Octokit }) { data: labels, }, ); - logger.log("Added labels %O to issue #%d", labels, issue.number); + logger.log( + `Added labels %O to ${issueOrPR} #%d`, + labels, + issue.number, + ); } } catch (error) { if (error.status === 403) { logger.error( - "Not allowed to add a comment to the issue #%d.", + `Not allowed to add a comment to the issue/PR #%d.`, issue.number, ); } else if (error.status === 404) { logger.error( - "Failed to add a comment to the issue #%d as it doesn't exist.", + `Failed to add a comment to the issue/PR #%d as it doesn't exist.`, issue.number, ); } else { errors.push(error); logger.error( - "Failed to add a comment to the issue #%d.", + `Failed to add a comment to the issue/PR #%d.`, issue.number, ); // Don't throw right away and continue to update other issues @@ -278,6 +327,100 @@ export default async function success(pluginConfig, context, { Octokit }) { } } +/** + * In order to speed up a function call that handles a big array of items, we split up the + * array in chunks and call the function for each chunk in parallel. At the end we combine the + * results again. + * + * @template TItem + * @template TCallbackResult + * @param {TItem[]} items + * @param {number} chunkSize + * @param {(items: TItem[]) => TCallbackResult} callback + * @returns TCallbackResult + */ +async function inChunks(items, chunkSize, callback) { + const chunkCalls = []; + for (let i = 0; i < items.length; i += chunkSize) { + chunkCalls.push(callback(items.slice(i, i + chunkSize))); + } + const results = await Promise.all(chunkCalls); + + return results.flat(); +} + +/** + * Fields common accross PRs and Issue + */ +const baseFields = ` + id + title + body + url + number + createdAt + updatedAt + closedAt + comments { + totalCount + } + state + author { + login + url + avatarUrl + __typename + } + authorAssociation + activeLockReason + labels(first: 100) { + nodes { + id + url + name + color + } + } + milestone { + url + id + number + state + title + description + creator { + login + url + avatarUrl + } + createdAt + closedAt + updatedAt + } + locked +`; + +/** + * Builds GraphQL query for fetching PRs/Commits related Issues to a list of commit hash (sha) + * @param {Array} numbers + * @returns {string} + */ +function buildRelatedIssuesQuery(numbers) { + return `#graphql + query getRelatedIssues($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${numbers + .map((num) => { + return `issue${num}: issue(number: ${num}) { + ${baseFields} + }`; + }) + .join("")} + } + } + `; +} + /** * Builds GraphQL query for fetching associated PRs to a list of commit hash (sha) * @param {Array} shas @@ -298,9 +441,20 @@ function buildAssociatedPRsQuery(shas) { hasNextPage } nodes { - url - number - body + ${baseFields} + mergeable + canBeRebased + changedFiles + mergedAt + isDraft + mergedBy { + login + avatarUrl + url + } + commits { + totalCount + } } } } @@ -320,15 +474,27 @@ const loadSingleCommitAssociatedPRs = `#graphql repository(owner: $owner, name: $repo) { commit: object(oid: $sha) { ...on Commit { + oid associatedPullRequests(after: $cursor, first: 100) { pageInfo { endCursor hasNextPage } nodes { - url - number - body + ${baseFields} + mergeable + canBeRebased + changedFiles + mergedAt + isDraft + mergedBy { + login + avatarUrl + url + } + commits { + totalCount + } } } } @@ -336,3 +502,76 @@ const loadSingleCommitAssociatedPRs = `#graphql } } `; + +/** + * Build associatedPRs or RelatedIssues object (into issue-like object with `pull_request` property) from the GraphQL repository response + * @param {object} responseNodes + * @param {"ISSUE" | "PR"} type + * @returns {object[]} + */ +function buildIssuesOrPRsFromResponseNode(responseNodes, type = "ISSUE") { + const resultArray = []; + for (const node of responseNodes) { + let baseProps = { + number: node.number, + title: node.title, + body: node.body, + labels: node.labels?.nodes.map((label) => label.name), + html_url: node.url, + created_at: node.createdAt, + updated_at: node.updatedAt, + user: { + login: node.author?.login, + html_url: node.author?.url, + avatar_url: node.author?.avatarUrl, + type: node.author?.__typename, + }, + comments: node.comments?.totalCount, + state: node.state, + milestone: node.milestone + ? { + url: node.milestone.url, + id: node.milestone.id, + number: node.milestone.number, + state: node.milestone.state, + title: node.milestone.title, + description: node.milestone.description, + creator: { + login: node.milestone.creator.login, + html_url: node.milestone.creator.url, + avatar_url: node.milestone.creator.avatarUrl, + }, + created_at: node.milestone.createdAt, + closed_at: node.milestone.closedAt, + updated_at: node.milestone.updatedAt, + } + : null, + locked: node.locked, + active_lock_reason: node.activeLockReason, + closed_at: node.closedAt, + }; + + let result = baseProps; + + if (type === "PR") { + const prProps = { + pull_request: true, + mergeable: node.mergeable, + rebaseable: node.canBeRebased, + changed_files: node.changedFiles, + commits: node.commits?.totalCount, + merged_at: node.mergedAt, + draft: node.isDraft, + merged_by: { + login: node.mergedBy?.login, + avatar_url: node.mergedBy?.avatarUrl, + html_url: node.mergedBy?.url, + }, + }; + result = merge(baseProps, prProps); + } + + resultArray.push(result); + } + return resultArray; +} diff --git a/lib/verify.js b/lib/verify.js index 39d5d3c2..997f80ef 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -38,8 +38,10 @@ const VALIDATORS = { (isPlainObject(asset) && isStringOrStringArray(asset.path)), ), successComment: canBeDisabled(isNonEmptyString), + successCommentCondition: canBeDisabled(isNonEmptyString), failTitle: canBeDisabled(isNonEmptyString), failComment: canBeDisabled(isNonEmptyString), + failCommentCondition: canBeDisabled(isNonEmptyString), labels: canBeDisabled(isArrayOf(isNonEmptyString)), assignees: isArrayOf(isNonEmptyString), releasedLabels: canBeDisabled(isArrayOf(isNonEmptyString)), diff --git a/test/fail.test.js b/test/fail.test.js index 9b7caee9..f0277d8d 100644 --- a/test/fail.test.js +++ b/test/fail.test.js @@ -451,3 +451,149 @@ test('Skip if "failTitle" is "false"', async (t) => { t.true(t.context.log.calledWith("Skip issue creation.")); }); + +test('Does not post comments if "failCommentCondition" is "false"', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const pluginConfig = { failCommentCondition: false }; + const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; + + await fail( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true(t.context.log.calledWith("Skip issue creation.")); +}); + +test('Does not post comments on existing issues when "failCommentCondition" is "false"', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const failTitle = "The automated release is failing 🚨"; + const env = { GITHUB_TOKEN: "github_token" }; + const pluginConfig = { failCommentCondition: "<% return false; %>" }; + const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; + const errors = [ + new SemanticReleaseError("Error message 1", "ERR1", "Error 1 details"), + new SemanticReleaseError("Error message 2", "ERR2", "Error 2 details"), + new SemanticReleaseError("Error message 3", "ERR3", "Error 3 details"), + ]; + const issues = [ + { number: 1, body: "Issue 1 body", title: failTitle }, + { number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title: failTitle }, + { number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle }, + ]; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: issues }, + ); + + await fail( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + errors, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true(fetch.done()); + t.true(t.context.log.calledWith("Skip commenting on or creating an issue.")); +}); + +test(`Post new issue if none exists yet, but don't comment on existing issues when "failCommentCondition" disallows it`, async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const errors = [{ message: "An error occured" }]; + const failTitle = "The automated release is failing 🚨"; + const pluginConfig = { + failTitle, + failComment: `Error: Release for branch \${branch.name} failed with error: \${errors.map(error => error.message).join(';')}`, + failCommentCondition: "<% return !issue; %>", + }; + const options = { + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: [] }, + ) + .postOnce( + (url, { body }) => { + t.is(url, `https://api.github.local/repos/${owner}/${repo}/issues`); + t.regex( + JSON.parse(body).body, + /Error: Release for branch master failed with error: An error occured\n\n/, + ); + return true; + }, + { html_url: "https://github.com/issues/2", number: 2 }, + ); + + await fail( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + errors, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true(fetch.done()); + t.true( + t.context.log.calledWith( + "Created issue #%d: %s.", + 2, + "https://github.com/issues/2", + ), + ); +}); diff --git a/test/integration.test.js b/test/integration.test.js index 2cf098b4..7b5a29ad 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -470,7 +470,7 @@ test("Comment and add labels on PR included in the releases", async (t) => { const repo = "test_repo"; const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -568,13 +568,13 @@ test("Comment and add labels on PR included in the releases", async (t) => { t.deepEqual(t.context.log.args[0], ["Verify GitHub authentication"]); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), ); t.true(fetch.done()); }); @@ -686,7 +686,7 @@ test("Verify, release and notify success", async (t) => { const uploadOrigin = "https://github.com"; const uploadUri = `/api/uploads/repos/${owner}/${repo}/releases/${releaseId}/assets`; const uploadUrl = `${uploadOrigin}${uploadUri}{?name,label}`; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const commits = [{ hash: "123", message: "Commit 1 message" }]; const fetch = fetchMock @@ -853,7 +853,7 @@ test("Verify, update release and notify success", async (t) => { }; const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; const releaseId = 1; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const commits = [ { hash: "123", message: "Commit 1 message", tree: { long: "aaa" } }, ]; diff --git a/test/success.test.js b/test/success.test.js index 97fcb787..22463310 100644 --- a/test/success.test.js +++ b/test/success.test.js @@ -28,8 +28,8 @@ test("Add comment and labels to PRs associated with release commits and issues s const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, body: "Fixes #3", state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, body: "Fixes #3", state: "closed" }, ]; const options = { branch: "master", @@ -59,32 +59,116 @@ test("Add comment and labels to PRs associated with release commits and issues s full_name: `${redirectedOwner}/${redirectedRepo}`, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) - .postOnce("https://api.github.local/graphql", { - data: { - repository: { - commit123: { - oid: "123", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], + }, + }, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], }, - nodes: [prs[0]], }, }, - commit456: { - oid: "456", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + }, + }, + ) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue3: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/3", + number: 3, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, }, - nodes: [prs[1]], + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/3", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, }, }, }, }, - }) + ) .getOnce( `https://api.github.local/repos/${redirectedOwner}/${redirectedRepo}/pulls/1/commits`, [{ sha: commits[0].hash }], @@ -162,23 +246,23 @@ test("Add comment and labels to PRs associated with release commits and issues s t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 2, "https://github.com/successcomment-2", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 2), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 2), ); t.true( t.context.log.calledWith( @@ -210,10 +294,10 @@ test("Add comment and labels to PRs associated with release commits and issues ( const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, body: "Fixes #3", state: "closed" }, - { number: 5, pull_request: {}, state: "closed" }, - { number: 6, pull_request: {}, state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, body: "Fixes #3", state: "closed" }, + { number: 5, pull_request: true, state: "closed" }, + { number: 6, pull_request: true, state: "closed" }, ]; const options = { branch: "master", @@ -299,18 +383,95 @@ test("Add comment and labels to PRs associated with release commits and issues ( overwriteRoutes: true, }, ) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue3: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/3", + number: 3, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/4", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + }, + }, + }, + ) .getOnce( `https://api.github.local/repos/${owner}/${repo}/pulls/6/commits`, [{ sha: commits[0].hash }], ) .postOnce( - `https://api.github.local/repos/${owner}/${repo}/issues/1/comments`, - { - html_url: "https://github.com/successcomment-1", - }, + `https://api.github.local/repos/${owner}/${repo}/issues/3/comments`, + { html_url: "https://github.com/successcomment-3" }, ) .postOnce( - `https://api.github.local/repos/${owner}/${repo}/issues/1/labels`, + `https://api.github.local/repos/${owner}/${repo}/issues/3/labels`, {}, { body: ["released"] }, ) @@ -364,12 +525,12 @@ test("Add comment and labels to PRs associated with release commits and issues ( t.true( t.context.log.calledWith( "Added comment to issue #%d: %s", - 1, - "https://github.com/successcomment-1", + 3, + "https://github.com/successcomment-3", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 3), ); t.true( t.context.log.calledWith( @@ -383,13 +544,13 @@ test("Add comment and labels to PRs associated with release commits and issues ( ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 6, "https://github.com/successcomment-6", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 6), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 6), ); t.true(fetch.done()); }); @@ -405,8 +566,8 @@ test("Add comment and labels to PRs associated with release commits and issues c const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, body: "Fixes #3", state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, body: "Fixes #3", state: "closed" }, ]; const options = { branch: "master", @@ -430,32 +591,116 @@ test("Add comment and labels to PRs associated with release commits and issues c .getOnce(`https://custom-url.com/prefix/repos/${owner}/${repo}`, { full_name: `${owner}/${repo}`, }) - .postOnce("https://custom-url.com/prefix/graphql", { - data: { - repository: { - commit123: { - oid: "123", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + .postOnce( + (url, { body }) => + url === "https://custom-url.com/prefix/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], + }, + }, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], }, - nodes: [prs[0]], }, }, - commit456: { - oid: "456", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + }, + }, + ) + .postOnce( + (url, { body }) => + url === "https://custom-url.com/prefix/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue3: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://custom-url.com/owner/repo/issues/3", + number: 3, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, }, - nodes: [prs[1]], + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://custom-url.com/owner/repo/issues/4", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, }, }, }, }, - }) + ) .getOnce( `https://custom-url.com/prefix/repos/${owner}/${repo}/pulls/1/commits`, [{ sha: commits[0].hash }], @@ -537,28 +782,28 @@ test("Add comment and labels to PRs associated with release commits and issues c t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://custom-url.com/successcomment-1", ), ); t.true( t.context.log.calledWith( - "Added labels %O to issue #%d", + "Added labels %O to PR #%d", ["released on @next"], 1, ), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 2, "https://custom-url.com/successcomment-2", ), ); t.true( t.context.log.calledWith( - "Added labels %O to issue #%d", + "Added labels %O to PR #%d", ["released on @next"], 2, ), @@ -601,12 +846,12 @@ test("Make multiple search queries if necessary", async (t) => { const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, state: "closed" }, - { number: 3, pull_request: {}, state: "closed" }, - { number: 4, pull_request: {}, state: "closed" }, - { number: 5, pull_request: {}, state: "closed" }, - { number: 6, pull_request: {}, state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, state: "closed" }, + { number: 3, pull_request: true, state: "closed" }, + { number: 4, pull_request: true, state: "closed" }, + { number: 5, pull_request: true, state: "closed" }, + { number: 6, pull_request: true, state: "closed" }, ]; const options = { branch: "master", @@ -817,63 +1062,63 @@ test("Make multiple search queries if necessary", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 2, "https://github.com/successcomment-2", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 2), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 2), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 3, "https://github.com/successcomment-3", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 3), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 3), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 4, "https://github.com/successcomment-4", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 4), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 4), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 5, "https://github.com/successcomment-5", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 5), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 5), ); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 6, "https://github.com/successcomment-6", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 6), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 6), ); t.true(fetch.done()); }); @@ -885,8 +1130,8 @@ test("Do not add comment and labels for unrelated PR returned by search (compare const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, state: "closed" }, ]; const options = { branch: "master", @@ -987,13 +1232,13 @@ test("Do not add comment and labels for unrelated PR returned by search (compare t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), ); t.true(fetch.done()); }); @@ -1184,6 +1429,53 @@ test("Do not add comment and labels to PR/issues from other repo", async (t) => }, }, }) + .postOnce( + (url, { body }) => { + t.is(url, "https://api.github.local/graphql"); + t.regex(JSON.parse(body).query, /query getRelatedIssues\(/); + return true; + }, + { + data: { + repository: { + issue2: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/14", + number: 2, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + }, + }, + }, + ) .postOnce( `https://api.github.local/repos/${owner}/${repo}/issues/2/comments`, { html_url: "https://github.com/successcomment-2" }, @@ -1240,9 +1532,9 @@ test("Ignore missing and forbidden issues/PRs", async (t) => { const failTitle = "The automated release is failing 🚨"; const pluginConfig = { failTitle }; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, body: "Fixes #4", state: "closed" }, - { number: 3, pull_request: {}, body: "Fixes #5", state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, body: "Fixes #4", state: "closed" }, + { number: 3, pull_request: true, body: "Fixes #5", state: "closed" }, ]; const options = { branch: "master", @@ -1264,42 +1556,160 @@ test("Ignore missing and forbidden issues/PRs", async (t) => { full_name: `${owner}/${repo}`, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) - .postOnce("https://api.github.local/graphql", { - data: { - repository: { - commit123: { - oid: "123", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], }, - nodes: [prs[0]], }, - }, - commit456: { - oid: "456", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], + }, + }, + commit789: { + oid: "789", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[2]], }, - nodes: [prs[1]], }, }, - commit789: { - oid: "789", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + }, + }, + ) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/4", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, }, - nodes: [prs[2]], + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue5: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/5", + number: 5, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue1: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/1", + number: 1, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, }, }, }, }, - }) + ) .getOnce( `https://api.github.local/repos/${owner}/${repo}/pulls/1/commits`, [{ sha: commits[0].hash }], @@ -1376,13 +1786,13 @@ test("Ignore missing and forbidden issues/PRs", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 1), + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), ); t.true( t.context.log.calledWith( @@ -1406,13 +1816,13 @@ test("Ignore missing and forbidden issues/PRs", async (t) => { ); t.true( t.context.error.calledWith( - "Failed to add a comment to the issue #%d as it doesn't exist.", + "Failed to add a comment to the issue/PR #%d as it doesn't exist.", 2, ), ); t.true( t.context.error.calledWith( - "Not allowed to add a comment to the issue #%d.", + "Not allowed to add a comment to the issue/PR #%d.", 3, ), ); @@ -1433,7 +1843,7 @@ test("Add custom comment and labels", async (t) => { ], }; const prs = [ - { number: 1, prop: "PR prop", pull_request: {}, state: "closed" }, + { number: 1, prop: "PR prop", pull_request: true, state: "closed" }, ]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; const lastRelease = { version: "1.0.0" }; @@ -1471,11 +1881,9 @@ test("Add custom comment and labels", async (t) => { ) .postOnce( `https://api.github.local/repos/${owner}/${repo}/issues/1/comments`, - { html_url: "https://github.com/successcomment-1" }, { - body: { - body: `last release: ${lastRelease.version} nextRelease: ${nextRelease.version} branch: master commits: 1 releases: 1 PR attribute: PR prop`, - }, + html_url: "https://github.com/successcomment-1", + body: `last release: ${lastRelease.version} nextRelease: ${nextRelease.version} branch: master commits: 1 releases: 1 PR attribute: PR prop`, }, ) .postOnce( @@ -1514,14 +1922,14 @@ test("Add custom comment and labels", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( t.context.log.calledWith( - "Added labels %O to issue #%d", + "Added labels %O to PR #%d", ["released on @next", "released from master"], 1, ), @@ -1535,7 +1943,7 @@ test("Add custom label", async (t) => { const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: ["custom label"], failTitle }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; const lastRelease = { version: "1.0.0" }; const commits = [{ hash: "123", message: "Commit 1 message" }]; @@ -1610,17 +2018,13 @@ test("Add custom label", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), ); t.true( - t.context.log.calledWith( - "Added labels %O to issue #%d", - ["custom label"], - 1, - ), + t.context.log.calledWith("Added labels %O to PR #%d", ["custom label"], 1), ); t.true(fetch.done()); }); @@ -1631,7 +2035,7 @@ test("Comment on issue/PR without ading a label", async (t) => { const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, failTitle }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; const lastRelease = { version: "1.0.0" }; const commits = [{ hash: "123", message: "Commit 1 message" }]; @@ -1701,7 +2105,7 @@ test("Comment on issue/PR without ading a label", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -1715,7 +2119,7 @@ test("Editing the release to include all release links at the bottom", async (t) const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "bottom" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -1811,7 +2215,7 @@ test("Editing the release to include all release links at the bottom", async (t) t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -1825,7 +2229,7 @@ test("Editing the release to include all release links at the top", async (t) => const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "top" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -1921,7 +2325,7 @@ test("Editing the release to include all release links at the top", async (t) => t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -1935,7 +2339,7 @@ test("Editing the release to include all release links with no additional releas const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "top" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -2017,7 +2421,7 @@ test("Editing the release to include all release links with no additional releas t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -2031,7 +2435,7 @@ test("Editing the release to include all release links with no additional releas const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "bottom" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -2113,7 +2517,7 @@ test("Editing the release to include all release links with no additional releas t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -2127,7 +2531,7 @@ test("Editing the release to include all release links with no releases", async const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "bottom" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, }; @@ -2202,7 +2606,7 @@ test("Editing the release to include all release links with no releases", async t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -2216,7 +2620,7 @@ test("Editing the release with no ID in the release", async (t) => { const env = { GITHUB_TOKEN: "github_token" }; const failTitle = "The automated release is failing 🚨"; const pluginConfig = { releasedLabels: false, addReleases: "bottom" }; - const prs = [{ number: 1, pull_request: {}, state: "closed" }]; + const prs = [{ number: 1, pull_request: true, state: "closed" }]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; const nextRelease = { version: "2.0.0", @@ -2293,7 +2697,7 @@ test("Editing the release with no ID in the release", async (t) => { t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 1, "https://github.com/successcomment-1", ), @@ -2313,8 +2717,8 @@ test("Ignore errors when adding comments and closing issues", async (t) => { { number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle }, ]; const prs = [ - { number: 1, pull_request: {}, state: "closed" }, - { number: 2, pull_request: {}, state: "closed" }, + { number: 1, pull_request: true, state: "closed" }, + { number: 2, pull_request: true, state: "closed" }, ]; const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git`, @@ -2434,12 +2838,15 @@ test("Ignore errors when adding comments and closing issues", async (t) => { t.is(error1.status, 400); t.is(error2.status, 500); t.true( - t.context.error.calledWith("Failed to add a comment to the issue #%d.", 1), + t.context.error.calledWith( + "Failed to add a comment to the issue/PR #%d.", + 1, + ), ); t.true(t.context.error.calledWith("Failed to close the issue #%d.", 2)); t.true( t.context.log.calledWith( - "Added comment to issue #%d: %s", + "Added comment to PR #%d: %s", 2, "https://github.com/successcomment-2", ), @@ -2557,7 +2964,7 @@ test("Close open issues when a release is successful", async (t) => { t.true(fetch.done()); }); -test('Skip commention on issues/PR if "successComment" is "false"', async (t) => { +test('Skip comment on on issues/PR if "successComment" is "false"', async (t) => { const owner = "test_user"; const repo = "test_repo"; const env = { GITHUB_TOKEN: "github_token" }; @@ -2618,6 +3025,817 @@ test('Skip commention on issues/PR if "successComment" is "false"', async (t) => t.true(fetch.done()); }); +test('Does not comment/label on issues/PR if "successCommentCondition" is "false"', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const failTitle = "The automated release is failing 🚨"; + const pluginConfig = { failTitle, successCommentCondition: false }; + const options = { + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + const commits = [ + { + hash: "123", + message: "Commit 1 message\n\n Fix #1", + tree: { long: "aaa" }, + }, + ]; + const nextRelease = { version: "1.0.0" }; + const releases = [ + { name: "GitHub release", url: "https://github.com/release" }, + ]; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: [] }, + ); + + await success( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + commits, + nextRelease, + releases, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true( + t.context.log.calledWith("Skip commenting on issues and pull requests."), + ); + t.true(fetch.done()); +}); + +test('Add comment and label to found issues/associatedPR using the "successCommentCondition": if specific label is found', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const failTitle = "The automated release is failing 🚨"; + const pluginConfig = { + failTitle, + // Issues with the label "semantic-release-relevant" will be commented and labeled + successCommentCondition: + "<% return issue.labels.includes('semantic-release-relevant'); %>", + }; + const options = { + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + const commits = [ + { hash: "123", message: "Commit 1 message" }, + { hash: "456", message: "Commit 2 message" }, + ]; + const nextRelease = { version: "1.0.0" }; + const releases = [ + { name: "GitHub release", url: "https://github.com/release" }, + ]; + const issues = [ + { number: 1, body: "Issue 1 body", title: failTitle }, + { number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title: failTitle }, + { number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle }, + ]; + + const prs = [ + { + id: "PR_kwDOMLlZj85z_R2M", + title: "fix: will semantic-release recognize the associated issue ", + body: "", + url: "https://github.com/babblebey/sr-github/pull/12", + number: 5, + createdAt: "2024-06-30T14:43:48Z", + updatedAt: "2024-08-26T16:19:57Z", + closedAt: "2024-06-30T14:44:05Z", + comments: { + totalCount: 12, + }, + state: "MERGED", + author: { + login: "babblebey", + url: "https://github.com/babblebey", + avatarUrl: + "https://avatars.githubusercontent.com/u/25631971?u=f4597764b2c31478a516d97bb9ecd019b5e62ae7&v=4", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "LA_kwDOMLlZj88AAAABp9kjcQ", + url: "https://github.com/babblebey/sr-github/labels/released", + name: "semantic-release-relevant", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + mergeable: "UNKNOWN", + canBeRebased: false, + changedFiles: 1, + mergedAt: "2024-06-30T14:44:05Z", + isDraft: false, + mergedBy: { + login: "babblebey", + avatarUrl: + "https://avatars.githubusercontent.com/u/25631971?u=f4597764b2c31478a516d97bb9ecd019b5e62ae7&v=4", + url: "https://github.com/babblebey", + }, + commits: { + totalCount: 1, + }, + }, + { + id: "PR_kwDOMLlZj85z_R2M", + title: "fix: will semantic-release recognize the associated issue ", + body: "", + url: "https://github.com/babblebey/sr-github/pull/12", + number: 4, + createdAt: "2024-06-30T14:43:48Z", + updatedAt: "2024-08-26T16:19:57Z", + closedAt: "2024-06-30T14:44:05Z", + comments: { + totalCount: 12, + }, + state: "MERGED", + author: { + login: "babblebey", + url: "https://github.com/babblebey", + avatarUrl: + "https://avatars.githubusercontent.com/u/25631971?u=f4597764b2c31478a516d97bb9ecd019b5e62ae7&v=4", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "LA_kwDOMLlZj88AAAABp9kjcQ", + url: "https://github.com/babblebey/sr-github/labels/released", + name: "released", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + mergeable: "UNKNOWN", + canBeRebased: false, + changedFiles: 1, + mergedAt: "2024-06-30T14:44:05Z", + isDraft: false, + mergedBy: { + login: "babblebey", + avatarUrl: + "https://avatars.githubusercontent.com/u/25631971?u=f4597764b2c31478a516d97bb9ecd019b5e62ae7&v=4", + url: "https://github.com/babblebey", + }, + commits: { + totalCount: 1, + }, + }, + ]; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], + }, + }, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], + }, + }, + }, + }, + }, + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/4/commits`, + [{ sha: commits[0].hash }], + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/5/commits`, + [{ sha: commits[1].hash }], + ) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: issues }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/5/comments`, + { html_url: "https://github.com/successcomment-5" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/5/labels`, + {}, + { body: ["released"] }, + ) + .patchOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/2`, + { html_url: "https://github.com/issues/2" }, + { + body: { + state: "closed", + }, + }, + ) + .patchOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/3`, + { html_url: "https://github.com/issues/3" }, + { + body: { + state: "closed", + }, + }, + ); + + await success( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + commits, + nextRelease, + releases, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true( + t.context.log.calledWith( + "Added comment to PR #%d: %s", + 5, + "https://github.com/successcomment-5", + ), + ); + t.true( + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 5), + ); + t.true(fetch.done()); +}); + +test('Does not comment/label associatedPR and relatedIssues created by "Bots"', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const failTitle = "The automated release is failing 🚨"; + const pluginConfig = { + failTitle, + // Only issues or PRs not created by "Bot" will be commented and labeled + successCommentCondition: "<% return issue.user.type !== 'Bot'; %>", + }; + const options = { + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + const commits = [ + { hash: "123", message: "Commit 1 message" }, + { hash: "456", message: "Commit 2 message" }, + ]; + const nextRelease = { version: "1.0.0" }; + const releases = [ + { name: "GitHub release", url: "https://github.com/release" }, + ]; + const issues = [ + { number: 1, body: `Issue 1 body\n\n${ISSUE_ID}`, title: failTitle }, + ]; + const prs = [ + { + number: 2, + id: "PR_kwDOMLlZj851SZzc", + title: "pr title", + body: "Fixes #4", + url: "https://pr-url", + createdAt: "2024-07-13T09:57:51Z", + updatedAt: "2024-08-29T12:15:33Z", + closedAt: "2024-07-13T09:58:50Z", + comments: { + totalCount: 23, + }, + state: "MERGED", + author: { + login: "user_login", + url: "https://user-url", + avatarUrl: "https://avatar-url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + mergeable: "UNKNOWN", + canBeRebased: false, + changedFiles: 1, + mergedAt: "2024-07-13T09:58:50Z", + isDraft: false, + mergedBy: { + login: "user", + avatarUrl: "https://alink-to-avatar", + url: "https://user-url", + }, + commits: { + totalCount: 1, + }, + }, + { + number: 3, + id: "PR_kwDOMLlZj851SZzc", + title: "pr title", + body: "Fixes #5", + url: "https://pr-url", + createdAt: "2024-07-13T09:57:51Z", + updatedAt: "2024-08-29T12:15:33Z", + closedAt: "2024-07-13T09:58:50Z", + comments: { + totalCount: 23, + }, + state: "MERGED", + author: { + login: "user_login", + url: "https://user-url", + avatarUrl: "https://avatar-url", + __typename: "Bot", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + mergeable: "UNKNOWN", + canBeRebased: false, + changedFiles: 1, + mergedAt: "2024-07-13T09:58:50Z", + isDraft: false, + mergedBy: { + login: "user", + avatarUrl: "https://alink-to-avatar", + url: "https://user-url", + }, + commits: { + totalCount: 1, + }, + }, + ]; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], + }, + }, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], + }, + }, + }, + }, + }, + ) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/4", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + issue5: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/5", + number: 5, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "Bot", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + }, + }, + }, + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/2/commits`, + [{ sha: commits[0].hash }], + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/3/commits`, + [{ sha: commits[1].hash }], + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/4/comments`, + { html_url: "https://github.com/successcomment-4" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/4/labels`, + {}, + { body: ["released"] }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/2/comments`, + { html_url: "https://github.com/successcomment-2" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/2/labels`, + {}, + { body: ["released"] }, + ) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: issues }, + ) + .patchOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/1`, + { html_url: "https://github.com/issues/1" }, + { + body: { + state: "closed", + }, + }, + ); + + await success( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + commits, + nextRelease, + releases, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true( + t.context.log.calledWith( + "Closed issue #%d: %s.", + 1, + "https://github.com/issues/1", + ), + ); + t.true(t.context.log.calledWith("Skip commenting on PR #%d.", 3)); + t.true( + t.context.log.calledWith( + "Added comment to issue #%d: %s", + 4, + "https://github.com/successcomment-4", + ), + ); + t.true( + t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 4), + ); + t.true(fetch.done()); +}); + +test('Does not comment/label "associatedPR" when "successCommentCondition" disables it: Only comment on "relatedIssues"', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const failTitle = "The automated release is failing 🚨"; + const pluginConfig = { + failTitle, + // Only issues will be commented and labeled (not PRs) + successCommentCondition: "<% return !issue.pull_request; %>", + }; + const options = { + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + const commits = [ + { hash: "123", message: "Commit 1 message" }, + { hash: "456", message: "Commit 2 message" }, + ]; + const nextRelease = { version: "1.0.0" }; + const releases = [ + { name: "GitHub release", url: "https://github.com/release" }, + ]; + const issues = [ + { number: 1, body: `Issue 1 body\n\n${ISSUE_ID}`, title: failTitle }, + { + number: 4, + body: `Issue 4 body`, + title: "Issue 4 title", + state: "closed", + }, + ]; + const prs = [ + { number: 2, pull_request: true, body: "Fixes #4", state: "closed" }, + { number: 3, pull_request: true, state: "closed" }, + ]; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + full_name: `${owner}/${repo}`, + }) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[0]], + }, + }, + commit456: { + oid: "456", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[1]], + }, + }, + }, + }, + }, + ) + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getRelatedIssues("), + { + data: { + repository: { + issue4: { + id: "I_kw", + title: "issue title", + body: "", + url: "https://github.com/owner/repo/issues/4", + number: 4, + createdAt: "2024-07-13T09:58:09Z", + updatedAt: "2024-08-26T16:19:59Z", + closedAt: "2024-07-13T09:58:51Z", + comments: { + totalCount: 12, + }, + state: "CLOSED", + author: { + login: "user", + url: "author_url", + avatarUrl: "author_avatar_url", + __typename: "User", + }, + authorAssociation: "OWNER", + activeLockReason: null, + labels: { + nodes: [ + { + id: "label_id", + url: "label_url", + name: "label_name", + color: "ededed", + }, + ], + }, + milestone: null, + locked: false, + }, + }, + }, + }, + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/2/commits`, + [{ sha: commits[0].hash }], + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/3/commits`, + [{ sha: commits[1].hash }], + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/4/comments`, + { html_url: "https://github.com/successcomment-4" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/4/labels`, + {}, + { body: ["released"] }, + ) + .getOnce( + `https://api.github.local/search/issues?q=${encodeURIComponent( + "in:title", + )}+${encodeURIComponent(`repo:${owner}/${repo}`)}+${encodeURIComponent( + "type:issue", + )}+${encodeURIComponent("state:open")}+${encodeURIComponent(failTitle)}`, + { items: issues }, + ) + .patchOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/1`, + { html_url: "https://github.com/issues/1" }, + { + body: { + state: "closed", + }, + }, + ); + + await success( + pluginConfig, + { + env, + options, + branch: { name: "master" }, + commits, + nextRelease, + releases, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.true( + t.context.log.calledWith( + "Closed issue #%d: %s.", + 1, + "https://github.com/issues/1", + ), + ); + t.true( + t.context.log.calledWith( + "Added comment to issue #%d: %s", + 4, + "https://github.com/successcomment-4", + ), + ); + t.true( + t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 4), + ); + t.true(fetch.done()); +}); + test('Skip closing issues if "failComment" is "false"', async (t) => { const owner = "test_user"; const repo = "test_repo";