Skip to content

Commit 1b636aa

Browse files
authored
ci: add workflow to comment on issues/PRs about new releases (#8981)
1 parent 854f4a4 commit 1b636aa

File tree

7 files changed

+444
-0
lines changed

7 files changed

+444
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: 📝 Comment on Release
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
ref:
7+
required: true
8+
type: string
9+
10+
jobs:
11+
comment:
12+
name: Comment on Release
13+
if: github.repository == 'remix-run/react-router'
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: ⬇️ Checkout repo
17+
uses: actions/checkout@v3
18+
19+
- name: ⎔ Setup node
20+
uses: actions/setup-node@v3
21+
with:
22+
node-version-file: ".nvmrc"
23+
cache: "yarn"
24+
25+
- name: 📥 Install deps
26+
# even though this is called "npm-install" it does use yarn to install
27+
# because we have a yarn.lock and caches efficiently.
28+
uses: bahmutov/npm-install@v1
29+
30+
- name: 📝 Comment on issues
31+
run: node ./scripts/release/comment.mjs
32+
env:
33+
GITHUB_REPOSITORY: ${{ github.repository }}
34+
GITHUB_TOKEN: ${{ github.token }}
35+
VERSION: ${{ inputs.ref }}

.github/workflows/release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ jobs:
3333
run: |
3434
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
3535
node scripts/publish.js
36+
37+
comment:
38+
needs: [release]
39+
name: 📝 Comment on related issues and pull requests
40+
if: github.repository == 'remix-run/react-router'
41+
uses: remix-run/react-router/.github/workflows/release-comments.yml@main
42+
with:
43+
ref: ${{ github.ref }}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
"@babel/preset-modules": "^0.1.4",
2121
"@babel/preset-react": "^7.14.5",
2222
"@babel/preset-typescript": "^7.15.0",
23+
"@octokit/graphql": "^4.8.0",
24+
"@octokit/plugin-paginate-rest": "^2.17.0",
25+
"@octokit/rest": "^18.12.0",
2326
"@rollup/plugin-replace": "^2.2.1",
2427
"@types/jest": "26.x",
2528
"@types/jsonfile": "^6.0.1",

scripts/release/comment.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
commentOnIssue,
3+
commentOnPullRequest,
4+
getIssuesClosedByPullRequests,
5+
prsMergedSinceLast,
6+
} from "./octokit.mjs";
7+
import { LATEST_RELEASE, OWNER, REPO } from "./constants.mjs";
8+
9+
async function commentOnIssuesAndPrsAboutRelease() {
10+
if (LATEST_RELEASE.includes("experimental")) {
11+
return;
12+
}
13+
14+
let { merged, previousRelease } = await prsMergedSinceLast({
15+
owner: OWNER,
16+
repo: REPO,
17+
lastRelease: LATEST_RELEASE,
18+
});
19+
20+
let suffix = merged.length === 1 ? "" : "s";
21+
console.log(
22+
`Found ${merged.length} PR${suffix} merged since last release (latest: ${LATEST_RELEASE}, previous: ${previousRelease})`
23+
);
24+
25+
let promises = [];
26+
let issuesCommentedOn = new Set();
27+
28+
for (let pr of merged) {
29+
console.log(`commenting on pr #${pr.number}`);
30+
31+
promises.push(
32+
commentOnPullRequest({
33+
owner: OWNER,
34+
repo: REPO,
35+
pr: pr.number,
36+
version: LATEST_RELEASE,
37+
})
38+
);
39+
40+
let issuesClosed = await getIssuesClosedByPullRequests(
41+
pr.html_url,
42+
pr.body
43+
);
44+
45+
for (let issue of issuesClosed) {
46+
if (issuesCommentedOn.has(issue.number)) {
47+
// already commented on this issue
48+
continue;
49+
}
50+
issuesCommentedOn.add(issue.number);
51+
console.log(`commenting on issue #${issue.number}`);
52+
promises.push(
53+
commentOnIssue({
54+
issue: issue.number,
55+
owner: OWNER,
56+
repo: REPO,
57+
version: LATEST_RELEASE,
58+
})
59+
);
60+
}
61+
}
62+
63+
await Promise.all(promises);
64+
}
65+
66+
commentOnIssuesAndPrsAboutRelease();

scripts/release/constants.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
if (!process.env.GITHUB_TOKEN) {
2+
throw new Error("GITHUB_TOKEN is required");
3+
}
4+
if (!process.env.GITHUB_REPOSITORY) {
5+
throw new Error("GITHUB_REPOSITORY is required");
6+
}
7+
if (!process.env.VERSION) {
8+
throw new Error("VERSION is required");
9+
}
10+
if (!process.env.VERSION.startsWith("refs/tags/")) {
11+
throw new Error("VERSION must be a tag, received " + process.env.VERSION);
12+
}
13+
14+
export const [OWNER, REPO] = process.env.GITHUB_REPOSITORY.split("/");
15+
export const LATEST_RELEASE = process.env.VERSION.replace("refs/tags/", "");
16+
export const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
17+
export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
18+
export const PR_FILES_STARTS_WITH = ["packages/"];

scripts/release/octokit.mjs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { Octokit as RestOctokit } from "@octokit/rest";
2+
import { paginateRest } from "@octokit/plugin-paginate-rest";
3+
import { graphql } from "@octokit/graphql";
4+
5+
import {
6+
GITHUB_TOKEN,
7+
GITHUB_REPOSITORY,
8+
PR_FILES_STARTS_WITH,
9+
} from "./constants.mjs";
10+
11+
const graphqlWithAuth = graphql.defaults({
12+
headers: { authorization: `token ${GITHUB_TOKEN}` },
13+
});
14+
15+
const Octokit = RestOctokit.plugin(paginateRest);
16+
const octokit = new Octokit({ auth: GITHUB_TOKEN });
17+
18+
const gql = String.raw;
19+
20+
export async function prsMergedSinceLast({
21+
owner,
22+
repo,
23+
lastRelease: lastReleaseVersion,
24+
}) {
25+
let releases = await octokit.paginate(octokit.rest.repos.listReleases, {
26+
owner,
27+
repo,
28+
per_page: 100,
29+
});
30+
31+
let sorted = releases
32+
.sort((a, b) => {
33+
return new Date(b.published_at) - new Date(a.published_at);
34+
})
35+
.filter((release) => {
36+
return release.tag_name.includes("experimental") === false;
37+
});
38+
39+
let lastReleaseIndex = sorted.findIndex((release) => {
40+
return release.tag_name === lastReleaseVersion;
41+
});
42+
43+
let lastRelease = sorted[lastReleaseIndex];
44+
if (!lastRelease) {
45+
throw new Error(
46+
`Could not find last release ${lastRelease} in ${GITHUB_REPOSITORY}`
47+
);
48+
}
49+
50+
// if the lastRelease was a stable release, then we want to find the previous stable release
51+
let previousRelease;
52+
if (lastRelease.prerelease === false) {
53+
let stableReleases = sorted.filter((release) => {
54+
return release.prerelease === false;
55+
});
56+
previousRelease = stableReleases.at(1);
57+
} else {
58+
previousRelease = sorted.at(lastReleaseIndex + 1);
59+
}
60+
61+
if (!previousRelease) {
62+
throw new Error(`Could not find previous release in ${GITHUB_REPOSITORY}`);
63+
}
64+
65+
let startDate = new Date(previousRelease.created_at);
66+
let endDate = new Date(lastRelease.created_at);
67+
68+
let prs = await octokit.paginate(octokit.pulls.list, {
69+
owner,
70+
repo,
71+
state: "closed",
72+
sort: "updated",
73+
direction: "desc",
74+
});
75+
76+
let mergedPullRequestsSinceLastTag = prs.filter((pullRequest) => {
77+
if (!pullRequest.merged_at) return false;
78+
let mergedDate = new Date(pullRequest.merged_at);
79+
return mergedDate > startDate && mergedDate < endDate;
80+
});
81+
82+
let prsWithFiles = await Promise.all(
83+
mergedPullRequestsSinceLastTag.map(async (pr) => {
84+
let files = await octokit.paginate(octokit.pulls.listFiles, {
85+
owner,
86+
repo,
87+
per_page: 100,
88+
pull_number: pr.number,
89+
});
90+
91+
return {
92+
...pr,
93+
files,
94+
};
95+
})
96+
);
97+
98+
return {
99+
previousRelease: previousRelease.tag_name,
100+
merged: prsWithFiles.filter((pr) => {
101+
return pr.files.some((file) => {
102+
return checkIfStringStartsWith(file.filename, PR_FILES_STARTS_WITH);
103+
});
104+
}),
105+
};
106+
}
107+
108+
export async function commentOnPullRequest({ owner, repo, pr, version }) {
109+
await octokit.issues.createComment({
110+
owner,
111+
repo,
112+
issue_number: pr,
113+
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!`,
114+
});
115+
}
116+
117+
export async function commentOnIssue({ owner, repo, issue, version }) {
118+
await octokit.issues.createComment({
119+
owner,
120+
repo,
121+
issue_number: issue,
122+
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!`,
123+
});
124+
}
125+
126+
async function getIssuesLinkedToPullRequest(prHtmlUrl, nodes = [], after) {
127+
let res = await graphqlWithAuth(
128+
gql`
129+
query GET_ISSUES_CLOSED_BY_PR($prHtmlUrl: URI!, $after: String) {
130+
resource(url: $prHtmlUrl) {
131+
... on PullRequest {
132+
closingIssuesReferences(first: 100, after: $after) {
133+
nodes {
134+
number
135+
}
136+
pageInfo {
137+
hasNextPage
138+
endCursor
139+
}
140+
}
141+
}
142+
}
143+
}
144+
`,
145+
{ prHtmlUrl, after }
146+
);
147+
148+
let newNodes = res?.resource?.closingIssuesReferences?.nodes ?? [];
149+
nodes.push(...newNodes);
150+
151+
if (res?.resource?.closingIssuesReferences?.pageInfo?.hasNextPage) {
152+
return getIssuesLinkedToPullRequest(
153+
prHtmlUrl,
154+
nodes,
155+
res?.resource?.closingIssuesReferences?.pageInfo?.endCursor
156+
);
157+
}
158+
159+
return nodes;
160+
}
161+
162+
export async function getIssuesClosedByPullRequests(prHtmlUrl, prBody) {
163+
let linked = await getIssuesLinkedToPullRequest(prHtmlUrl);
164+
if (!prBody) return linked;
165+
166+
/**
167+
* This regex matches for one of github's issue references for auto linking an issue to a PR
168+
* as that only happens when the PR is sent to the default branch of the repo
169+
* 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
170+
*/
171+
let regex =
172+
/(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s#([0-9]+)/gi;
173+
let matches = prBody.match(regex);
174+
if (!matches) return linked;
175+
176+
let issues = matches.map((match) => {
177+
let [, issueNumber] = match.split(" #");
178+
return { number: parseInt(issueNumber, 10) };
179+
});
180+
181+
return [...linked, ...issues.filter((issue) => issue !== null)];
182+
}
183+
184+
function checkIfStringStartsWith(string, substrings) {
185+
return substrings.some((substr) => string.startsWith(substr));
186+
}

0 commit comments

Comments
 (0)