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._
+
+
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"
+ }
+}