Skip to content

Commit

Permalink
ci: add workflow to comment on issues/PRs about new releases (#8981)
Browse files Browse the repository at this point in the history
  • Loading branch information
mcansh committed Jun 22, 2022
1 parent 854f4a4 commit 1b636aa
Show file tree
Hide file tree
Showing 7 changed files with 444 additions and 0 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/release-comments.yml
Original file line number Diff line number Diff line change
@@ -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 }}
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions scripts/release/comment.mjs
Original file line number Diff line number Diff line change
@@ -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();
18 changes: 18 additions & 0 deletions scripts/release/constants.mjs
Original file line number Diff line number Diff line change
@@ -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/"];
186 changes: 186 additions & 0 deletions scripts/release/octokit.mjs
Original file line number Diff line number Diff line change
@@ -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));
}

0 comments on commit 1b636aa

Please sign in to comment.