diff --git a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts new file mode 100644 index 000000000..0bf34182e --- /dev/null +++ b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts @@ -0,0 +1,328 @@ +import * as core from '@actions/core'; +import { context, getOctokit } from '@actions/github'; +import { GitHub } from '@actions/github/lib/utils'; + +// A labelable object can be a pull request or an issue +interface Labelable { + id: string; + number: number; + repoOwner: string; + repoName: string; + createdAt: string; +} + +main().catch((error: Error): void => { + console.error(error); + process.exit(1); +}); + +async function main(): Promise { + // "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions. + // We can't use "GITHUB_TOKEN" here, as its permissions are scoped to the repository where the action is running. + // "GITHUB_TOKEN" does not have access to other repositories, even when they belong to the same organization. + // As we want to update linked issues which are not necessarily located in the same repository, + // we need to create our own "RELEASE_LABEL_TOKEN" with "repo" permissions. + // Such a token allows to access other repositories of the MetaMask organisation. + const personalAccessToken = process.env.RELEASE_LABEL_TOKEN; + if (!personalAccessToken) { + core.setFailed('RELEASE_LABEL_TOKEN not found'); + process.exit(1); + } + + const nextReleaseVersionNumber = process.env.NEXT_SEMVER_VERSION; + if (!nextReleaseVersionNumber) { + // NEXT_SEMVER_VERSION is automatically deduced as minor version bump on top of the latest version + // found, either in repo's list of branches, or in repo's list of tags or in repo's "package.json" version. + // For edge cases (e.g. major version bumps, etc.), where the value can not be deduced automatically, + // NEXT_SEMVER_VERSION can be defined manually set by defining FORCE_NEXT_SEMVER_VERSION variable in + // section "Secrets and variables">"Actions">"Variables">"New repository variable" in the settings of this repo. + // Example value: 6.5.0 + core.setFailed('NEXT_SEMVER_VERSION not found'); + process.exit(1); + } + + if (!isValidVersionFormat(nextReleaseVersionNumber)) { + core.setFailed(`NEXT_SEMVER_VERSION (${nextReleaseVersionNumber}) is not a valid version format. The expected format is "x.y.z", where "x", "y" and "z" are numbers.`); + process.exit(1); + } + + // Release label indicates the next release version number + // Example release label: "release-6.5.0" + const releaseLabelName = `release-${nextReleaseVersionNumber}`; + const releaseLabelColor = "ededed"; + const releaseLabelDescription = `Issue or pull request that will be included in release ${nextReleaseVersionNumber}`; + + // Initialise octokit, required to call Github GraphQL API + const octokit: InstanceType = getOctokit( + personalAccessToken, + { + previews: ["bane"], // The "bane" preview is required for adding, updating, creating and deleting labels. + }, + ); + + // Retrieve pull request info from context + const prRepoOwner = context.repo.owner; + const prRepoName = context.repo.repo; + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { + core.setFailed('Pull request number not found'); + process.exit(1); + } + + // Retrieve pull request + const pullRequest: Labelable = await retrievePullRequest(octokit, prRepoOwner, prRepoName, prNumber); + + // Add the release label to the pull request + await addLabelToLabelable(octokit, pullRequest, releaseLabelName, releaseLabelColor, releaseLabelDescription); + + // Retrieve linked issues for the pull request + const linkedIssues: Labelable[] = await retrieveLinkedIssues(octokit, prRepoOwner, prRepoName, prNumber); + + // Add the release label to the linked issues + for (const linkedIssue of linkedIssues) { + await addLabelToLabelable(octokit, linkedIssue, releaseLabelName, releaseLabelColor, releaseLabelDescription); + } +} + +// This helper function checks if version has the correct format: "x.y.z" where "x", "y" and "z" are numbers. +function isValidVersionFormat(str: string): boolean { + const regex = /^\d+\.\d+\.\d+$/; + return regex.test(str); +} + +// This function retrieves the repo +async function retrieveRepo(octokit: InstanceType, repoOwner: string, repoName: string): Promise { + + const retrieveRepoQuery = ` + query RetrieveRepo($repoOwner: String!, $repoName: String!) { + repository(owner: $repoOwner, name: $repoName) { + id + } + } +`; + + const retrieveRepoResult: { + repository: { + id: string; + }; + } = await octokit.graphql(retrieveRepoQuery, { + repoOwner, + repoName, + }); + + const repoId = retrieveRepoResult?.repository?.id; + + return repoId; +} + +// This function retrieves the label on a specific repo +async function retrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string): Promise { + + const retrieveLabelQuery = ` + query RetrieveLabel($repoOwner: String!, $repoName: String!, $labelName: String!) { + repository(owner: $repoOwner, name: $repoName) { + label(name: $labelName) { + id + } + } + } + `; + + const retrieveLabelResult: { + repository: { + label: { + id: string; + }; + }; + } = await octokit.graphql(retrieveLabelQuery, { + repoOwner, + repoName, + labelName, + }); + + const labelId = retrieveLabelResult?.repository?.label?.id; + + return labelId; +} + +// This function creates the label on a specific repo +async function createLabel(octokit: InstanceType, repoId: string, labelName: string, labelColor: string, labelDescription: string): Promise { + + const createLabelMutation = ` + mutation CreateLabel($repoId: ID!, $labelName: String!, $labelColor: String!, $labelDescription: String) { + createLabel(input: {repositoryId: $repoId, name: $labelName, color: $labelColor, description: $labelDescription}) { + label { + id + } + } + } + `; + + const createLabelResult: { + createLabel: { + label: { + id: string; + }; + }; + } = await octokit.graphql(createLabelMutation, { + repoId, + labelName, + labelColor, + labelDescription, + }); + + const labelId = createLabelResult?.createLabel?.label?.id; + + return labelId; +} + +// This function creates or retrieves the label on a specific repo +async function createOrRetrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string, labelColor: string, labelDescription: string): Promise { + + // Check if label already exists on the repo + let labelId = await retrieveLabel(octokit, repoOwner, repoName, labelName); + + // If label doesn't exist on the repo, create it + if (!labelId) { + // Retrieve PR's repo + const repoId = await retrieveRepo(octokit, repoOwner, repoName); + + // Create label on repo + labelId = await createLabel(octokit, repoId, labelName, labelColor, labelDescription); + } + + return labelId; +} + +// This function retrieves the pull request on a specific repo +async function retrievePullRequest(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { + + const retrievePullRequestQuery = ` + query GetPullRequest($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + id + createdAt + } + } + } + `; + + const retrievePullRequestResult: { + repository: { + pullRequest: { + id: string; + createdAt: string; + }; + }; + } = await octokit.graphql(retrievePullRequestQuery, { + repoOwner, + repoName, + prNumber, + }); + + const pullRequest: Labelable = { + id: retrievePullRequestResult?.repository?.pullRequest?.id, + number: prNumber, + repoOwner: repoOwner, + repoName: repoName, + createdAt: retrievePullRequestResult?.repository?.pullRequest?.createdAt, + } + + return pullRequest; +} + + +// This function retrieves the list of linked issues for a pull request +async function retrieveLinkedIssues(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { + + // We assume there won't be more than 100 linked issues + const retrieveLinkedIssuesQuery = ` + query ($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 100) { + nodes { + id + number + createdAt + repository { + name + owner { + login + } + } + } + } + } + } + } + `; + + const retrieveLinkedIssuesResult: { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: Array<{ + id: string; + number: number; + createdAt: string; + repository: { + name: string; + owner: { + login: string; + }; + }; + }>; + }; + }; + }; + } = await octokit.graphql(retrieveLinkedIssuesQuery, { + repoOwner, + repoName, + prNumber + }); + + const linkedIssues = retrieveLinkedIssuesResult?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue: { + id: string; + number: number; + createdAt: string; + repository: { + name: string; + owner: { + login: string; + }; + }; + }) => { + return { + id: issue?.id, + number: issue?.number, + repoOwner: issue?.repository?.owner?.login, + repoName: issue?.repository?.name, + createdAt: issue?.createdAt + }; + }) || []; + + return linkedIssues; +} + +// This function adds label to a labelable object (i.e. a pull request or an issue) +async function addLabelToLabelable(octokit: InstanceType, labelable: Labelable, labelName: string, labelColor: string, labelDescription: string): Promise { + + // Retrieve label from the labelable's repo, or create label if required + const labelId = await createOrRetrieveLabel(octokit, labelable?.repoOwner, labelable?.repoName, labelName, labelColor, labelDescription); + + const addLabelsToLabelableMutation = ` + mutation AddLabelsToLabelable($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { + clientMutationId + } + } + `; + + await octokit.graphql(addLabelsToLabelableMutation, { + labelableId: labelable?.id, + labelIds: [labelId], + }); + +} diff --git a/.github/workflows/add-release-label.yml b/.github/workflows/add-release-label.yml new file mode 100644 index 000000000..00f0293a6 --- /dev/null +++ b/.github/workflows/add-release-label.yml @@ -0,0 +1,42 @@ +name: Add release label to PR and linked issues when PR gets merged + +on: + pull_request: + branches: + - develop + types: + - closed + +jobs: + add-release-label: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # This is needed to checkout all branches + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install dependencies + run: yarn --immutable + + - name: Set execute permissions for script + run: chmod +x ./development/get-next-semver-version.sh + + - name: Get the next semver version + id: get-next-semver-version + env: + FORCE_NEXT_SEMVER_VERSION: ${{ vars.FORCE_NEXT_SEMVER_VERSION }} + run: ./development/get-next-semver-version.sh $FORCE_NEXT_SEMVER_VERSION + + - name: Add release label to PR and linked issues + id: add-release-label-to-pr-and-linked-issues + env: + RELEASE_LABEL_TOKEN: ${{ secrets.RELEASE_LABEL_TOKEN }} + NEXT_SEMVER_VERSION: ${{ env.NEXT_SEMVER_VERSION }} + run: npm run add-release-label-to-pr-and-linked-issues diff --git a/development/get-next-semver-version.sh b/development/get-next-semver-version.sh new file mode 100644 index 000000000..810747197 --- /dev/null +++ b/development/get-next-semver-version.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +FORCE_NEXT_SEMVER_VERSION=$1 + +# If FORCE_NEXT_SEMVER_VERSION is defined and not empty, use its value and skip the next operations +if [ -n "$FORCE_NEXT_SEMVER_VERSION" ] +then + echo "NEXT_SEMVER_VERSION=${FORCE_NEXT_SEMVER_VERSION}" >> "$GITHUB_ENV" + exit 0 +fi + +# Get the highest version from release branches +VERSION_BRANCHES=$(git branch -r | grep -o 'release/[0-9]*\.[0-9]*\.[0-9]*' | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | sort --version-sort | tail -n 1) + +# Get the highest version from tags +VERSION_TAGS=$(git tag | grep -o 'v[0-9]*\.[0-9]*\.[0-9]*' | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | sort --version-sort | tail -n 1) + +# Get the version from package.json +VERSION_PACKAGE=$(node -p "require('./package.json').version") + +# Compare versions and keep the highest one +HIGHEST_VERSION=$(printf "%s\n%s\n%s" "$VERSION_BRANCHES" "$VERSION_TAGS" "$VERSION_PACKAGE" | sort --version-sort | tail -n 1) + +# Increment the minor version of the highest version found +NEXT_VERSION=$(echo "$HIGHEST_VERSION" | awk -F. -v OFS=. '{$2++; print}') + +echo "NEXT_SEMVER_VERSION=${NEXT_VERSION}" >> "$GITHUB_ENV" diff --git a/package.json b/package.json index 43e66d32a..62e63668e 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "fitness-functions": "ts-node development/fitness-functions/index.ts", "generate-beta-commit": "node ./development/generate-beta-commit.js", "validate-branch-name": "validate-branch-name", - "label-prs": "ts-node ./.github/scripts/label-prs.ts" + "label-prs": "ts-node ./.github/scripts/label-prs.ts", + "add-release-label-to-pr-and-linked-issues": "ts-node ./.github/scripts/add-release-label-to-pr-and-linked-issues.ts" }, "resolutions": { "@babel/core": "patch:@babel/core@npm%3A7.21.5#./.yarn/patches/@babel-core-npm-7.21.5-c72c337956.patch",