Skip to content

Commit

Permalink
ci: Improve release changelog by adding the PR to items as well as th…
Browse files Browse the repository at this point in the history
…e username of the contributor (#31596)
  • Loading branch information
sampaiodiego authored Feb 1, 2024
1 parent 90c4928 commit 93e1444
Show file tree
Hide file tree
Showing 7 changed files with 538 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/changelog-git",
"changelog": ["@rocket.chat/release-changelog", { "repo": "RocketChat/Rocket.Chat" }],
"commit": false,
"fixed": [
["@rocket.chat/meteor", "@rocket.chat/core-typings", "@rocket.chat/rest-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/release-changelog/.eslintrc.json
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"]
}
21 changes: 21 additions & 0 deletions packages/release-changelog/package.json
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"
}
}
232 changes: 232 additions & 0 deletions packages/release-changelog/src/getGitHubInfo.ts
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,
},
};
}
114 changes: 114 additions & 0 deletions packages/release-changelog/src/index.ts
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;
Loading

0 comments on commit 93e1444

Please sign in to comment.