From 1b636aa82456f33f2d39f5887efcb0bb121a0d92 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Wed, 22 Jun 2022 10:30:48 -0400 Subject: [PATCH] ci: add workflow to comment on issues/PRs about new releases (#8981) --- .github/workflows/release-comments.yml | 35 +++++ .github/workflows/release.yml | 8 ++ package.json | 3 + scripts/release/comment.mjs | 66 +++++++++ scripts/release/constants.mjs | 18 +++ scripts/release/octokit.mjs | 186 +++++++++++++++++++++++++ yarn.lock | 128 +++++++++++++++++ 7 files changed, 444 insertions(+) create mode 100644 .github/workflows/release-comments.yml create mode 100644 scripts/release/comment.mjs create mode 100644 scripts/release/constants.mjs create mode 100644 scripts/release/octokit.mjs diff --git a/.github/workflows/release-comments.yml b/.github/workflows/release-comments.yml new file mode 100644 index 0000000000..e3365b45e6 --- /dev/null +++ b/.github/workflows/release-comments.yml @@ -0,0 +1,35 @@ +name: 📝 Comment on Release + +on: + workflow_call: + inputs: + ref: + required: true + type: string + +jobs: + comment: + name: Comment on Release + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + cache: "yarn" + + - name: 📥 Install deps + # even though this is called "npm-install" it does use yarn to install + # because we have a yarn.lock and caches efficiently. + uses: bahmutov/npm-install@v1 + + - name: 📝 Comment on issues + run: node ./scripts/release/comment.mjs + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a0c1b8316..8ff13ab1c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,3 +33,11 @@ jobs: run: | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc node scripts/publish.js + + comment: + needs: [release] + name: 📝 Comment on related issues and pull requests + if: github.repository == 'remix-run/react-router' + uses: remix-run/react-router/.github/workflows/release-comments.yml@main + with: + ref: ${{ github.ref }} diff --git a/package.json b/package.json index 07d30bc89a..52897932af 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "@babel/preset-modules": "^0.1.4", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.15.0", + "@octokit/graphql": "^4.8.0", + "@octokit/plugin-paginate-rest": "^2.17.0", + "@octokit/rest": "^18.12.0", "@rollup/plugin-replace": "^2.2.1", "@types/jest": "26.x", "@types/jsonfile": "^6.0.1", diff --git a/scripts/release/comment.mjs b/scripts/release/comment.mjs new file mode 100644 index 0000000000..2e96577042 --- /dev/null +++ b/scripts/release/comment.mjs @@ -0,0 +1,66 @@ +import { + commentOnIssue, + commentOnPullRequest, + getIssuesClosedByPullRequests, + prsMergedSinceLast, +} from "./octokit.mjs"; +import { LATEST_RELEASE, OWNER, REPO } from "./constants.mjs"; + +async function commentOnIssuesAndPrsAboutRelease() { + if (LATEST_RELEASE.includes("experimental")) { + return; + } + + let { merged, previousRelease } = await prsMergedSinceLast({ + owner: OWNER, + repo: REPO, + lastRelease: LATEST_RELEASE, + }); + + let suffix = merged.length === 1 ? "" : "s"; + console.log( + `Found ${merged.length} PR${suffix} merged since last release (latest: ${LATEST_RELEASE}, previous: ${previousRelease})` + ); + + let promises = []; + let issuesCommentedOn = new Set(); + + for (let pr of merged) { + console.log(`commenting on pr #${pr.number}`); + + promises.push( + commentOnPullRequest({ + owner: OWNER, + repo: REPO, + pr: pr.number, + version: LATEST_RELEASE, + }) + ); + + let issuesClosed = await getIssuesClosedByPullRequests( + pr.html_url, + pr.body + ); + + for (let issue of issuesClosed) { + if (issuesCommentedOn.has(issue.number)) { + // already commented on this issue + continue; + } + issuesCommentedOn.add(issue.number); + console.log(`commenting on issue #${issue.number}`); + promises.push( + commentOnIssue({ + issue: issue.number, + owner: OWNER, + repo: REPO, + version: LATEST_RELEASE, + }) + ); + } + } + + await Promise.all(promises); +} + +commentOnIssuesAndPrsAboutRelease(); diff --git a/scripts/release/constants.mjs b/scripts/release/constants.mjs new file mode 100644 index 0000000000..d64d87264e --- /dev/null +++ b/scripts/release/constants.mjs @@ -0,0 +1,18 @@ +if (!process.env.GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN is required"); +} +if (!process.env.GITHUB_REPOSITORY) { + throw new Error("GITHUB_REPOSITORY is required"); +} +if (!process.env.VERSION) { + throw new Error("VERSION is required"); +} +if (!process.env.VERSION.startsWith("refs/tags/")) { + throw new Error("VERSION must be a tag, received " + process.env.VERSION); +} + +export const [OWNER, REPO] = process.env.GITHUB_REPOSITORY.split("/"); +export const LATEST_RELEASE = process.env.VERSION.replace("refs/tags/", ""); +export const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; +export const PR_FILES_STARTS_WITH = ["packages/"]; diff --git a/scripts/release/octokit.mjs b/scripts/release/octokit.mjs new file mode 100644 index 0000000000..4496f40f30 --- /dev/null +++ b/scripts/release/octokit.mjs @@ -0,0 +1,186 @@ +import { Octokit as RestOctokit } from "@octokit/rest"; +import { paginateRest } from "@octokit/plugin-paginate-rest"; +import { graphql } from "@octokit/graphql"; + +import { + GITHUB_TOKEN, + GITHUB_REPOSITORY, + PR_FILES_STARTS_WITH, +} from "./constants.mjs"; + +const graphqlWithAuth = graphql.defaults({ + headers: { authorization: `token ${GITHUB_TOKEN}` }, +}); + +const Octokit = RestOctokit.plugin(paginateRest); +const octokit = new Octokit({ auth: GITHUB_TOKEN }); + +const gql = String.raw; + +export async function prsMergedSinceLast({ + owner, + repo, + lastRelease: lastReleaseVersion, +}) { + let releases = await octokit.paginate(octokit.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + + let sorted = releases + .sort((a, b) => { + return new Date(b.published_at) - new Date(a.published_at); + }) + .filter((release) => { + return release.tag_name.includes("experimental") === false; + }); + + let lastReleaseIndex = sorted.findIndex((release) => { + return release.tag_name === lastReleaseVersion; + }); + + let lastRelease = sorted[lastReleaseIndex]; + if (!lastRelease) { + throw new Error( + `Could not find last release ${lastRelease} in ${GITHUB_REPOSITORY}` + ); + } + + // if the lastRelease was a stable release, then we want to find the previous stable release + let previousRelease; + if (lastRelease.prerelease === false) { + let stableReleases = sorted.filter((release) => { + return release.prerelease === false; + }); + previousRelease = stableReleases.at(1); + } else { + previousRelease = sorted.at(lastReleaseIndex + 1); + } + + if (!previousRelease) { + throw new Error(`Could not find previous release in ${GITHUB_REPOSITORY}`); + } + + let startDate = new Date(previousRelease.created_at); + let endDate = new Date(lastRelease.created_at); + + let prs = await octokit.paginate(octokit.pulls.list, { + owner, + repo, + state: "closed", + sort: "updated", + direction: "desc", + }); + + let mergedPullRequestsSinceLastTag = prs.filter((pullRequest) => { + if (!pullRequest.merged_at) return false; + let mergedDate = new Date(pullRequest.merged_at); + return mergedDate > startDate && mergedDate < endDate; + }); + + let prsWithFiles = await Promise.all( + mergedPullRequestsSinceLastTag.map(async (pr) => { + let files = await octokit.paginate(octokit.pulls.listFiles, { + owner, + repo, + per_page: 100, + pull_number: pr.number, + }); + + return { + ...pr, + files, + }; + }) + ); + + return { + previousRelease: previousRelease.tag_name, + merged: prsWithFiles.filter((pr) => { + return pr.files.some((file) => { + return checkIfStringStartsWith(file.filename, PR_FILES_STARTS_WITH); + }); + }), + }; +} + +export async function commentOnPullRequest({ owner, repo, pr, version }) { + await octokit.issues.createComment({ + owner, + repo, + issue_number: pr, + body: `🤖 Hello there,\n\nWe just published version \`${version}\` which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!\n\nThanks!`, + }); +} + +export async function commentOnIssue({ owner, repo, issue, version }) { + await octokit.issues.createComment({ + owner, + repo, + issue_number: issue, + body: `🤖 Hello there,\n\nWe just published version \`${version}\` which involves this issue. If you'd like to take it for a test run please try it out and let us know what you think!\n\nThanks!`, + }); +} + +async function getIssuesLinkedToPullRequest(prHtmlUrl, nodes = [], after) { + let res = await graphqlWithAuth( + gql` + query GET_ISSUES_CLOSED_BY_PR($prHtmlUrl: URI!, $after: String) { + resource(url: $prHtmlUrl) { + ... on PullRequest { + closingIssuesReferences(first: 100, after: $after) { + nodes { + number + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `, + { prHtmlUrl, after } + ); + + let newNodes = res?.resource?.closingIssuesReferences?.nodes ?? []; + nodes.push(...newNodes); + + if (res?.resource?.closingIssuesReferences?.pageInfo?.hasNextPage) { + return getIssuesLinkedToPullRequest( + prHtmlUrl, + nodes, + res?.resource?.closingIssuesReferences?.pageInfo?.endCursor + ); + } + + return nodes; +} + +export async function getIssuesClosedByPullRequests(prHtmlUrl, prBody) { + let linked = await getIssuesLinkedToPullRequest(prHtmlUrl); + if (!prBody) return linked; + + /** + * This regex matches for one of github's issue references for auto linking an issue to a PR + * as that only happens when the PR is sent to the default branch of the repo + * https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword + */ + let regex = + /(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s#([0-9]+)/gi; + let matches = prBody.match(regex); + if (!matches) return linked; + + let issues = matches.map((match) => { + let [, issueNumber] = match.split(" #"); + return { number: parseInt(issueNumber, 10) }; + }); + + return [...linked, ...issues.filter((issue) => issue !== null)]; +} + +function checkIfStringStartsWith(string, substrings) { + return substrings.some((substr) => string.startsWith(substr)); +} diff --git a/yarn.lock b/yarn.lock index 87efb26bb3..23db6c1aec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1437,6 +1437,107 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@octokit/auth-token@^2.4.4": + version "2.5.0" + resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36" + integrity sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g== + dependencies: + "@octokit/types" "^6.0.3" + +"@octokit/core@^3.5.1": + version "3.6.0" + resolved "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz#3376cb9f3008d9b3d110370d90e0a1fcd5fe6085" + integrity sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.6.3" + "@octokit/request-error" "^2.0.5" + "@octokit/types" "^6.0.3" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^6.0.1": + version "6.0.12" + resolved "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658" + integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA== + dependencies: + "@octokit/types" "^6.0.3" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^4.5.8", "@octokit/graphql@^4.8.0": + version "4.8.0" + resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz#664d9b11c0e12112cbf78e10f49a05959aa22cc3" + integrity sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg== + dependencies: + "@octokit/request" "^5.6.0" + "@octokit/types" "^6.0.3" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^11.2.0": + version "11.2.0" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-11.2.0.tgz#b38d7fc3736d52a1e96b230c1ccd4a58a2f400a6" + integrity sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA== + +"@octokit/plugin-paginate-rest@^2.16.8", "@octokit/plugin-paginate-rest@^2.17.0": + version "2.17.0" + resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.17.0.tgz#32e9c7cab2a374421d3d0de239102287d791bce7" + integrity sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw== + dependencies: + "@octokit/types" "^6.34.0" + +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@^5.12.0": + version "5.13.0" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.13.0.tgz#8c46109021a3412233f6f50d28786f8e552427ba" + integrity sha512-uJjMTkN1KaOIgNtUPMtIXDOjx6dGYysdIFhgA52x4xSadQCz3b/zJexvITDVpANnfKPW/+E0xkOvLntqMYpviA== + dependencies: + "@octokit/types" "^6.34.0" + deprecation "^2.3.1" + +"@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" + integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg== + dependencies: + "@octokit/types" "^6.0.3" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^5.6.0", "@octokit/request@^5.6.3": + version "5.6.3" + resolved "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz#19a022515a5bba965ac06c9d1334514eb50c48b0" + integrity sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A== + dependencies: + "@octokit/endpoint" "^6.0.1" + "@octokit/request-error" "^2.1.0" + "@octokit/types" "^6.16.1" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/rest@^18.12.0": + version "18.12.0" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.12.0.tgz#f06bc4952fc87130308d810ca9d00e79f6988881" + integrity sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q== + dependencies: + "@octokit/core" "^3.5.1" + "@octokit/plugin-paginate-rest" "^2.16.8" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^5.12.0" + +"@octokit/types@^6.0.3", "@octokit/types@^6.16.1", "@octokit/types@^6.34.0": + version "6.34.0" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.34.0.tgz#c6021333334d1ecfb5d370a8798162ddf1ae8218" + integrity sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw== + dependencies: + "@octokit/openapi-types" "^11.2.0" + "@react-native-community/cli-debugger-ui@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-3.0.0.tgz" @@ -2600,6 +2701,11 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" +before-after-hook@^2.2.0: + version "2.2.2" + resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e" + integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ== + big-integer@^1.6.44: version "1.6.50" resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.50.tgz" @@ -3346,6 +3452,11 @@ depd@~2.0.0: resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + destroy@~1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz" @@ -4852,6 +4963,11 @@ is-plain-object@^3.0.0: resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz" integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g== +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz" @@ -6406,6 +6522,13 @@ node-fetch@^2.2.0, node-fetch@^2.5.0, node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -8552,6 +8675,11 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^2.0.1" +universal-user-agent@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" + integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz"