diff --git a/.github/actions/assign-fixed-issues/Dockerfile b/.github/actions/assign-fixed-issues/Dockerfile deleted file mode 100644 index e81b9490ab5fe1..00000000000000 --- a/.github/actions/assign-fixed-issues/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM debian:stable-slim - -LABEL "name"="Assign Fixed Issues" -LABEL "maintainer"="The WordPress Contributors" -LABEL "version"="1.0.0" - -LABEL "com.github.actions.name"="Assign Fixed Issues" -LABEL "com.github.actions.description"="Assigns the issues fixed by a pull request to the author of that pull request" -LABEL "com.github.actions.icon"="flag" -LABEL "com.github.actions.color"="green" - -RUN apt-get update && \ - apt-get install --no-install-recommends -y jq curl ca-certificates && \ - apt-get clean -y - -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/.github/actions/assign-fixed-issues/entrypoint.sh b/.github/actions/assign-fixed-issues/entrypoint.sh deleted file mode 100755 index 83680fcc308779..00000000000000 --- a/.github/actions/assign-fixed-issues/entrypoint.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -set -e - -# 1. Proceed only when acting on an opened pull request. - -action=$(jq -r '.action' $GITHUB_EVENT_PATH) - -if [ "$action" != 'closed' ]; then - echo "Action '$action' not a close action. Aborting." - exit 0; -fi - -# 2. Find the issues that this PR 'fixes'. - -issues=$( - jq -r '.pull_request.body' $GITHUB_EVENT_PATH | perl -nle 'print $1 while / - (?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved) - :? - \ + - (?:\#?|https?:\/\/github\.com\/WordPress\/gutenberg\/issues\/) - (\d+) - /igx' -) - -if [ -z "$issues" ]; then - echo "Pull request does not 'fix' any issues. Aborting." - exit 0 -fi - -# 3. Grab the author of the PR. - -author=$(jq -r '.pull_request.user.login' $GITHUB_EVENT_PATH) - -# 4. Loop through each 'fixed' issue. - -for issue in $issues; do - - # 4a. Add the author as an asignee to the issue. This fails if the author is - # already assigned, which is expected and ignored. - - curl \ - --silent \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"assignees\":[\"$author\"]}" \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$issue/assignees" > /dev/null - - # 3b. Label the issue as 'In Progress'. This fails if the label is already - # applied, which is expected and ignored. - - curl \ - --silent \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"labels":["[Status] In Progress"]}' \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$issue/labels" > /dev/null - -done diff --git a/.github/actions/first-time-contributor/Dockerfile b/.github/actions/first-time-contributor/Dockerfile deleted file mode 100644 index 01e3c560b82219..00000000000000 --- a/.github/actions/first-time-contributor/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM debian:stable-slim - -LABEL "name"="First Time Contributor" -LABEL "maintainer"="The WordPress Contributors" -LABEL "version"="1.0.0" - -LABEL "com.github.actions.name"="First Time Contributor" -LABEL "com.github.actions.description"="Assigns the first time contributor label to pull requests" -LABEL "com.github.actions.icon"="award" -LABEL "com.github.actions.color"="green" - -RUN apt-get update && \ - apt-get install --no-install-recommends -y jq curl ca-certificates && \ - apt-get clean -y - -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/.github/actions/first-time-contributor/entrypoint.sh b/.github/actions/first-time-contributor/entrypoint.sh deleted file mode 100755 index 9360574ebe5097..00000000000000 --- a/.github/actions/first-time-contributor/entrypoint.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -set -e - -# 1. Proceed only when acting on an opened pull request. -action=$(jq -r '.action' $GITHUB_EVENT_PATH) - -if [ "$action" != 'opened' ]; then - echo "Action '$action' not a close action. Aborting." - exit 0; -fi - -# 2. Get the author and pr number for the pull request. -author=$(jq -r '.pull_request.user.login' $GITHUB_EVENT_PATH) -pr_number=$(jq -r '.number' $GITHUB_EVENT_PATH) - -if [ "$pr_number" = "null" ] || [ "$author" = "null" ]; then - echo "Could not find PR number or author. $pr_number / $author" - exit 0 -fi - -# 3. Fetch the author's commit count for the repo to determine if they're a first-time contributor. -commit_count=$( - curl \ - --silent \ - -H "Accept: application/vnd.github.cloak-preview" \ - "https://api.github.com/search/commits?q=repo:$GITHUB_REPOSITORY+author:$author" \ - | jq -r '.total_count' -) - -# 4. If the response has a commit count of zero, exit early, the author is not a first time contributor. -if [ "$commit_count" != "0" ]; then - echo "Pull request #$pr_number was not created by a first-time contributor ($author)." - exit 0 -fi - -# 5. Assign the 'First Time Contributor' label. -curl \ - --silent \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"labels":["First-time Contributor"]}' \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" > /dev/null diff --git a/.github/actions/milestone-it/Dockerfile b/.github/actions/milestone-it/Dockerfile deleted file mode 100644 index af20456bcc34e5..00000000000000 --- a/.github/actions/milestone-it/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM debian:stable-slim - -LABEL "name"="Milestone It" -LABEL "maintainer"="The WordPress Contributors" -LABEL "version"="1.0.0" - -LABEL "com.github.actions.name"="Milestone It" -LABEL "com.github.actions.description"="Assigns a pull request to the next milestone" -LABEL "com.github.actions.icon"="flag" -LABEL "com.github.actions.color"="green" - -RUN apt-get update && \ - apt-get install --no-install-recommends -y jq curl ca-certificates && \ - apt-get clean -y - -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/.github/actions/milestone-it/entrypoint.sh b/.github/actions/milestone-it/entrypoint.sh deleted file mode 100755 index 8c91df0cb05ff6..00000000000000 --- a/.github/actions/milestone-it/entrypoint.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash -set -e - -# 1. Proceed only when acting on a merge action on the master branch. - -action=$(jq -r '.action' $GITHUB_EVENT_PATH) - -if [ "$action" != 'closed' ]; then - echo "Action '$action' not a close action. Aborting." - exit 0; -fi - -merged=$(jq -r '.pull_request.merged' $GITHUB_EVENT_PATH) - -if [ "$merged" != 'true' ]; then - echo "Pull request closed without merge. Aborting." - exit 0; -fi - -base=$(jq -r '.pull_request.base.ref' $GITHUB_EVENT_PATH) - -if [ "$base" != 'master' ]; then - echo 'Milestones apply only to master merge. Aborting.' - exit 0; -fi - -# 2. Determine if milestone already exists (don't replace one which has already -# been assigned). - -pr=$(jq -r '.number' $GITHUB_EVENT_PATH) - -current_milestone=$( - curl \ - --silent \ - -H "Authorization: token $GITHUB_TOKEN" \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$pr" \ - | jq '.milestone' -) - -if [ "$current_milestone" != 'null' ]; then - echo 'Milestone already applied. Aborting.' - exit 0; -fi - -# 3. Read current version. - -version=$( - curl \ - --silent \ - "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/master/package.json" \ - | jq -r '.version' -) - -IFS='.' read -ra parts <<< "$version" -major=${parts[0]} -minor=${parts[1]} - -# 4. Determine next milestone. - -if [[ $minor == 9* ]]; then - major=$((major+1)) - minor="0" -else - minor=$((minor+1)) -fi - -milestone="Gutenberg $major.$minor" - -# 5. Calculate next milestone due date, using a static reference of an earlier -# release (v5.0) as a reference point for the biweekly release schedule. - -reference_major=5 -reference_minor=0 -reference_date=1564358400 -num_versions_elapsed=$(((major-reference_major)*10+(minor-reference_minor))) -weeks=$((num_versions_elapsed*2)) -due=$(date -u --iso-8601=seconds -d "$(date -d @$(echo $reference_date)) + $(echo $weeks) weeks") - -# 6. Create milestone. This may fail for duplicates, which is expected and -# ignored. - -curl \ - --silent \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"title\":\"$milestone\",\"due_on\":\"$due\",\"description\":\"Tasks to be included in the $milestone plugin release.\"}" \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/milestones" > /dev/null - -# 7. Find milestone number. This could be improved to allow for non-open status -# or paginated results. - -number=$( - curl \ - --silent \ - -H "Authorization: token $GITHUB_TOKEN" \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/milestones" \ - | jq ".[] | select(.title == \"$milestone\") | .number" -) - -# 8. Assign pull request to milestone. - -curl \ - --silent \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"milestone\":$number}" \ - "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$pr" > /dev/null diff --git a/.github/workflows/pull-request-automation.yml b/.github/workflows/pull-request-automation.yml new file mode 100644 index 00000000000000..10277d5a3b45e3 --- /dev/null +++ b/.github/workflows/pull-request-automation.yml @@ -0,0 +1,14 @@ +on: pull_request +name: Pull request automation + +jobs: + pull-request-automation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + # Changing into the action's directory and running `npm install` is much + # faster than a full project-wide `npm ci`. + - run: cd packages/project-management-automation && npm install + - uses: ./packages/project-management-automation + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request-add-the-first-time-contributor-label-to-prs-opened-by-first-time-contributors.yml b/.github/workflows/pull_request-add-the-first-time-contributor-label-to-prs-opened-by-first-time-contributors.yml deleted file mode 100644 index 5c5bf75d0716e6..00000000000000 --- a/.github/workflows/pull_request-add-the-first-time-contributor-label-to-prs-opened-by-first-time-contributors.yml +++ /dev/null @@ -1,12 +0,0 @@ -on: pull_request -name: Add the First-time Contributor label -jobs: - filterOpened: - name: Filter opened - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: First Time Contributor - uses: ./.github/actions/first-time-contributor - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request-assign-fixed-issues-when-pull-request-opened.yml b/.github/workflows/pull_request-assign-fixed-issues-when-pull-request-opened.yml deleted file mode 100644 index bc39c694424252..00000000000000 --- a/.github/workflows/pull_request-assign-fixed-issues-when-pull-request-opened.yml +++ /dev/null @@ -1,12 +0,0 @@ -on: pull_request -name: Assign fixed issues when pull request opened -jobs: - filterOpened: - name: Filter opened - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Assign Fixed Issues - uses: ./.github/actions/assign-fixed-issues - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request-milestone-merged-pull-requests.yml b/.github/workflows/pull_request-milestone-merged-pull-requests.yml deleted file mode 100644 index 6487a9ed13b7de..00000000000000 --- a/.github/workflows/pull_request-milestone-merged-pull-requests.yml +++ /dev/null @@ -1,12 +0,0 @@ -on: pull_request -name: Milestone merged pull requests -jobs: - milestoneIt: - name: Milestone It - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Milestone It - uses: ./.github/actions/milestone-it - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json index 8c96482e340a30..277a832fd419f4 100644 --- a/docs/manifest-devhub.json +++ b/docs/manifest-devhub.json @@ -1349,6 +1349,12 @@ "markdown_source": "../packages/priority-queue/README.md", "parent": "packages" }, + { + "title": "@wordpress/project-management-automation", + "slug": "packages-project-management-automation", + "markdown_source": "../packages/project-management-automation/README.md", + "parent": "packages" + }, { "title": "@wordpress/redux-routine", "slug": "packages-redux-routine", diff --git a/package-lock.json b/package-lock.json index 2febb418532ae1..019dd2a38b2379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,22 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@actions/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.0.0.tgz", + "integrity": "sha512-aMIlkx96XH4E/2YZtEOeyrYQfhlas9jIRkfGPqMwXD095Rdkzo4lB6ZmbxPQSzD+e1M+Xsm98ZhuSMYGv/AlqA==", + "dev": true + }, + "@actions/github": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-1.0.0.tgz", + "integrity": "sha512-PPbWZ5wFAD/Vr+RCECfR3KNHjTwYln4liJBihs9tQUL0/PCFqB2lSkIh9V94AcZFHxgKk8snImjuLaBE8bKR7A==", + "dev": true, + "requires": { + "@octokit/graphql": "^2.0.1", + "@octokit/rest": "^16.15.0" + } + }, "@babel/code-frame": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", @@ -3888,6 +3904,65 @@ } } }, + "@octokit/graphql": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-2.1.3.tgz", + "integrity": "sha512-XoXJqL2ondwdnMIW3wtqJWEwcBfKk37jO/rYkoxNPEVeLBDGsGO1TCWggrAlq3keGt/O+C/7VepXnukUxwt5vA==", + "dev": true, + "requires": { + "@octokit/request": "^5.0.0", + "universal-user-agent": "^2.0.3" + }, + "dependencies": { + "@octokit/request": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.0.2.tgz", + "integrity": "sha512-z1BQr43g4kOL4ZrIVBMHwi68Yg9VbkRUyuAgqCp1rU3vbYa69+2gIld/+gHclw15bJWQnhqqyEb7h5a5EqgZ0A==", + "dev": true, + "requires": { + "@octokit/endpoint": "^5.1.0", + "@octokit/request-error": "^1.0.1", + "deprecation": "^2.0.0", + "is-plain-object": "^3.0.0", + "node-fetch": "^2.3.0", + "once": "^1.4.0", + "universal-user-agent": "^3.0.0" + }, + "dependencies": { + "universal-user-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-3.0.0.tgz", + "integrity": "sha512-T3siHThqoj5X0benA5H0qcDnrKGXzU8TKoX15x/tQHw1hQBvIEBHjxQ2klizYsqBOO/Q+WuxoQUihadeeqDnoA==", + "dev": true, + "requires": { + "os-name": "^3.0.0" + } + } + } + }, + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + } + } + }, "@octokit/plugin-enterprise-rest": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-3.6.2.tgz", diff --git a/package.json b/package.json index aa4805e6597d89..a29163b7492d2a 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "@wordpress/wordcount": "file:packages/wordcount" }, "devDependencies": { + "@actions/core": "1.0.0", + "@actions/github": "1.0.0", "@babel/core": "7.4.4", "@babel/plugin-syntax-jsx": "7.2.0", "@babel/runtime-corejs3": "7.4.4", diff --git a/packages/project-management-automation/.npmrc b/packages/project-management-automation/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/project-management-automation/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/project-management-automation/README.md b/packages/project-management-automation/README.md new file mode 100644 index 00000000000000..40c85f70a67233 --- /dev/null +++ b/packages/project-management-automation/README.md @@ -0,0 +1,35 @@ +# Gutenberg project management automation + +This is a [GitHub Action](https://help.github.com/en/categories/automating-your-workflow-with-github-actions) which contains various automation to assist with managing the Gutenberg GitHub repository: + +- `add-first-time-contributor-label`: Adds the 'First Time Contributor' label to PRs opened by contributors that have not yet made a commit. +- `add-milestone`: Assigns the correct milestone to PRs once merged. +- `assign-fixed-issues`: Assigns any issues 'fixed' by a newly opened PR to the author of that PR. + +# Installation and usage + +To use the action, include it in your workflow configuration file: + +```yaml +on: pull_request +jobs: + pull-request-automation: + runs-on: ubuntu-latest + steps: + - uses: WordPress/gutenberg/packages/project-management-automation@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + +``` + +# API + +## Inputs + +- `github_token`: Required. GitHub API token to use for making API requests. This should be stored as a secret in the GitHub repository. + +## Outputs + +_None._ + +

Code is Poetry.

diff --git a/packages/project-management-automation/action.yml b/packages/project-management-automation/action.yml new file mode 100644 index 00000000000000..c5cd00678edd4d --- /dev/null +++ b/packages/project-management-automation/action.yml @@ -0,0 +1,10 @@ +name: Gutenberg project management automation +description: > + Various automation to assist with managing the Gutenberg GitHub repository. +inputs: + github_token: + description: Secret GitHub API token to use for making API requests. + required: true +runs: + using: node12 + main: lib/index.js diff --git a/packages/project-management-automation/lib/add-first-time-contributor-label.js b/packages/project-management-automation/lib/add-first-time-contributor-label.js new file mode 100644 index 00000000000000..e185acbd46c4b6 --- /dev/null +++ b/packages/project-management-automation/lib/add-first-time-contributor-label.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +const debug = require( './debug' ); + +/** + * Adds the 'First Time Contributor' label to PRs opened by contributors that + * have not yet made a commit. + * + * @param {Object} payload Pull request event payload, see https://developer.github.com/v3/activity/events/types/#pullrequestevent. + * @param {Object} octokit Initialized Octokit REST client, see https://octokit.github.io/rest.js/. + */ +async function addFirstTimeContributorLabel( payload, octokit ) { + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const author = payload.pull_request.user.login; + + debug( `add-first-time-contributor-label: Searching for commits in ${ owner }/${ repo } by @${ author }` ); + + const { total_count: totalCount } = await octokit.search.commits( { + q: `repo:${ owner }/${ repo }+author:${ author }`, + } ); + + if ( totalCount !== 0 ) { + debug( 'add-first-time-contributor-label: Commits found. Aborting' ); + return; + } + + debug( `add-first-time-contributor-label: Adding 'First Time Contributor' label to issue #${ payload.pull_request.number }` ); + + await octokit.issues.addLabels( { + owner, + repo, + issue_number: payload.pull_request.number, + labels: [ 'First-time Contributor' ], + } ); +} + +module.exports = addFirstTimeContributorLabel; diff --git a/packages/project-management-automation/lib/add-milestone.js b/packages/project-management-automation/lib/add-milestone.js new file mode 100644 index 00000000000000..8fcc9c08d4e019 --- /dev/null +++ b/packages/project-management-automation/lib/add-milestone.js @@ -0,0 +1,103 @@ +/** + * Internal dependencies + */ +const debug = require( './debug' ); + +// Milestone due dates are calculated from a known due date: +// 6.3, which was due on August 12 2019. +const REFERENCE_MAJOR = 6; +const REFERENCE_MINOR = 3; +const REFERENCE_DATE = '2019-08-12'; + +// Releases are every 14 days. +const DAYS_PER_RELEASE = 14; + +/** + * Assigns the correct milestone to PRs once merged. + * + * @param {Object} payload Pull request event payload, see https://developer.github.com/v3/activity/events/types/#pullrequestevent. + * @param {Object} octokit Initialized Octokit REST client, see https://octokit.github.io/rest.js/. + */ +async function addMilestone( payload, octokit ) { + if ( ! payload.pull_request.merged ) { + debug( 'add-milestone: Pull request is not merged. Aborting' ); + return; + } + + if ( payload.pull_request.base.ref !== 'master' ) { + debug( 'add-milestone: Pull request is not based on `master`. Aborting' ); + return; + } + + debug( 'add-milestone: Fetching current milestone' ); + + const { milestone } = await octokit.issues.get( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.pull_request.number, + } ); + + if ( milestone ) { + debug( 'add-milestone: Pull request already has a milestone. Aborting' ); + return; + } + + debug( 'add-milestone: Fetching `package.json` contents' ); + + const { content } = await octokit.repos.getContents( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + path: 'package.json', + } ); + + const { version } = JSON.parse( content ); + + let [ major, minor ] = version.split( '.' ).map( Number ); + + debug( `add-milestone: Current plugin version is ${ major }.${ minor }` ); + + if ( minor === 9 ) { + major += 1; + minor = 0; + } else { + minor += 1; + } + + const numVersionsElapsed = ( ( major - REFERENCE_MAJOR ) * 10 ) + ( minor - REFERENCE_MINOR ); + const numDaysElapsed = numVersionsElapsed * DAYS_PER_RELEASE; + + // Using UTC for the calculation ensures it's not affected by daylight savings. + const dueDate = new Date( REFERENCE_DATE ); + dueDate.setUTCDate( dueDate.getUTCDate() + numDaysElapsed ); + + debug( `add-milestone: Creating 'Gutenberg ${ major }.${ minor }' milestone, due on ${ dueDate.toISOString() }` ); + + await octokit.issues.createMilestone( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + title: `Gutenberg ${ major }.${ minor }`, + due_on: dueDate.toISOString(), + } ); + + debug( 'add-milestone: Fetching all milestones' ); + + const milestones = await octokit.issues.listMilestonesForRepo( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + } ); + + const [ { number } ] = milestones.filter( + ( { title } ) => title === `Gutenberg ${ major }.${ minor }` + ); + + debug( `add-milestone: Adding issue #${ payload.pull_request.number } to milestone #${ number }` ); + + await octokit.issues.update( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.pull_request.number, + milestone: number, + } ); +} + +module.exports = addMilestone; diff --git a/packages/project-management-automation/lib/assign-fixed-issues.js b/packages/project-management-automation/lib/assign-fixed-issues.js new file mode 100644 index 00000000000000..c0780256a41022 --- /dev/null +++ b/packages/project-management-automation/lib/assign-fixed-issues.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +const debug = require( './debug' ); + +/** + * Assigns any issues 'fixed' by a newly opened PR to the author of that PR. + * + * @param {Object} payload Pull request event payload, see https://developer.github.com/v3/activity/events/types/#pullrequestevent. + * @param {Object} octokit Initialized Octokit REST client, see https://octokit.github.io/rest.js/. + */ +async function assignFixedIssues( payload, octokit ) { + const regex = /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):? +(?:\#?|https?:\/\/github\.com\/WordPress\/gutenberg\/issues\/)(\d+)/gi; + + let match; + while ( ( match = regex.exec( payload.pull_request.body ) ) ) { + const [ , issue ] = match; + + debug( `assign-fixed-issues: Assigning issue #${ issue } to @${ payload.pull_request.user.login }` ); + + await octokit.issues.addAssignees( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: +issue, + assignees: [ payload.pull_request.user.login ], + } ); + + debug( `assign-fixed-issues: Applying '[Status] In Progress' label to issue #${ issue }` ); + + await octokit.issues.addLabels( { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: +issue, + labels: [ '[Status] In Progress' ], + } ); + } +} + +module.exports = assignFixedIssues; diff --git a/packages/project-management-automation/lib/debug.js b/packages/project-management-automation/lib/debug.js new file mode 100644 index 00000000000000..63bc88bca3c046 --- /dev/null +++ b/packages/project-management-automation/lib/debug.js @@ -0,0 +1,12 @@ +/** + * Prints a debug message to STDOUT in non-testing environments. + * + * @param {string} message The message to print. + */ +function debug( message ) { + if ( process.env.NODE_ENV !== 'test' ) { + process.stdout.write( message + '\n' ); + } +} + +module.exports = debug; diff --git a/packages/project-management-automation/lib/index.js b/packages/project-management-automation/lib/index.js new file mode 100644 index 00000000000000..b752af33433c76 --- /dev/null +++ b/packages/project-management-automation/lib/index.js @@ -0,0 +1,56 @@ +/** + * GitHub dependencies + */ +const { setFailed, getInput } = require( '@actions/core' ); +const { context, GitHub } = require( '@actions/github' ); + +/** + * Internal dependencies + */ +const assignFixedIssues = require( './assign-fixed-issues' ); +const addFirstTimeContributorLabel = require( './add-first-time-contributor-label' ); +const addMilestone = require( './add-milestone' ); +const debug = require( './debug' ); + +const automations = [ + { + event: 'pull_request', + action: 'opened', + task: assignFixedIssues, + }, + { + event: 'pull_request', + action: 'opened', + task: addFirstTimeContributorLabel, + }, + { + event: 'pull_request', + action: 'closed', + task: addMilestone, + }, +]; + +( async function main() { + const token = getInput( 'github_token' ); + if ( ! token ) { + setFailed( 'main: Input `github_token` is required' ); + return; + } + + const octokit = new GitHub( token ); + + debug( `main: Received event = '${ context.eventName }', action = '${ context.payload.action }'` ); + + for ( const { event, action, task } of automations ) { + if ( event === context.eventName && action === context.payload.action ) { + try { + debug( `main: Starting task ${ task.name }` ); + await task( context.payload, octokit ); + } catch ( error ) { + debug( `main: Task ${ task.name } failed with error: ${ error }` ); + } + } + } + + debug( 'main: All done!' ); +}() ); diff --git a/packages/project-management-automation/lib/test/add-first-time-contributor-label.js b/packages/project-management-automation/lib/test/add-first-time-contributor-label.js new file mode 100644 index 00000000000000..91eeb56dca339d --- /dev/null +++ b/packages/project-management-automation/lib/test/add-first-time-contributor-label.js @@ -0,0 +1,62 @@ +/** + * Internal dependencies + */ +import addFirstTimeContributorLabel from '../add-first-time-contributor-label'; + +describe( 'addFirstTimeContributorLabel', () => { + const payload = { + pull_request: { + user: { + login: 'matt', + }, + number: 123, + }, + repository: { + owner: { + login: 'WordPress', + }, + name: 'gutenberg', + }, + }; + + it( 'does nothing if the user has commits', async () => { + const octokit = { + search: { + commits: jest.fn( () => Promise.resolve( { total_count: 100 } ) ), + }, + issues: { + addLabels: jest.fn(), + }, + }; + + await addFirstTimeContributorLabel( payload, octokit ); + + expect( octokit.search.commits ).toHaveBeenCalledWith( { + q: 'repo:WordPress/gutenberg+author:matt', + } ); + expect( octokit.issues.addLabels ).not.toHaveBeenCalled(); + } ); + + it( 'adds the label if the user does not have commits', async () => { + const octokit = { + search: { + commits: jest.fn( () => Promise.resolve( { total_count: 0 } ) ), + }, + issues: { + addLabels: jest.fn(), + }, + }; + + await addFirstTimeContributorLabel( payload, octokit ); + + expect( octokit.search.commits ).toHaveBeenCalledWith( { + q: 'repo:WordPress/gutenberg+author:matt', + } ); + expect( octokit.issues.addLabels ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + labels: [ 'First-time Contributor' ], + } ); + } ); +} ); diff --git a/packages/project-management-automation/lib/test/add-milestone.js b/packages/project-management-automation/lib/test/add-milestone.js new file mode 100644 index 00000000000000..edb5478d107a18 --- /dev/null +++ b/packages/project-management-automation/lib/test/add-milestone.js @@ -0,0 +1,242 @@ +/** + * Internal dependencies + */ +import addMilestone from '../add-milestone'; + +describe( 'addFirstTimeContributorLabel', () => { + it( 'does nothing if PR is not merged', async () => { + const payload = { + pull_request: { + merged: false, + }, + }; + const octokit = { + issues: { + get: jest.fn(), + createMilestone: jest.fn(), + listMilestonesForRepo: jest.fn(), + update: jest.fn(), + }, + repos: { + getContents: jest.fn(), + }, + }; + + await addMilestone( payload, octokit ); + + expect( octokit.issues.get ).not.toHaveBeenCalled(); + expect( octokit.issues.createMilestone ).not.toHaveBeenCalled(); + expect( octokit.issues.listMilestonesForRepo ).not.toHaveBeenCalled(); + expect( octokit.issues.update ).not.toHaveBeenCalled(); + expect( octokit.repos.getContents ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if base is not master', async () => { + const payload = { + pull_request: { + merged: true, + base: { + ref: 'release/5.0', + }, + }, + }; + const octokit = { + issues: { + get: jest.fn(), + createMilestone: jest.fn(), + listMilestonesForRepo: jest.fn(), + update: jest.fn(), + }, + repos: { + getContents: jest.fn(), + }, + }; + + await addMilestone( payload, octokit ); + + expect( octokit.issues.get ).not.toHaveBeenCalled(); + expect( octokit.issues.createMilestone ).not.toHaveBeenCalled(); + expect( octokit.issues.listMilestonesForRepo ).not.toHaveBeenCalled(); + expect( octokit.issues.update ).not.toHaveBeenCalled(); + expect( octokit.repos.getContents ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if PR already has a milestone', async () => { + const payload = { + pull_request: { + merged: true, + base: { + ref: 'master', + }, + number: 123, + }, + repository: { + owner: { + login: 'WordPress', + }, + name: 'gutenberg', + }, + }; + const octokit = { + issues: { + get: jest.fn( () => Promise.resolve( { + milestone: 'Gutenberg 6.4', + } ) ), + createMilestone: jest.fn(), + listMilestonesForRepo: jest.fn(), + update: jest.fn(), + }, + repos: { + getContents: jest.fn(), + }, + }; + + await addMilestone( payload, octokit ); + + expect( octokit.issues.get ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + } ); + expect( octokit.issues.createMilestone ).not.toHaveBeenCalled(); + expect( octokit.issues.listMilestonesForRepo ).not.toHaveBeenCalled(); + expect( octokit.issues.update ).not.toHaveBeenCalled(); + expect( octokit.repos.getContents ).not.toHaveBeenCalled(); + } ); + + it( 'correctly milestones PRs when `package.json` has a `*.[1-8]` version', async () => { + const payload = { + pull_request: { + merged: true, + base: { + ref: 'master', + }, + number: 123, + }, + repository: { + owner: { + login: 'WordPress', + }, + name: 'gutenberg', + }, + }; + const octokit = { + issues: { + get: jest.fn( () => Promise.resolve( { + milestone: null, + } ) ), + createMilestone: jest.fn(), + listMilestonesForRepo: jest.fn( () => Promise.resolve( [ + { title: 'Gutenberg 6.2', number: 10 }, + { title: 'Gutenberg 6.3', number: 11 }, + { title: 'Gutenberg 6.4', number: 12 }, + ] ) ), + update: jest.fn(), + }, + repos: { + getContents: jest.fn( () => Promise.resolve( { + content: JSON.stringify( { + version: '6.3.0', + } ), + } ) ), + }, + }; + + await addMilestone( payload, octokit ); + + expect( octokit.issues.get ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + } ); + expect( octokit.repos.getContents ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + path: 'package.json', + } ); + expect( octokit.issues.createMilestone ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + title: 'Gutenberg 6.4', + due_on: '2019-08-26T00:00:00.000Z', + } ); + expect( octokit.issues.listMilestonesForRepo ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + } ); + expect( octokit.issues.update ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + milestone: 12, + } ); + } ); + + it( 'correctly milestones PRs when `package.json` has a `*.9` version', async () => { + const payload = { + pull_request: { + merged: true, + base: { + ref: 'master', + }, + number: 123, + }, + repository: { + owner: { + login: 'WordPress', + }, + name: 'gutenberg', + }, + }; + const octokit = { + issues: { + get: jest.fn( () => Promise.resolve( { + milestone: null, + } ) ), + createMilestone: jest.fn(), + listMilestonesForRepo: jest.fn( () => Promise.resolve( [ + { title: 'Gutenberg 6.8', number: 10 }, + { title: 'Gutenberg 6.9', number: 11 }, + { title: 'Gutenberg 7.0', number: 12 }, + ] ) ), + update: jest.fn(), + }, + repos: { + getContents: jest.fn( () => Promise.resolve( { + content: JSON.stringify( { + version: '6.9.0', + } ), + } ) ), + }, + }; + + await addMilestone( payload, octokit ); + + expect( octokit.issues.get ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + } ); + expect( octokit.repos.getContents ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + path: 'package.json', + } ); + expect( octokit.issues.createMilestone ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + title: 'Gutenberg 7.0', + due_on: '2019-11-18T00:00:00.000Z', + } ); + expect( octokit.issues.listMilestonesForRepo ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + } ); + expect( octokit.issues.update ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + milestone: 12, + } ); + } ); +} ); diff --git a/packages/project-management-automation/lib/test/assign-fixed-issues.js b/packages/project-management-automation/lib/test/assign-fixed-issues.js new file mode 100644 index 00000000000000..3af702875e464b --- /dev/null +++ b/packages/project-management-automation/lib/test/assign-fixed-issues.js @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import assignFixedIssues from '../assign-fixed-issues'; + +describe( 'assignFixedIssues', () => { + it( 'does nothing if there are no fixed issues', async () => { + const payload = { + pull_request: { + body: 'This pull request seeks to make Gutenberg better than ever.', + }, + }; + const octokit = { + issues: { + addAssignees: jest.fn(), + addLabels: jest.fn(), + }, + }; + + await assignFixedIssues( payload, octokit ); + + expect( octokit.issues.addAssignees ).not.toHaveBeenCalled(); + expect( octokit.issues.addLabels ).not.toHaveBeenCalled(); + } ); + + it( 'assigns and labels fixed issues', async () => { + const payload = { + pull_request: { + body: 'This pull request seeks to close #123 and also fixes https://github.com/WordPress/gutenberg/issues/456.', + user: { + login: 'matt', + }, + }, + repository: { + owner: { + login: 'WordPress', + }, + name: 'gutenberg', + }, + }; + const octokit = { + issues: { + addAssignees: jest.fn( () => Promise.resolve( {} ) ), + addLabels: jest.fn( () => Promise.resolve( {} ) ), + }, + }; + + await assignFixedIssues( payload, octokit ); + + expect( octokit.issues.addAssignees ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + assignees: [ 'matt' ], + } ); + expect( octokit.issues.addLabels ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 123, + labels: [ '[Status] In Progress' ], + } ); + expect( octokit.issues.addAssignees ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 456, + assignees: [ 'matt' ], + } ); + expect( octokit.issues.addLabels ).toHaveBeenCalledWith( { + owner: 'WordPress', + repo: 'gutenberg', + issue_number: 456, + labels: [ '[Status] In Progress' ], + } ); + } ); +} ); diff --git a/packages/project-management-automation/package.json b/packages/project-management-automation/package.json new file mode 100644 index 00000000000000..3fa89f1f691237 --- /dev/null +++ b/packages/project-management-automation/package.json @@ -0,0 +1,28 @@ +{ + "name": "@wordpress/project-management-automation", + "version": "1.0.0-beta.0", + "description": "GitHub Action that implements various automation to assist with managing the Gutenberg GitHub repository.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/project-management-automation/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/project-management-automation" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "lib/index.js", + "dependencies": { + "@actions/core": "^1.0.0", + "@actions/github": "^1.0.0", + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +}