diff --git a/lib/success.js b/lib/success.js index 48246f29..98f7ae0c 100644 --- a/lib/success.js +++ b/lib/success.js @@ -1,4 +1,4 @@ -import { isNil, uniqBy, template, flatten, isEmpty, merge } from "lodash-es"; +import { isNil, uniqBy, template, isEmpty, merge } from "lodash-es"; import pFilter from "p-filter"; import AggregateError from "aggregate-error"; import issueParser from "issue-parser"; @@ -75,47 +75,29 @@ export default async function success(pluginConfig, context, { Octokit }) { const releaseInfos = releases.filter((release) => Boolean(release.name)); const shas = commits.map(({ hash }) => hash); + // For commits that are not on the default branch, `associatedPullRequests` + // also returns pull requests of other repositories (e.g. forks syncing from + // this one). Their numbers refer to the other repository, so skip them. + const isFromThisRepository = ({ repository }) => + !repository || repository.nameWithOwner === `${owner}/${repo}`; + // Get associatedPRs const associatedPRs = await inChunks(shas, 100, async (chunk) => { - const responsePRs = []; const { repository } = await octokit.graphql( buildAssociatedPRsQuery(chunk), { owner, repo }, ); - const responseAssociatedPRs = Object.values(repository).map( - (item) => item.associatedPullRequests, + const nodes = await Promise.all( + Object.values(repository).map((commit) => + getAssociatedPRsOfCommit(octokit, owner, repo, commit), + ), + ); + return buildIssuesOrPRsFromResponseNode( + nodes.flat().filter(isFromThisRepository), ); - for (const { nodes, pageInfo } of responseAssociatedPRs) { - if (nodes.length === 0) continue; - - responsePRs.push(...buildIssuesOrPRsFromResponseNode(nodes)); - if (pageInfo.hasNextPage) { - let cursor = pageInfo.endCursor; - let hasNextPage = true; - while (hasNextPage) { - const { repository } = await octokit.graphql( - loadSingleCommitAssociatedPRs, - { owner, repo, sha: response.commit.oid, cursor }, - ); - const { associatedPullRequests } = repository.commit; - responsePRs.push( - ...buildIssuesOrPRsFromResponseNode( - associatedPullRequests.nodes, - "PR", - ), - ); - if (associatedPullRequests.pageInfo.hasNextPage) { - cursor = associatedPullRequests.pageInfo.endCursor; - } else { - hasNextPage = false; - } - } - } - } - return responsePRs; }); - const uniqueAssociatedPRs = uniqBy(flatten(associatedPRs), "number"); + const uniqueAssociatedPRs = uniqBy(associatedPRs, "number"); const prs = await pFilter(uniqueAssociatedPRs, async ({ number }) => { const commits = await octokit.paginate( @@ -169,7 +151,7 @@ export default async function success(pluginConfig, context, { Octokit }) { let issues = []; if (!isEmpty(parsedIssues)) { - const uniqueParsedIssues = uniqBy(flatten(parsedIssues), "number"); + const uniqueParsedIssues = uniqBy(parsedIssues, "number"); // Get relatedIssues (or relatedPRs i.e. Issues/PRs that are closed by an associatedPR) issues = await inChunks(uniqueParsedIssues, 100, async (chunk) => { @@ -354,6 +336,24 @@ async function inChunks(items, chunkSize, callback) { return results.flat(); } +/** + * Returns the `associatedPullRequests` nodes of a commit from the `getAssociatedPRs` + * response, loading the remaining pages for commits that have more than 100 of them. + */ +async function getAssociatedPRsOfCommit(octokit, owner, repo, commit) { + let page = commit.associatedPullRequests; + const nodes = [...page.nodes]; + while (page.pageInfo.hasNextPage) { + const { repository } = await octokit.graphql( + loadSingleCommitAssociatedPRs, + { owner, repo, sha: commit.oid, cursor: page.pageInfo.endCursor }, + ); + page = repository.commit.associatedPullRequests; + nodes.push(...page.nodes); + } + return nodes; +} + /** * Fields common accross PRs and Issue */ @@ -408,6 +408,28 @@ const baseFields = ` locked `; +/** + * Fields of a PR, on top of the `baseFields` + */ +const prFields = ` + ${baseFields} + repository { + nameWithOwner + } + mergeable + changedFiles + mergedAt + isDraft + mergedBy { + login + avatarUrl + url + } + commits { + totalCount + } +`; + /** * Builds GraphQL query for fetching PRs/Commits related Issues to a list of commit hash (sha) * @param {Array} numbers @@ -424,19 +446,7 @@ function buildRelatedIssuesQuery(numbers) { ${baseFields} } ... on PullRequest { - ${baseFields} - mergeable - changedFiles - mergedAt - isDraft - mergedBy { - login - avatarUrl - url - } - commits { - totalCount - } + ${prFields} } }`; }) @@ -466,19 +476,7 @@ function buildAssociatedPRsQuery(shas) { hasNextPage } nodes { - ${baseFields} - mergeable - changedFiles - mergedAt - isDraft - mergedBy { - login - avatarUrl - url - } - commits { - totalCount - } + ${prFields} } } } @@ -505,19 +503,7 @@ const loadSingleCommitAssociatedPRs = `#graphql hasNextPage } nodes { - ${baseFields} - mergeable - changedFiles - mergedAt - isDraft - mergedBy { - login - avatarUrl - url - } - commits { - totalCount - } + ${prFields} } } } diff --git a/test/success.test.js b/test/success.test.js index e88e1b70..040a6721 100644 --- a/test/success.test.js +++ b/test/success.test.js @@ -341,44 +341,57 @@ test("Add comment and labels to PRs associated with release commits and issues ( .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { full_name: `${owner}/${repo}`, }) - .postOnce("https://api.github.local/graphql", { - data: { - repository: { - commit123: { - oid: "123", - associatedPullRequests: { - pageInfo: { - endCursor: "YE", - hasNextPage: true, + .postOnce( + (url, { body }) => + url === "https://api.github.local/graphql" && + JSON.parse(body).query.includes("query getAssociatedPRs("), + { + data: { + repository: { + commit123: { + oid: "123", + associatedPullRequests: { + pageInfo: { + endCursor: "YE", + hasNextPage: true, + }, + 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]], }, - nodes: [prs[1]], }, - }, - commit789: { - oid: "789", - associatedPullRequests: { - pageInfo: { - endCursor: "NI", - hasNextPage: false, + commit789: { + oid: "789", + associatedPullRequests: { + pageInfo: { + endCursor: "NI", + hasNextPage: false, + }, + nodes: [prs[2]], }, - nodes: [prs[2]], }, }, }, }, - }) + ) .postOnce( - "https://api.github.local/graphql", + (url, { body }) => { + const { query, variables } = JSON.parse(body); + return ( + url === "https://api.github.local/graphql" && + query.includes("query getCommitAssociatedPRs(") && + variables.sha === "123" && + variables.cursor === "YE" + ); + }, { data: { repository: { @@ -394,9 +407,6 @@ test("Add comment and labels to PRs associated with release commits and issues ( }, }, }, - { - overwriteRoutes: true, - }, ) .postOnce( (url, { body }) => @@ -483,10 +493,49 @@ test("Add comment and labels to PRs associated with release commits and issues ( }, }, ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/1/commits`, + [{ sha: commits[0].hash }], + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/2/commits`, + [{ sha: commits[1].hash }], + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/5/commits`, + [{ sha: commits[2].hash }], + ) .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" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/1/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"] }, + ) + .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"] }, + ) .postOnce( `https://api.github.local/repos/${owner}/${repo}/issues/3/comments`, { html_url: "https://github.com/successcomment-3" }, @@ -567,16 +616,22 @@ test("Add comment and labels to PRs associated with release commits and issues ( t.true( t.context.log.calledWith("Added labels %O to issue #%d", ["released"], 4), ); - t.true( - t.context.log.calledWith( - "Added comment to PR #%d: %s", - 6, - "https://github.com/successcomment-6", - ), - ); - t.true( - t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 6), - ); + for (const number of [1, 2, 5, 6]) { + t.true( + t.context.log.calledWith( + "Added comment to PR #%d: %s", + number, + `https://github.com/successcomment-${number}`, + ), + ); + t.true( + t.context.log.calledWith( + "Added labels %O to PR #%d", + ["released"], + number, + ), + ); + } t.true(fetch.done()); }); @@ -1306,6 +1361,123 @@ test("Do not add comment and labels for unrelated PR returned by search (compare t.true(fetch.done()); }); +test("Do not add comment and labels to associated PRs of other repositories", 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 }; + const prs = [ + { + number: 1, + __typename: "PullRequest", + state: "closed", + repository: { nameWithOwner: `${owner}/${repo}` }, + }, + // A fork's PR that contains the release commit. Its number belongs to the + // fork, so no request must be made for it in this repository + { + number: 66, + __typename: "PullRequest", + state: "open", + repository: { nameWithOwner: "other_user/fork" }, + }, + ]; + const options = { + branch: "master", + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + }; + const commits = [{ hash: "123", message: "Commit 1 message" }]; + 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}`, + }) + .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, + }, + }, + }, + }, + }, + ) + .getOnce( + `https://api.github.local/repos/${owner}/${repo}/pulls/1/commits`, + [{ sha: commits[0].hash }], + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/1/comments`, + { html_url: "https://github.com/successcomment-1" }, + ) + .postOnce( + `https://api.github.local/repos/${owner}/${repo}/issues/1/labels`, + {}, + { body: ["released"] }, + ) + .postOnce( + (url, { body }) => { + t.is(url, "https://api.github.local/graphql"); + t.regex(JSON.parse(body).query, /query getSRIssues\(/); + return true; + }, + { + data: { + repository: { + issues: { nodes: [] }, + }, + }, + }, + ); + + await success( + pluginConfig, + { + env, + options, + 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", + 1, + "https://github.com/successcomment-1", + ), + ); + t.true( + t.context.log.calledWith("Added labels %O to PR #%d", ["released"], 1), + ); + t.true(fetch.done()); +}); + test("Do not add comment and labels if no PR is associated with release commits", async (t) => { const owner = "test_user"; const repo = "test_repo";