-
-
Notifications
You must be signed in to change notification settings - Fork 10.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ci: add workflow to comment on issues/PRs about new releases (#8981)
- Loading branch information
Showing
7 changed files
with
444 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/"]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} |
Oops, something went wrong.