-
Notifications
You must be signed in to change notification settings - Fork 10.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ci: Improve release changelog by adding the PR to items as well as th…
…e username of the contributor (#31596)
- Loading branch information
1 parent
90c4928
commit 93e1444
Showing
7 changed files
with
538 additions
and
12 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
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,8 @@ | ||
{ | ||
"extends": ["@rocket.chat/eslint-config"], | ||
"plugins": ["jest"], | ||
"env": { | ||
"jest/globals": true | ||
}, | ||
"ignorePatterns": ["**/dist"] | ||
} |
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,21 @@ | ||
{ | ||
"name": "@rocket.chat/release-changelog", | ||
"version": "0.1.0", | ||
"private": true, | ||
"scripts": { | ||
"build": "tsc", | ||
"lint": "eslint src" | ||
}, | ||
"main": "dist/index.js", | ||
"devDependencies": { | ||
"@changesets/types": "^6.0.0", | ||
"@rocket.chat/eslint-config": "workspace:^", | ||
"@types/node": "^14.18.63", | ||
"eslint": "~8.45.0", | ||
"typescript": "~5.3.2" | ||
}, | ||
"dependencies": { | ||
"dataloader": "^1.4.0", | ||
"node-fetch": "^2" | ||
} | ||
} |
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,232 @@ | ||
import DataLoader from 'dataloader'; | ||
import fetch from 'node-fetch'; | ||
|
||
const validRepoNameRegex = /^[\w.-]+\/[\w.-]+$/; | ||
|
||
type RequestData = { kind: 'commit'; repo: string; commit: string } | { kind: 'pull'; repo: string; pull: number }; | ||
|
||
type ReposWithCommitsAndPRsToFetch = Record<string, ({ kind: 'commit'; commit: string } | { kind: 'pull'; pull: number })[]>; | ||
|
||
function makeQuery(repos: ReposWithCommitsAndPRsToFetch) { | ||
return ` | ||
query { | ||
${Object.keys(repos) | ||
.map( | ||
(repo, i) => | ||
`a${i}: repository( | ||
owner: ${JSON.stringify(repo.split('/')[0])} | ||
name: ${JSON.stringify(repo.split('/')[1])} | ||
) { | ||
${repos[repo] | ||
.map((data) => | ||
data.kind === 'commit' | ||
? `a${data.commit}: object(expression: ${JSON.stringify(data.commit)}) { | ||
... on Commit { | ||
commitUrl | ||
associatedPullRequests(first: 50) { | ||
nodes { | ||
number | ||
url | ||
mergedAt | ||
authorAssociation | ||
author { | ||
login | ||
url | ||
} | ||
} | ||
} | ||
author { | ||
user { | ||
login | ||
url | ||
} | ||
} | ||
}}` | ||
: `pr__${data.pull}: pullRequest(number: ${data.pull}) { | ||
url | ||
author { | ||
login | ||
url | ||
} | ||
mergeCommit { | ||
commitUrl | ||
abbreviatedOid | ||
} | ||
}`, | ||
) | ||
.join('\n')} | ||
}`, | ||
) | ||
.join('\n')} | ||
} | ||
`; | ||
} | ||
|
||
// why are we using dataloader? | ||
// it provides use with two things | ||
// 1. caching | ||
// since getInfo will be called inside of changeset's getReleaseLine | ||
// and there could be a lot of release lines for a single commit | ||
// caching is important so we don't do a bunch of requests for the same commit | ||
// 2. batching | ||
// getReleaseLine will be called a large number of times but it'll be called at the same time | ||
// so instead of doing a bunch of network requests, we can do a single one. | ||
const GHDataLoader = new DataLoader(async (requests: RequestData[]) => { | ||
if (!process.env.GITHUB_TOKEN) { | ||
throw new Error( | ||
'Please create a GitHub personal access token at https://github.com/settings/tokens/new with `read:user` and `repo:status` permissions and add it as the GITHUB_TOKEN environment variable', | ||
); | ||
} | ||
const repos: ReposWithCommitsAndPRsToFetch = {}; | ||
requests.forEach(({ repo, ...data }) => { | ||
if (repos[repo] === undefined) { | ||
repos[repo] = []; | ||
} | ||
repos[repo].push(data); | ||
}); | ||
|
||
const data = await fetch('https://api.github.com/graphql', { | ||
method: 'POST', | ||
headers: { | ||
Authorization: `Token ${process.env.GITHUB_TOKEN}`, | ||
}, | ||
body: JSON.stringify({ query: makeQuery(repos) }), | ||
}).then((x: any) => x.json()); | ||
|
||
if (data.errors) { | ||
throw new Error(`An error occurred when fetching data from GitHub\n${JSON.stringify(data.errors, null, 2)}`); | ||
} | ||
|
||
// this is mainly for the case where there's an authentication problem | ||
if (!data.data) { | ||
throw new Error(`An error occurred when fetching data from GitHub\n${JSON.stringify(data)}`); | ||
} | ||
|
||
const cleanedData: Record<string, { commit: Record<string, any>; pull: Record<string, any> }> = {}; | ||
Object.keys(repos).forEach((repo, index) => { | ||
const output: { commit: Record<string, any>; pull: Record<string, any> } = { | ||
commit: {}, | ||
pull: {}, | ||
}; | ||
cleanedData[repo] = output; | ||
Object.entries(data.data[`a${index}`]).forEach(([field, value]) => { | ||
// this is "a" because that's how it was when it was first written, "a" means it's a commit not a pr | ||
// we could change it to commit__ but then we have to get new GraphQL results from the GH API to put in the tests | ||
if (field[0] === 'a') { | ||
output.commit[field.substring(1)] = value; | ||
} else { | ||
output.pull[field.replace('pr__', '')] = value; | ||
} | ||
}); | ||
}); | ||
|
||
return requests.map(({ repo, ...data }) => cleanedData[repo][data.kind][data.kind === 'pull' ? data.pull : data.commit]); | ||
}); | ||
|
||
type UserType = { login: string; association: 'CONTRIBUTOR' | 'MEMBER' | 'OWNER' } | null; | ||
|
||
export async function getInfo(request: { commit: string; repo: string }): Promise<{ | ||
user: UserType; | ||
pull: number | null; | ||
links: { | ||
commit: string; | ||
pull: string | null; | ||
user: string | null; | ||
}; | ||
}> { | ||
if (!request.commit) { | ||
throw new Error('Please pass a commit SHA to getInfo'); | ||
} | ||
|
||
if (!request.repo) { | ||
throw new Error('Please pass a GitHub repository in the form of userOrOrg/repoName to getInfo'); | ||
} | ||
|
||
if (!validRepoNameRegex.test(request.repo)) { | ||
throw new Error( | ||
`Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the "${validRepoNameRegex.source}" pattern)`, | ||
); | ||
} | ||
|
||
const data = await GHDataLoader.load({ kind: 'commit', ...request }); | ||
let user = null; | ||
if (data.author?.user) { | ||
user = { association: 'MEMBER', ...data.author.user }; | ||
} | ||
|
||
const associatedPullRequest = data.associatedPullRequests?.nodes?.length | ||
? (data.associatedPullRequests.nodes as any[]).sort((a, b) => { | ||
if (a.mergedAt === null && b.mergedAt === null) { | ||
return 0; | ||
} | ||
if (a.mergedAt === null) { | ||
return 1; | ||
} | ||
if (b.mergedAt === null) { | ||
return -1; | ||
} | ||
a = new Date(a.mergedAt); | ||
b = new Date(b.mergedAt); | ||
|
||
if (a > b) { | ||
return 1; | ||
} | ||
|
||
return a < b ? -1 : 0; | ||
})[0] | ||
: null; | ||
if (associatedPullRequest) { | ||
user = { | ||
association: associatedPullRequest.authorAssociation, | ||
...associatedPullRequest.author, | ||
}; | ||
} | ||
return { | ||
user, | ||
pull: associatedPullRequest ? associatedPullRequest.number : null, | ||
links: { | ||
commit: `[\`${request.commit.slice(0, 7)}\`](${data.commitUrl})`, | ||
pull: associatedPullRequest ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` : null, | ||
user: user ? `[@${user.login}](${user.url})` : null, | ||
}, | ||
}; | ||
} | ||
|
||
export async function getInfoFromPullRequest(request: { pull: number; repo: string }): Promise<{ | ||
user: UserType; | ||
commit: string | null; | ||
links: { | ||
commit: string | null; | ||
pull: string; | ||
user: string | null; | ||
}; | ||
}> { | ||
if (request.pull === undefined) { | ||
throw new Error('Please pass a pull request number'); | ||
} | ||
|
||
if (!request.repo) { | ||
throw new Error('Please pass a GitHub repository in the form of userOrOrg/repoName to getInfo'); | ||
} | ||
|
||
if (!validRepoNameRegex.test(request.repo)) { | ||
throw new Error( | ||
`Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the "${validRepoNameRegex.source}" pattern)`, | ||
); | ||
} | ||
|
||
const data = await GHDataLoader.load({ kind: 'pull', ...request }); | ||
const user = data?.author; | ||
|
||
const commit = data?.mergeCommit; | ||
|
||
return { | ||
user: user ? user.login : null, | ||
commit: commit ? commit.abbreviatedOid : null, | ||
links: { | ||
commit: commit ? `[\`${commit.abbreviatedOid.slice(0, 7)}\`](${commit.commitUrl})` : null, | ||
pull: `[#${request.pull}](https://github.com/${request.repo}/pull/${request.pull})`, | ||
user: user ? `[@${user.login}](${user.url})` : null, | ||
}, | ||
}; | ||
} |
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,114 @@ | ||
import type { ChangelogFunctions } from '@changesets/types'; | ||
|
||
import { getInfo, getInfoFromPullRequest } from './getGitHubInfo'; | ||
|
||
const changelogFunctions: ChangelogFunctions = { | ||
getReleaseLine: async (changeset, _type, options) => { | ||
if (!options?.repo) { | ||
throw new Error( | ||
'Please provide a repo to this changelog generator like this:\n"changelog": ["@rocket.chat/release-changelog", { "repo": "org/repo" }]', | ||
); | ||
} | ||
|
||
let prFromSummary: number | undefined; | ||
let commitFromSummary: string | undefined; | ||
const usersFromSummary: string[] = []; | ||
|
||
const replacedChangelog = changeset.summary | ||
.replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => { | ||
const num = Number(pr); | ||
if (!isNaN(num)) prFromSummary = num; | ||
return ''; | ||
}) | ||
.replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => { | ||
commitFromSummary = commit; | ||
return ''; | ||
}) | ||
.replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => { | ||
usersFromSummary.push(user); | ||
return ''; | ||
}) | ||
.trim(); | ||
|
||
const [firstLine, ...futureLines] = replacedChangelog.split('\n').map((l) => l.trimEnd()); | ||
|
||
const links = await (async () => { | ||
if (prFromSummary !== undefined) { | ||
const result = await getInfoFromPullRequest({ | ||
repo: options.repo, | ||
pull: prFromSummary, | ||
}); | ||
|
||
let { links } = result; | ||
if (commitFromSummary) { | ||
const shortCommitId = commitFromSummary.slice(0, 7); | ||
links = { | ||
...links, | ||
commit: `[\`${shortCommitId}\`](https://github.com/${options.repo}/commit/${commitFromSummary})`, | ||
}; | ||
} | ||
|
||
const { user } = result; | ||
|
||
return { | ||
...links, | ||
user, | ||
}; | ||
} | ||
const commitToFetchFrom = commitFromSummary || changeset.commit; | ||
if (commitToFetchFrom) { | ||
const { links, user } = await getInfo({ | ||
repo: options.repo, | ||
commit: commitToFetchFrom, | ||
}); | ||
return { ...links, user }; | ||
} | ||
return { | ||
commit: null, | ||
pull: null, | ||
user: null, | ||
}; | ||
})(); | ||
|
||
const users = (() => { | ||
if (usersFromSummary.length) { | ||
return usersFromSummary.map((userFromSummary) => `[@${userFromSummary}](https://github.com/${userFromSummary})`).join(', '); | ||
} | ||
|
||
if (links.user?.association === 'CONTRIBUTOR') { | ||
return `[@${links.user.login}](https://github.com/${links.user.login})`; | ||
} | ||
})(); | ||
|
||
const prefix = [ | ||
links.pull === null ? '' : links.pull, | ||
// links.commit === null ? '' : links.commit, | ||
users ? `by ${users}` : '', | ||
] | ||
.filter(Boolean) | ||
.join(' '); | ||
|
||
return `-${prefix ? ` (${prefix})` : ''} ${firstLine}\n${futureLines.map((l) => ` ${l}`).join('\n')}`; | ||
}, | ||
getDependencyReleaseLine: async (changesets, dependenciesUpdated, options) => { | ||
if (!options.repo) { | ||
throw new Error( | ||
'Please provide a repo to this changelog generator like this:\n"changelog": ["@rocket.chat/release-changelog", { "repo": "org/repo" }]', | ||
); | ||
} | ||
if (dependenciesUpdated.length === 0) return ''; | ||
|
||
const commits = changesets | ||
.map((cs) => cs.commit) | ||
.filter((_) => _) | ||
.join(', '); | ||
|
||
const changesetLink = `- <details><summary>Updated dependencies [${commits}]:</summary>\n`; | ||
|
||
const updatedDepenenciesList = dependenciesUpdated.map((dependency) => ` - ${dependency.name}@${dependency.newVersion}`); | ||
|
||
return [changesetLink, ...updatedDepenenciesList, ' </details>'].join('\n'); | ||
}, | ||
}; | ||
|
||
export default changelogFunctions; |
Oops, something went wrong.