Repository: google/go-github
Pull request #1302 (2019-10-07 13:59:03)

Add support for UserEvent webhooks
Closes https://github.com/google/go-github/issues/1301.

codecov[bot]: # [Codecov](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=h1) Report
> Merging [#1302](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=desc) into [master](https://codecov.io/gh/google/go-github/commit/740806ae9dd43b6f68f872c299eedee17bcfa717?src=pr&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `50%`.

[![Impacted file tree graph](https://codecov.io/gh/google/go-github/pull/1302/graphs/tree.svg?width=650&token=EgYjGjZlI0&height=150&src=pr)](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master   #1302      +/-   ##
=========================================
- Coverage   73.42%   73.4%   -0.02%     
=========================================
  Files          86      86              
  Lines        6047    6051       +4     
=========================================
+ Hits         4440    4442       +2     
- Misses        837     839       +2     
  Partials      770     770
```


| [Impacted Files](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [github/event\_types.go](https://codecov.io/gh/google/go-github/pull/1302/diff?src=pr&el=tree#diff-Z2l0aHViL2V2ZW50X3R5cGVzLmdv) | `100% <ø> (ø)` | :arrow_up: |
| [github/messages.go](https://codecov.io/gh/google/go-github/pull/1302/diff?src=pr&el=tree#diff-Z2l0aHViL21lc3NhZ2VzLmdv) | `82.85% <ø> (ø)` | :arrow_up: |
| [github/event.go](https://codecov.io/gh/google/go-github/pull/1302/diff?src=pr&el=tree#diff-Z2l0aHViL2V2ZW50Lmdv) | `89.58% <0%> (-1.91%)` | :arrow_down: |
| [github/admin.go](https://codecov.io/gh/google/go-github/pull/1302/diff?src=pr&el=tree#diff-Z2l0aHViL2FkbWluLmdv) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=footer). Last update [740806a...f12b0c2](https://codecov.io/gh/google/go-github/pull/1302?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

JordanSussman: Comments have been updated to reflect feedback. Thanks for the quick feedback, @gmlewis.

Patch:
diff --git a/.codecov.yml b/.codecov.yml
index 746db536..0f63adb3 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -1,3 +1,5 @@
 ignore:
   # ignore auto-generated code
   - "github/github-accessors.go"
+  # ignore experimental scrape package
+  - "scrape"
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index b1eff617..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-language: go
-
-go:
-  - "1.x"
-  - "1.12.x"
-  - master
-
-matrix:
-  allow_failures:
-    - go: master
-  fast_finish: true
-
-cache:
-  directories:
-    - $HOME/.cache/go-build
-    - $HOME/gopath/pkg/mod
-
-env:
-  global:
-    - GO111MODULE=on
-
-script:
-  - diff -u <(echo -n) <(gofmt -d -s .)
-  - go generate -x ./... && git diff --exit-code; code=$?; git checkout -- .; (exit $code) # Check that go generate ./... produces a zero diff; clean up any changes afterwards.
-  - go vet ./...
-  - go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
-  - go test -v -tags=integration -run=^$ ./test/integration # Check that integration test builds successfully, but don't run any of the tests (they hit live GitHub API).
-
-after_success:
-  - bash <(curl -s https://codecov.io/bash)
diff --git a/README.md b/README.md
index 66503b24..c6f57924 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,10 @@
 # go-github #
 
-[![GoDoc](https://godoc.org/github.com/google/go-github/github?status.svg)](https://godoc.org/github.com/google/go-github/github) [![Build Status](https://travis-ci.org/google/go-github.svg?branch=master)](https://travis-ci.org/google/go-github) [![Test Coverage](https://codecov.io/gh/google/go-github/branch/master/graph/badge.svg)](https://codecov.io/gh/google/go-github) [![Discuss at go-github@googlegroups.com](https://img.shields.io/badge/discuss-go--github%40googlegroups.com-blue.svg)](https://groups.google.com/group/go-github) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/796/badge)](https://bestpractices.coreinfrastructure.org/projects/796)
+[![GoDoc](https://godoc.org/github.com/google/go-github/github?status.svg)](https://godoc.org/github.com/google/go-github/github)
+[![Test Status](https://github.com/google/go-github/workflows/tests/badge.svg)](https://github.com/google/go-github/actions?query=workflow%3Atests)
+[![Test Coverage](https://codecov.io/gh/google/go-github/branch/master/graph/badge.svg)](https://codecov.io/gh/google/go-github)
+[![Discuss at go-github@googlegroups.com](https://img.shields.io/badge/discuss-go--github%40googlegroups.com-blue.svg)](https://groups.google.com/group/go-github)
+[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/796/badge)](https://bestpractices.coreinfrastructure.org/projects/796)
 
 go-github is a Go client library for accessing the [GitHub API v3][].
 
diff --git a/example/simple/main.go b/example/simple/main.go
index b10be06b..8bab9603 100644
--- a/example/simple/main.go
+++ b/example/simple/main.go
@@ -17,7 +17,7 @@ import (
 
 // Fetch all the public organizations' membership of a user.
 //
-func FetchOrganizations(username string) ([]*github.Organization, error) {
+func fetchOrganizations(username string) ([]*github.Organization, error) {
 	client := github.NewClient(nil)
 	orgs, _, err := client.Organizations.List(context.Background(), username, nil)
 	return orgs, err
@@ -28,7 +28,7 @@ func main() {
 	fmt.Print("Enter GitHub username: ")
 	fmt.Scanf("%s", &username)
 
-	organizations, err := FetchOrganizations(username)
+	organizations, err := fetchOrganizations(username)
 	if err != nil {
 		fmt.Printf("Error: %v\n", err)
 		return
diff --git a/example/topics/main.go b/example/topics/main.go
new file mode 100644
index 00000000..96098efe
--- /dev/null
+++ b/example/topics/main.go
@@ -0,0 +1,40 @@
+// Copyright 2019 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// The simple command demonstrates a simple functionality which
+// prompts the user for a GitHub topic and lists all the public
+// organization memberships of the specified topic.
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/google/go-github/v28/github"
+)
+
+// Fetch all the public organizations' membership of a user.
+//
+func FetchTopics(topic string) (*github.TopicsSearchResult, error) {
+	client := github.NewClient(nil)
+	topics, _, err := client.Search.Topics(context.Background(), topic, nil)
+	return topics, err
+}
+
+func main() {
+	var topic string
+	fmt.Print("Enter GitHub topic: ")
+	fmt.Scanf("%s", &topic)
+
+	topics, err := FetchTopics(topic)
+	if err != nil {
+		fmt.Printf("Error: %v\n", err)
+		return
+	}
+
+	for _, topic := range topics.Topics {
+		fmt.Println(*topic.Name)
+	}
+}
diff --git a/github/admin.go b/github/admin.go
index 2d96733a..31cd1fa2 100644
--- a/github/admin.go
+++ b/github/admin.go
@@ -62,6 +62,24 @@ func (m UserLDAPMapping) String() string {
 	return Stringify(m)
 }
 
+// Enterprise represents the GitHub enterprise profile.
+type Enterprise struct {
+	ID          *int       `json:"id,omitempty"`
+	Slug        *string    `json:"slug,omitempty"`
+	Name        *string    `json:"name,omitempty"`
+	NodeID      *string    `json:"node_id,omitempty"`
+	AvatarURL   *string    `json:"avatar_url,omitempty"`
+	Description *string    `json:"description,omitempty"`
+	WebsiteURL  *string    `json:"website_url,omitempty"`
+	HTMLURL     *string    `json:"html_url,omitempty"`
+	CreatedAt   *Timestamp `json:"created_at,omitempty"`
+	UpdatedAt   *Timestamp `json:"updated_at,omitempty"`
+}
+
+func (m Enterprise) String() string {
+	return Stringify(m)
+}
+
 // UpdateUserLDAPMapping updates the mapping between a GitHub user and an LDAP user.
 //
 // GitHub API docs: https://developer.github.com/v3/enterprise/ldap/#update-ldap-mapping-for-a-user
diff --git a/github/apps.go b/github/apps.go
index 5041e801..913464f6 100644
--- a/github/apps.go
+++ b/github/apps.go
@@ -145,7 +145,7 @@ func (s *AppsService) Get(ctx context.Context, appSlug string) (*App, *Response,
 
 // ListInstallations lists the installations that the current GitHub App has.
 //
-// GitHub API docs: https://developer.github.com/v3/apps/#find-installations
+// GitHub API docs: https://developer.github.com/v3/apps/#list-installations
 func (s *AppsService) ListInstallations(ctx context.Context, opt *ListOptions) ([]*Installation, *Response, error) {
 	u, err := addOptions("app/installations", opt)
 	if err != nil {
@@ -227,7 +227,7 @@ func (s *AppsService) CreateInstallationToken(ctx context.Context, id int64, opt
 	return t, resp, nil
 }
 
-// Create a new attachment on user comment containing a url.
+// CreateAttachment creates a new attachment on user comment containing a url.
 //
 // GitHub API docs: https://developer.github.com/v3/apps/#create-a-content-attachment
 func (s *AppsService) CreateAttachment(ctx context.Context, contentReferenceID int64, title, body string) (*Attachment, *Response, error) {
@@ -252,14 +252,14 @@ func (s *AppsService) CreateAttachment(ctx context.Context, contentReferenceID i
 
 // FindOrganizationInstallation finds the organization's installation information.
 //
-// GitHub API docs: https://developer.github.com/v3/apps/#find-organization-installation
+// GitHub API docs: https://developer.github.com/v3/apps/#get-an-organization-installation
 func (s *AppsService) FindOrganizationInstallation(ctx context.Context, org string) (*Installation, *Response, error) {
 	return s.getInstallation(ctx, fmt.Sprintf("orgs/%v/installation", org))
 }
 
 // FindRepositoryInstallation finds the repository's installation information.
 //
-// GitHub API docs: https://developer.github.com/v3/apps/#find-repository-installation
+// GitHub API docs: https://developer.github.com/v3/apps/#get-a-repository-installation
 func (s *AppsService) FindRepositoryInstallation(ctx context.Context, owner, repo string) (*Installation, *Response, error) {
 	return s.getInstallation(ctx, fmt.Sprintf("repos/%v/%v/installation", owner, repo))
 }
@@ -273,7 +273,7 @@ func (s *AppsService) FindRepositoryInstallationByID(ctx context.Context, id int
 
 // FindUserInstallation finds the user's installation information.
 //
-// GitHub API docs: https://developer.github.com/v3/apps/#find-repository-installation
+// GitHub API docs: https://developer.github.com/v3/apps/#get-a-user-installation
 func (s *AppsService) FindUserInstallation(ctx context.Context, user string) (*Installation, *Response, error) {
 	return s.getInstallation(ctx, fmt.Sprintf("users/%v/installation", user))
 }
diff --git a/github/apps_manifest.go b/github/apps_manifest.go
new file mode 100644
index 00000000..8d6ab3c8
--- /dev/null
+++ b/github/apps_manifest.go
@@ -0,0 +1,53 @@
+// Copyright 2019 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"context"
+	"fmt"
+)
+
+const (
+	mediaTypeAppManifestPreview = "application/vnd.github.fury-preview+json"
+)
+
+// AppConfig describes the configuration of a GitHub App.
+type AppConfig struct {
+	ID            *int64     `json:"id,omitempty"`
+	NodeID        *string    `json:"node_id,omitempty"`
+	Owner         *User      `json:"owner,omitempty"`
+	Name          *string    `json:"name,omitempty"`
+	Description   *string    `json:"description,omitempty"`
+	ExternalURL   *string    `json:"external_url,omitempty"`
+	HTMLURL       *string    `json:"html_url,omitempty"`
+	CreatedAt     *Timestamp `json:"created_at,omitempty"`
+	UpdatedAt     *Timestamp `json:"updated_at,omitempty"`
+	ClientID      *string    `json:"client_id,omitempty"`
+	ClientSecret  *string    `json:"client_secret,omitempty"`
+	WebhookSecret *string    `json:"webhook_secret,omitempty"`
+	PEM           *string    `json:"pem,omitempty"`
+}
+
+// CompleteAppManifest completes the App manifest handshake flow for the given
+// code.
+//
+// GitHub API docs: https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/#3-you-exchange-the-temporary-code-to-retrieve-the-app-configuration
+func (s *AppsService) CompleteAppManifest(ctx context.Context, code string) (*AppConfig, *Response, error) {
+	u := fmt.Sprintf("app-manifests/%s/conversions", code)
+	req, err := s.client.NewRequest("POST", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	req.Header.Set("Accept", mediaTypeAppManifestPreview)
+
+	cfg := new(AppConfig)
+	resp, err := s.client.Do(ctx, req, cfg)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return cfg, resp, nil
+}
diff --git a/github/checks.go b/github/checks.go
index 84f84034..1acc26e9 100644
--- a/github/checks.go
+++ b/github/checks.go
@@ -140,7 +140,6 @@ func (s *ChecksService) GetCheckSuite(ctx context.Context, owner, repo string, c
 // CreateCheckRunOptions sets up parameters needed to create a CheckRun.
 type CreateCheckRunOptions struct {
 	Name        string            `json:"name"`                   // The name of the check (e.g., "code-coverage"). (Required.)
-	HeadBranch  string            `json:"head_branch"`            // The name of the branch to perform a check against. (Required.)
 	HeadSHA     string            `json:"head_sha"`               // The SHA of the commit. (Required.)
 	DetailsURL  *string           `json:"details_url,omitempty"`  // The URL of the integrator's site that has the full details of the check. (Optional.)
 	ExternalID  *string           `json:"external_id,omitempty"`  // A reference for the run on the integrator's system. (Optional.)
@@ -183,7 +182,6 @@ func (s *ChecksService) CreateCheckRun(ctx context.Context, owner, repo string,
 // UpdateCheckRunOptions sets up parameters needed to update a CheckRun.
 type UpdateCheckRunOptions struct {
 	Name        string            `json:"name"`                   // The name of the check (e.g., "code-coverage"). (Required.)
-	HeadBranch  *string           `json:"head_branch,omitempty"`  // The name of the branch to perform a check against. (Optional.)
 	HeadSHA     *string           `json:"head_sha,omitempty"`     // The SHA of the commit. (Optional.)
 	DetailsURL  *string           `json:"details_url,omitempty"`  // The URL of the integrator's site that has the full details of the check. (Optional.)
 	ExternalID  *string           `json:"external_id,omitempty"`  // A reference for the run on the integrator's system. (Optional.)
diff --git a/github/event.go b/github/event.go
index 0ba8f46a..de20f584 100644
--- a/github/event.go
+++ b/github/event.go
@@ -110,6 +110,8 @@ func (e *Event) ParsePayload() (payload interface{}, err error) {
 		payload = &TeamEvent{}
 	case "TeamAddEvent":
 		payload = &TeamAddEvent{}
+	case "UserEvent":
+		payload = &UserEvent{}
 	case "WatchEvent":
 		payload = &WatchEvent{}
 	}
diff --git a/github/event_types.go b/github/event_types.go
index d3e21235..a04c7f62 100644
--- a/github/event_types.go
+++ b/github/event_types.go
@@ -738,7 +738,7 @@ type PushEventRepoOwner struct {
 }
 
 // ReleaseEvent is triggered when a release is published, unpublished, created,
-// edited, deleted, or prerelased.
+// edited, deleted, or prereleased.
 // The Webhook event name is "release".
 //
 // GitHub API docs: https://developer.github.com/v3/activity/events/types/#releaseevent
@@ -872,6 +872,20 @@ type TeamAddEvent struct {
 	Installation *Installation `json:"installation,omitempty"`
 }
 
+// UserEvent is triggered when a user is created or deleted.
+// The Webhook event name is "user".
+//
+// Only global webhooks can subscribe to this event type.
+//
+// GitHub API docs: https://developer.github.com/enterprise/v3/activity/events/types/#userevent-enterprise
+type UserEvent struct {
+	User *User `json:"user,omitempty"`
+	// The action performed. Possible values are: "created" or "deleted".
+	Action     *string     `json:"action,omitempty"`
+	Enterprise *Enterprise `json:"enterprise,omitempty"`
+	Sender     *User       `json:"sender,omitempty"`
+}
+
 // WatchEvent is related to starring a repository, not watching. See this API
 // blog post for an explanation: https://developer.github.com/changes/2012-09-05-watcher-api/
 //
diff --git a/github/github-accessors.go b/github/github-accessors.go
index 9e5a3a6b..b8f0765b 100644
--- a/github/github-accessors.go
+++ b/github/github-accessors.go
@@ -188,6 +188,110 @@ func (a *App) GetUpdatedAt() Timestamp {
 	return *a.UpdatedAt
 }
 
+// GetClientID returns the ClientID field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetClientID() string {
+	if a == nil || a.ClientID == nil {
+		return ""
+	}
+	return *a.ClientID
+}
+
+// GetClientSecret returns the ClientSecret field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetClientSecret() string {
+	if a == nil || a.ClientSecret == nil {
+		return ""
+	}
+	return *a.ClientSecret
+}
+
+// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetCreatedAt() Timestamp {
+	if a == nil || a.CreatedAt == nil {
+		return Timestamp{}
+	}
+	return *a.CreatedAt
+}
+
+// GetDescription returns the Description field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetDescription() string {
+	if a == nil || a.Description == nil {
+		return ""
+	}
+	return *a.Description
+}
+
+// GetExternalURL returns the ExternalURL field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetExternalURL() string {
+	if a == nil || a.ExternalURL == nil {
+		return ""
+	}
+	return *a.ExternalURL
+}
+
+// GetHTMLURL returns the HTMLURL field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetHTMLURL() string {
+	if a == nil || a.HTMLURL == nil {
+		return ""
+	}
+	return *a.HTMLURL
+}
+
+// GetID returns the ID field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetID() int64 {
+	if a == nil || a.ID == nil {
+		return 0
+	}
+	return *a.ID
+}
+
+// GetName returns the Name field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetName() string {
+	if a == nil || a.Name == nil {
+		return ""
+	}
+	return *a.Name
+}
+
+// GetNodeID returns the NodeID field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetNodeID() string {
+	if a == nil || a.NodeID == nil {
+		return ""
+	}
+	return *a.NodeID
+}
+
+// GetOwner returns the Owner field.
+func (a *AppConfig) GetOwner() *User {
+	if a == nil {
+		return nil
+	}
+	return a.Owner
+}
+
+// GetPEM returns the PEM field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetPEM() string {
+	if a == nil || a.PEM == nil {
+		return ""
+	}
+	return *a.PEM
+}
+
+// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetUpdatedAt() Timestamp {
+	if a == nil || a.UpdatedAt == nil {
+		return Timestamp{}
+	}
+	return *a.UpdatedAt
+}
+
+// GetWebhookSecret returns the WebhookSecret field if it's non-nil, zero value otherwise.
+func (a *AppConfig) GetWebhookSecret() string {
+	if a == nil || a.WebhookSecret == nil {
+		return ""
+	}
+	return *a.WebhookSecret
+}
+
 // GetBody returns the Body field if it's non-nil, zero value otherwise.
 func (a *Attachment) GetBody() string {
 	if a == nil || a.Body == nil {
@@ -1988,6 +2092,14 @@ func (c *CreateOrgInvitationOptions) GetRole() string {
 	return *c.Role
 }
 
+// GetBody returns the Body field if it's non-nil, zero value otherwise.
+func (c *CreateUserProjectOptions) GetBody() string {
+	if c == nil || c.Body == nil {
+		return ""
+	}
+	return *c.Body
+}
+
 // GetInstallation returns the Installation field.
 func (d *DeleteEvent) GetInstallation() *Installation {
 	if d == nil {
@@ -2604,6 +2716,86 @@ func (d *DraftReviewComment) GetPosition() int {
 	return *d.Position
 }
 
+// GetAvatarURL returns the AvatarURL field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetAvatarURL() string {
+	if e == nil || e.AvatarURL == nil {
+		return ""
+	}
+	return *e.AvatarURL
+}
+
+// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetCreatedAt() Timestamp {
+	if e == nil || e.CreatedAt == nil {
+		return Timestamp{}
+	}
+	return *e.CreatedAt
+}
+
+// GetDescription returns the Description field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetDescription() string {
+	if e == nil || e.Description == nil {
+		return ""
+	}
+	return *e.Description
+}
+
+// GetHTMLURL returns the HTMLURL field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetHTMLURL() string {
+	if e == nil || e.HTMLURL == nil {
+		return ""
+	}
+	return *e.HTMLURL
+}
+
+// GetID returns the ID field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetID() int {
+	if e == nil || e.ID == nil {
+		return 0
+	}
+	return *e.ID
+}
+
+// GetName returns the Name field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetName() string {
+	if e == nil || e.Name == nil {
+		return ""
+	}
+	return *e.Name
+}
+
+// GetNodeID returns the NodeID field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetNodeID() string {
+	if e == nil || e.NodeID == nil {
+		return ""
+	}
+	return *e.NodeID
+}
+
+// GetSlug returns the Slug field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetSlug() string {
+	if e == nil || e.Slug == nil {
+		return ""
+	}
+	return *e.Slug
+}
+
+// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetUpdatedAt() Timestamp {
+	if e == nil || e.UpdatedAt == nil {
+		return Timestamp{}
+	}
+	return *e.UpdatedAt
+}
+
+// GetWebsiteURL returns the WebsiteURL field if it's non-nil, zero value otherwise.
+func (e *Enterprise) GetWebsiteURL() string {
+	if e == nil || e.WebsiteURL == nil {
+		return ""
+	}
+	return *e.WebsiteURL
+}
+
 // GetActor returns the Actor field.
 func (e *Event) GetActor() *User {
 	if e == nil {
@@ -4484,6 +4676,30 @@ func (i *IssueEvent) GetURL() string {
 	return *i.URL
 }
 
+// GetDirection returns the Direction field if it's non-nil, zero value otherwise.
+func (i *IssueListCommentsOptions) GetDirection() string {
+	if i == nil || i.Direction == nil {
+		return ""
+	}
+	return *i.Direction
+}
+
+// GetSince returns the Since field if it's non-nil, zero value otherwise.
+func (i *IssueListCommentsOptions) GetSince() time.Time {
+	if i == nil || i.Since == nil {
+		return time.Time{}
+	}
+	return *i.Since
+}
+
+// GetSort returns the Sort field if it's non-nil, zero value otherwise.
+func (i *IssueListCommentsOptions) GetSort() string {
+	if i == nil || i.Sort == nil {
+		return ""
+	}
+	return *i.Sort
+}
+
 // GetAssignee returns the Assignee field if it's non-nil, zero value otherwise.
 func (i *IssueRequest) GetAssignee() string {
 	if i == nil || i.Assignee == nil {
@@ -6380,6 +6596,14 @@ func (o *OrganizationEvent) GetSender() *User {
 	return o.Sender
 }
 
+// GetTotalCount returns the TotalCount field if it's non-nil, zero value otherwise.
+func (o *OrganizationInstallations) GetTotalCount() int {
+	if o == nil || o.TotalCount == nil {
+		return 0
+	}
+	return *o.TotalCount
+}
+
 // GetAction returns the Action field if it's non-nil, zero value otherwise.
 func (o *OrgBlockEvent) GetAction() string {
 	if o == nil || o.Action == nil {
@@ -6724,6 +6948,14 @@ func (p *Plan) GetCollaborators() int {
 	return *p.Collaborators
 }
 
+// GetFilledSeats returns the FilledSeats field if it's non-nil, zero value otherwise.
+func (p *Plan) GetFilledSeats() int {
+	if p == nil || p.FilledSeats == nil {
+		return 0
+	}
+	return *p.FilledSeats
+}
+
 // GetName returns the Name field if it's non-nil, zero value otherwise.
 func (p *Plan) GetName() string {
 	if p == nil || p.Name == nil {
@@ -6740,6 +6972,14 @@ func (p *Plan) GetPrivateRepos() int {
 	return *p.PrivateRepos
 }
 
+// GetSeats returns the Seats field if it's non-nil, zero value otherwise.
+func (p *Plan) GetSeats() int {
+	if p == nil || p.Seats == nil {
+		return 0
+	}
+	return *p.Seats
+}
+
 // GetSpace returns the Space field if it's non-nil, zero value otherwise.
 func (p *Plan) GetSpace() int {
 	if p == nil || p.Space == nil {
@@ -8420,6 +8660,14 @@ func (p *PullRequestReviewRequest) GetNodeID() string {
 	return *p.NodeID
 }
 
+// GetDismissalRestrictions returns the DismissalRestrictions field.
+func (p *PullRequestReviewsEnforcement) GetDismissalRestrictions() *DismissalRestrictions {
+	if p == nil {
+		return nil
+	}
+	return p.DismissalRestrictions
+}
+
 // GetDismissalRestrictionsRequest returns the DismissalRestrictionsRequest field.
 func (p *PullRequestReviewsEnforcementRequest) GetDismissalRestrictionsRequest() *DismissalRestrictionsRequest {
 	if p == nil {
@@ -11916,6 +12164,102 @@ func (t *Timeline) GetURL() string {
 	return *t.URL
 }
 
+// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetCreatedAt() Timestamp {
+	if t == nil || t.CreatedAt == nil {
+		return Timestamp{}
+	}
+	return *t.CreatedAt
+}
+
+// GetCreatedBy returns the CreatedBy field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetCreatedBy() string {
+	if t == nil || t.CreatedBy == nil {
+		return ""
+	}
+	return *t.CreatedBy
+}
+
+// GetCurated returns the Curated field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetCurated() bool {
+	if t == nil || t.Curated == nil {
+		return false
+	}
+	return *t.Curated
+}
+
+// GetDescription returns the Description field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetDescription() string {
+	if t == nil || t.Description == nil {
+		return ""
+	}
+	return *t.Description
+}
+
+// GetDisplayName returns the DisplayName field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetDisplayName() string {
+	if t == nil || t.DisplayName == nil {
+		return ""
+	}
+	return *t.DisplayName
+}
+
+// GetFeatured returns the Featured field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetFeatured() bool {
+	if t == nil || t.Featured == nil {
+		return false
+	}
+	return *t.Featured
+}
+
+// GetName returns the Name field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetName() string {
+	if t == nil || t.Name == nil {
+		return ""
+	}
+	return *t.Name
+}
+
+// GetScore returns the Score field.
+func (t *TopicResult) GetScore() *float64 {
+	if t == nil {
+		return nil
+	}
+	return t.Score
+}
+
+// GetShortDescription returns the ShortDescription field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetShortDescription() string {
+	if t == nil || t.ShortDescription == nil {
+		return ""
+	}
+	return *t.ShortDescription
+}
+
+// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise.
+func (t *TopicResult) GetUpdatedAt() string {
+	if t == nil || t.UpdatedAt == nil {
+		return ""
+	}
+	return *t.UpdatedAt
+}
+
+// GetIncompleteResults returns the IncompleteResults field if it's non-nil, zero value otherwise.
+func (t *TopicsSearchResult) GetIncompleteResults() bool {
+	if t == nil || t.IncompleteResults == nil {
+		return false
+	}
+	return *t.IncompleteResults
+}
+
+// GetTotal returns the Total field if it's non-nil, zero value otherwise.
+func (t *TopicsSearchResult) GetTotal() int {
+	if t == nil || t.Total == nil {
+		return 0
+	}
+	return *t.Total
+}
+
 // GetCount returns the Count field if it's non-nil, zero value otherwise.
 func (t *TrafficClones) GetCount() int {
 	if t == nil || t.Count == nil {
@@ -12132,14 +12476,6 @@ func (u *UpdateCheckRunOptions) GetExternalID() string {
 	return *u.ExternalID
 }
 
-// GetHeadBranch returns the HeadBranch field if it's non-nil, zero value otherwise.
-func (u *UpdateCheckRunOptions) GetHeadBranch() string {
-	if u == nil || u.HeadBranch == nil {
-		return ""
-	}
-	return *u.HeadBranch
-}
-
 // GetHeadSHA returns the HeadSHA field if it's non-nil, zero value otherwise.
 func (u *UpdateCheckRunOptions) GetHeadSHA() string {
 	if u == nil || u.HeadSHA == nil {
@@ -12540,6 +12876,38 @@ func (u *UserEmail) GetVisibility() string {
 	return *u.Visibility
 }
 
+// GetAction returns the Action field if it's non-nil, zero value otherwise.
+func (u *UserEvent) GetAction() string {
+	if u == nil || u.Action == nil {
+		return ""
+	}
+	return *u.Action
+}
+
+// GetEnterprise returns the Enterprise field.
+func (u *UserEvent) GetEnterprise() *Enterprise {
+	if u == nil {
+		return nil
+	}
+	return u.Enterprise
+}
+
+// GetSender returns the Sender field.
+func (u *UserEvent) GetSender() *User {
+	if u == nil {
+		return nil
+	}
+	return u.Sender
+}
+
+// GetUser returns the User field.
+func (u *UserEvent) GetUser() *User {
+	if u == nil {
+		return nil
+	}
+	return u.User
+}
+
 // GetAvatarURL returns the AvatarURL field if it's non-nil, zero value otherwise.
 func (u *UserLDAPMapping) GetAvatarURL() string {
 	if u == nil || u.AvatarURL == nil {
diff --git a/github/github.go b/github/github.go
index 0580271c..f5398325 100644
--- a/github/github.go
+++ b/github/github.go
@@ -67,9 +67,6 @@ const (
 	// https://developer.github.com/changes/2016-05-23-timeline-preview-api/
 	mediaTypeTimelinePreview = "application/vnd.github.mockingbird-preview+json"
 
-	// https://developer.github.com/changes/2016-07-06-github-pages-preiew-api/
-	mediaTypePagesPreview = "application/vnd.github.mister-fantastic-preview+json"
-
 	// https://developer.github.com/changes/2016-09-14-projects-api/
 	mediaTypeProjectsPreview = "application/vnd.github.inertia-preview+json"
 
@@ -148,9 +145,6 @@ const (
 	// https://developer.github.com/changes/2019-04-11-pulls-branches-for-commit/
 	mediaTypeListPullsOrBranchesForCommitPreview = "application/vnd.github.groot-preview+json"
 
-	// https://developer.github.com/changes/2019-06-12-team-sync/
-	mediaTypeTeamSyncPreview = "application/vnd.github.team-sync-preview+json"
-
 	// https://developer.github.com/v3/previews/#repository-creation-permissions
 	mediaTypeMemberAllowedRepoCreationTypePreview = "application/vnd.github.surtur-preview+json"
 
diff --git a/github/issues_comments.go b/github/issues_comments.go
index ab68afe2..42d6bc78 100644
--- a/github/issues_comments.go
+++ b/github/issues_comments.go
@@ -36,13 +36,13 @@ func (i IssueComment) String() string {
 // IssuesService.ListComments method.
 type IssueListCommentsOptions struct {
 	// Sort specifies how to sort comments. Possible values are: created, updated.
-	Sort string `url:"sort,omitempty"`
+	Sort *string `url:"sort,omitempty"`
 
 	// Direction in which to sort comments. Possible values are: asc, desc.
-	Direction string `url:"direction,omitempty"`
+	Direction *string `url:"direction,omitempty"`
 
 	// Since filters comments by time.
-	Since time.Time `url:"since,omitempty"`
+	Since *time.Time `url:"since,omitempty"`
 
 	ListOptions
 }
diff --git a/github/messages.go b/github/messages.go
index ac7c17f3..a87daa3e 100644
--- a/github/messages.go
+++ b/github/messages.go
@@ -80,6 +80,7 @@ var (
 		"status":                         "StatusEvent",
 		"team":                           "TeamEvent",
 		"team_add":                       "TeamAddEvent",
+		"user":                           "UserEvent",
 		"watch":                          "WatchEvent",
 	}
 )
diff --git a/github/orgs.go b/github/orgs.go
index e1aa20db..5012c6c9 100644
--- a/github/orgs.go
+++ b/github/orgs.go
@@ -70,6 +70,12 @@ type Organization struct {
 	ReposURL         *string `json:"repos_url,omitempty"`
 }
 
+// OrganizationInstallations represents GitHub app installations for an organization.
+type OrganizationInstallations struct {
+	TotalCount    *int            `json:"total_count,omitempty"`
+	Installations []*Installation `json:"installations,omitempty"`
+}
+
 func (o Organization) String() string {
 	return Stringify(o)
 }
@@ -80,6 +86,8 @@ type Plan struct {
 	Space         *int    `json:"space,omitempty"`
 	Collaborators *int    `json:"collaborators,omitempty"`
 	PrivateRepos  *int    `json:"private_repos,omitempty"`
+	FilledSeats   *int    `json:"filled_seats,omitempty"`
+	Seats         *int    `json:"seats,omitempty"`
 }
 
 func (p Plan) String() string {
@@ -213,3 +221,31 @@ func (s *OrganizationsService) Edit(ctx context.Context, name string, org *Organ
 
 	return o, resp, nil
 }
+
+// ListInstallations lists installations for an organization.
+//
+// GitHub API docs: https://developer.github.com/v3/orgs/#list-installations-for-an-organization
+func (s *OrganizationsService) ListInstallations(ctx context.Context, org string, opt *ListOptions) (*OrganizationInstallations, *Response, error) {
+	u := fmt.Sprintf("orgs/%v/installations", org)
+
+	u, err := addOptions(u, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// TODO: remove custom Accept header when this API fully launches.
+	req.Header.Set("Accept", mediaTypeIntegrationPreview)
+
+	result := new(OrganizationInstallations)
+	resp, err := s.client.Do(ctx, req, result)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return result, resp, nil
+}
diff --git a/github/repos.go b/github/repos.go
index 37a09d5b..1960e0a7 100644
--- a/github/repos.go
+++ b/github/repos.go
@@ -736,7 +736,7 @@ type RequiredStatusChecksRequest struct {
 // PullRequestReviewsEnforcement represents the pull request reviews enforcement of a protected branch.
 type PullRequestReviewsEnforcement struct {
 	// Specifies which users and teams can dismiss pull request reviews.
-	DismissalRestrictions DismissalRestrictions `json:"dismissal_restrictions"`
+	DismissalRestrictions *DismissalRestrictions `json:"dismissal_restrictions,omitempty"`
 	// Specifies if approved reviews are dismissed automatically, when a new commit is pushed.
 	DismissStaleReviews bool `json:"dismiss_stale_reviews"`
 	// RequireCodeOwnerReviews specifies if an approved review is required in pull requests including files with a designated code owner.
@@ -1134,9 +1134,9 @@ func (s *RepositoriesService) UpdatePullRequestReviewEnforcement(ctx context.Con
 func (s *RepositoriesService) DisableDismissalRestrictions(ctx context.Context, owner, repo, branch string) (*PullRequestReviewsEnforcement, *Response, error) {
 	u := fmt.Sprintf("repos/%v/%v/branches/%v/protection/required_pull_request_reviews", owner, repo, branch)
 
-	data := struct {
-		R []interface{} `json:"dismissal_restrictions"`
-	}{[]interface{}{}}
+	data := new(struct {
+		DismissalRestrictionsRequest `json:"dismissal_restrictions"`
+	})
 
 	req, err := s.client.NewRequest("PATCH", u, data)
 	if err != nil {
diff --git a/github/repos_contents.go b/github/repos_contents.go
index bf6cabc5..eed0420a 100644
--- a/github/repos_contents.go
+++ b/github/repos_contents.go
@@ -240,15 +240,28 @@ const (
 // or github.Zipball constant.
 //
 // GitHub API docs: https://developer.github.com/v3/repos/contents/#get-archive-link
-func (s *RepositoriesService) GetArchiveLink(ctx context.Context, owner, repo string, archiveformat archiveFormat, opt *RepositoryContentGetOptions) (*url.URL, *Response, error) {
+func (s *RepositoriesService) GetArchiveLink(ctx context.Context, owner, repo string, archiveformat archiveFormat, opt *RepositoryContentGetOptions, followRedirects bool) (*url.URL, *Response, error) {
 	u := fmt.Sprintf("repos/%s/%s/%s", owner, repo, archiveformat)
 	if opt != nil && opt.Ref != "" {
 		u += fmt.Sprintf("/%s", opt.Ref)
 	}
-	req, err := s.client.NewRequest("GET", u, nil)
+	resp, err := s.getArchiveLinkFromURL(ctx, u, followRedirects)
 	if err != nil {
 		return nil, nil, err
 	}
+	if resp.StatusCode != http.StatusFound {
+		return nil, newResponse(resp), fmt.Errorf("unexpected status code: %s", resp.Status)
+	}
+	parsedURL, err := url.Parse(resp.Header.Get("Location"))
+	return parsedURL, newResponse(resp), err
+}
+
+func (s *RepositoriesService) getArchiveLinkFromURL(ctx context.Context, u string, followRedirects bool) (*http.Response, error) {
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, err
+	}
+
 	var resp *http.Response
 	// Use http.DefaultTransport if no custom Transport is configured
 	req = withContext(ctx, req)
@@ -258,12 +271,14 @@ func (s *RepositoriesService) GetArchiveLink(ctx context.Context, owner, repo st
 		resp, err = s.client.client.Transport.RoundTrip(req)
 	}
 	if err != nil {
-		return nil, nil, err
+		return nil, err
 	}
 	resp.Body.Close()
-	if resp.StatusCode != http.StatusFound {
-		return nil, newResponse(resp), fmt.Errorf("unexpected status code: %s", resp.Status)
+
+	// If redirect response is returned, follow it
+	if followRedirects && resp.StatusCode == http.StatusMovedPermanently {
+		u = resp.Header.Get("Location")
+		resp, err = s.getArchiveLinkFromURL(ctx, u, false)
 	}
-	parsedURL, err := url.Parse(resp.Header.Get("Location"))
-	return parsedURL, newResponse(resp), err
+	return resp, err
 }
diff --git a/github/repos_pages.go b/github/repos_pages.go
index 28674130..7eb32a9d 100644
--- a/github/repos_pages.go
+++ b/github/repos_pages.go
@@ -8,7 +8,6 @@ package github
 import (
 	"context"
 	"fmt"
-	"strings"
 )
 
 // Pages represents a GitHub Pages site configuration.
@@ -54,9 +53,7 @@ func (s *RepositoriesService) EnablePages(ctx context.Context, owner, repo strin
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	acceptHeaders := []string{mediaTypeEnablePagesAPIPreview, mediaTypePagesPreview}
-	req.Header.Set("Accept", strings.Join(acceptHeaders, ", "))
+	req.Header.Set("Accept", mediaTypeEnablePagesAPIPreview)
 
 	enable := new(Pages)
 	resp, err := s.client.Do(ctx, req, enable)
@@ -93,9 +90,6 @@ func (s *RepositoriesService) GetPagesInfo(ctx context.Context, owner, repo stri
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	req.Header.Set("Accept", mediaTypePagesPreview)
-
 	site := new(Pages)
 	resp, err := s.client.Do(ctx, req, site)
 	if err != nil {
@@ -177,9 +171,6 @@ func (s *RepositoriesService) RequestPageBuild(ctx context.Context, owner, repo
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	req.Header.Set("Accept", mediaTypePagesPreview)
-
 	build := new(PagesBuild)
 	resp, err := s.client.Do(ctx, req, build)
 	if err != nil {
diff --git a/github/search.go b/github/search.go
index 13cc4c84..4e66ba1c 100644
--- a/github/search.go
+++ b/github/search.go
@@ -76,6 +76,37 @@ func (s *SearchService) Repositories(ctx context.Context, query string, opt *Sea
 	return result, resp, err
 }
 
+// TopicsSearchResult represents the result of a topics search.
+type TopicsSearchResult struct {
+	Total             *int           `json:"total_count,omitempty"`
+	IncompleteResults *bool          `json:"incomplete_results,omitempty"`
+	Topics            []*TopicResult `json:"items,omitempty"`
+}
+
+type TopicResult struct {
+	Name             *string    `json:"name,omitempty"`
+	DisplayName      *string    `json:"display_name,omitempty"`
+	ShortDescription *string    `json:"short_description,omitempty"`
+	Description      *string    `json:"description,omitempty"`
+	CreatedBy        *string    `json:"created_by,omitempty"`
+	CreatedAt        *Timestamp `json:"created_at,omitempty"`
+	UpdatedAt        *string    `json:"updated_at,omitempty"`
+	Featured         *bool      `json:"featured,omitempty"`
+	Curated          *bool      `json:"curated,omitempty"`
+	Score            *float64   `json:"score,omitempty"`
+}
+
+// Topics finds topics via various criteria. Results are sorted by best match.
+// Please see https://help.github.com/en/articles/searching-topics for more
+// information about search qualifiers.
+//
+// GitHub API docs: https://developer.github.com/v3/search/#search-topics
+func (s *SearchService) Topics(ctx context.Context, query string, opt *SearchOptions) (*TopicsSearchResult, *Response, error) {
+	result := new(TopicsSearchResult)
+	resp, err := s.search(ctx, "topics", &searchParameters{Query: query}, opt, result)
+	return result, resp, err
+}
+
 // CommitsSearchResult represents the result of a commits search.
 type CommitsSearchResult struct {
 	Total             *int            `json:"total_count,omitempty"`
@@ -245,6 +276,10 @@ func (s *SearchService) search(ctx context.Context, searchType string, parameter
 		// Accept header for search commits preview endpoint
 		// TODO: remove custom Accept header when this API fully launches.
 		req.Header.Set("Accept", mediaTypeCommitSearchPreview)
+	case searchType == "topics":
+		// Accept header for search repositories based on topics preview endpoint
+		// TODO: remove custom Accept header when this API fully launches.
+		req.Header.Set("Accept", mediaTypeTopicsPreview)
 	case searchType == "repositories":
 		// Accept header for search repositories based on topics preview endpoint
 		// TODO: remove custom Accept header when this API fully launches.
diff --git a/github/teams.go b/github/teams.go
index b4953e63..7f00b986 100644
--- a/github/teams.go
+++ b/github/teams.go
@@ -504,9 +504,6 @@ func (s *TeamsService) ListIDPGroupsInOrganization(ctx context.Context, org stri
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	req.Header.Set("Accept", mediaTypeTeamSyncPreview)
-
 	groups := new(IDPGroupList)
 	resp, err := s.client.Do(ctx, req, groups)
 	if err != nil {
@@ -526,9 +523,6 @@ func (s *TeamsService) ListIDPGroupsForTeam(ctx context.Context, teamID string)
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	req.Header.Set("Accept", mediaTypeTeamSyncPreview)
-
 	groups := new(IDPGroupList)
 	resp, err := s.client.Do(ctx, req, groups)
 	if err != nil {
@@ -549,9 +543,6 @@ func (s *TeamsService) CreateOrUpdateIDPGroupConnections(ctx context.Context, te
 		return nil, nil, err
 	}
 
-	// TODO: remove custom Accept header when this API fully launches.
-	req.Header.Set("Accept", mediaTypeTeamSyncPreview)
-
 	groups := new(IDPGroupList)
 	resp, err := s.client.Do(ctx, req, groups)
 	if err != nil {
diff --git a/github/users_projects.go b/github/users_projects.go
new file mode 100644
index 00000000..bd2ca75e
--- /dev/null
+++ b/github/users_projects.go
@@ -0,0 +1,68 @@
+// Copyright 2019 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"context"
+	"fmt"
+)
+
+// ListProjects lists the projects for the specified user.
+//
+// GitHub API docs: https://developer.github.com/v3/projects/#list-user-projects
+func (s *UsersService) ListProjects(ctx context.Context, user string, opt *ProjectListOptions) ([]*Project, *Response, error) {
+	u := fmt.Sprintf("users/%v/projects", user)
+	u, err := addOptions(u, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// TODO: remove custom Accept header when this API fully launches.
+	req.Header.Set("Accept", mediaTypeProjectsPreview)
+
+	var projects []*Project
+	resp, err := s.client.Do(ctx, req, &projects)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return projects, resp, nil
+}
+
+// CreateUserProjectOptions specifies the parameters to the UsersService.CreateProject method.
+type CreateUserProjectOptions struct {
+	// The name of the project. (Required.)
+	Name string `json:"name"`
+	// The description of the project. (Optional.)
+	Body *string `json:"body,omitempty"`
+}
+
+// CreateProject creates a GitHub Project for the current user.
+//
+// GitHub API docs: https://developer.github.com/v3/projects/#create-a-user-project
+func (s *UsersService) CreateProject(ctx context.Context, opt *CreateUserProjectOptions) (*Project, *Response, error) {
+	u := "users/projects"
+	req, err := s.client.NewRequest("POST", u, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// TODO: remove custom Accept header when this API fully launches.
+	req.Header.Set("Accept", mediaTypeProjectsPreview)
+
+	project := &Project{}
+	resp, err := s.client.Do(ctx, req, project)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return project, resp, nil
+}
diff --git a/go.mod b/go.mod
index f6198d3c..23ce41ca 100644
--- a/go.mod
+++ b/go.mod
@@ -1,12 +1,11 @@
 module github.com/google/go-github/v28
 
 require (
-	github.com/golang/protobuf v1.2.0 // indirect
+	github.com/golang/protobuf v1.3.2 // indirect
 	github.com/google/go-querystring v1.0.0
 	golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
 	golang.org/x/net v0.0.0-20190311183353-d8887717615a // indirect
 	golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
-	golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 // indirect
 	google.golang.org/appengine v1.1.0
 )
 
diff --git a/go.sum b/go.sum
index 927256d3..dbab13e8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
@@ -8,8 +8,6 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJV
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/scrape/README.md b/scrape/README.md
new file mode 100644
index 00000000..5d6180da
--- /dev/null
+++ b/scrape/README.md
@@ -0,0 +1,26 @@
+[![GoDoc](https://godoc.org/github.com/google/go-github/scrape?status.svg)](https://godoc.org/github.com/google/go-github/scrape)
+
+The scrape package provides an experimental client for accessing additional
+GitHub data via screen scraping.  It is designed to be a client of last resort
+for data that cannot be retrieved via the REST or GraphQL APIs.
+
+# What should be added here
+
+**Add only what you need.**  Whereas the main go-github library attempts to
+implement the entire GitHub REST API, there is little point in trying to do that
+here.  Certainly, feel free to contribution patches to get data you actually
+need, but I'd rather not try and provide exhaustive coverage of all GitHub data
+here.
+
+**Add only what can't be accessed elsewhere.**  If the data can be retrieved
+through the REST or GraphQL API, use the appropriate libraries for that.
+
+**Prefer read-only access.**  For now, I'm only focusing on reading data. It
+might be that writing works fine as well, but it is of course much riskier.
+
+# How to add methods
+
+See [apps.go](apps.go) for examples of methods that access data.  Basically,
+fetch the contents of the page using `client.get`, and then use goquery to dig
+into the markup on the page.  Prefer selectors that grab semantic ID or class
+names, as they are more likely to be stable.
diff --git a/scrape/apps.go b/scrape/apps.go
new file mode 100644
index 00000000..eb2f9005
--- /dev/null
+++ b/scrape/apps.go
@@ -0,0 +1,146 @@
+// Copyright 2013 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// apps.go contains functions for accessing data about applications installed
+// on a GitHub organization.
+
+package scrape
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/google/go-github/v28/github"
+)
+
+// AppRestrictionsEnabled returns whether the specified organization has
+// restricted third-party application access.
+func (c *Client) AppRestrictionsEnabled(org string) (bool, error) {
+	doc, err := c.get("/organizations/%s/settings/oauth_application_policy", org)
+	if err != nil {
+		return false, err
+	}
+
+	s := doc.Find(".oauth-application-whitelist svg").First()
+	if s.Length() == 0 {
+		return false, errors.New("unable to find expected markup")
+	}
+
+	if s.HasClass("octicon-check") {
+		return true, nil
+	}
+	if s.HasClass("octicon-alert") {
+		return false, nil
+	}
+
+	return false, errors.New("unable to find expected markup")
+}
+
+// ListOAuthApps lists the reviewed OAuth Applications for the
+// specified organization (whether approved or denied).
+func (c *Client) ListOAuthApps(org string) ([]OAuthApp, error) {
+	doc, err := c.get("/organizations/%s/settings/oauth_application_policy", org)
+	if err != nil {
+		return nil, err
+	}
+
+	var apps []OAuthApp
+	doc.Find(".oauth-application-whitelist ul > li").Each(func(i int, s *goquery.Selection) {
+		var app OAuthApp
+		app.Name = s.Find(".request-info strong").First().Text()
+		app.Description = s.Find(".request-info .application-description").Text()
+
+		if editURL, ok := s.Find(".request-indicator a.edit-link").Attr("href"); ok {
+			app.ID = intFromLastPathSegment(editURL)
+		}
+
+		if r := s.Find(".request-indicator .requestor"); r.Length() > 0 {
+			app.State = OAuthAppRequested
+			app.RequestedBy = r.Text()
+			if editURL, ok := s.Find(".request-indicator a").Last().Attr("href"); ok {
+				app.ID = intFromLastPathSegment(editURL)
+			}
+		} else if r := s.Find(".request-indicator .approved-request"); r.Length() > 0 {
+			app.State = OAuthAppApproved
+		} else if r := s.Find(".request-indicator .denied-request"); r.Length() > 0 {
+			app.State = OAuthAppDenied
+		}
+		apps = append(apps, app)
+	})
+
+	return apps, nil
+}
+
+func intFromLastPathSegment(s string) int {
+	seg := strings.Split(s, "/")
+	if len(seg) > 0 {
+		i, _ := strconv.Atoi(seg[len(seg)-1])
+		return i
+	}
+	return 0
+}
+
+// OAuthAppReviewState indicates the current state of a requested OAuth Application.
+type OAuthAppReviewState int
+
+const (
+	// OAuthAppRequested indicates access has been requested, but not reviewed
+	OAuthAppRequested OAuthAppReviewState = iota + 1
+	// OAuthAppApproved indicates access has been approved
+	OAuthAppApproved
+	// OAuthAppDenied indicates access has been denied
+	OAuthAppDenied
+)
+
+// OAuthApp represents an OAuth application that has been reviewed for access to organization data.
+type OAuthApp struct {
+	ID          int
+	Name        string
+	Description string
+	State       OAuthAppReviewState
+	RequestedBy string
+}
+
+// AppManifest represents a GitHub App manifest, used for preconfiguring
+// GitHub App configuration.
+type AppManifest struct {
+	// The name of the GitHub App.
+	Name *string `json:"name,omitempty"`
+	//Required. The homepage of your GitHub App.
+	URL *string `json:"url,omitempty"`
+	// Required. The configuration of the GitHub App's webhook.
+	HookAttributes map[string]string `json:"hook_attributes,omitempty"`
+	// The full URL to redirect to after the person installs the GitHub App.
+	RedirectURL *string `json:"redirect_url,omitempty"`
+	// A description of the GitHub App.
+	Description *string `json:"description,omitempty"`
+	// Set to true when your GitHub App is available to the public or false when
+	// it is only accessible to the owner of the app.
+	Public *bool `json:"public,omitempty"`
+	// The list of events the GitHub App subscribes to.
+	DefaultEvents []string `json:"default_events,omitempty"`
+	// The set of permissions needed by the GitHub App.
+	DefaultPermissions *github.InstallationPermissions `json:"default_permissions,omitempty"`
+}
+
+// CreateApp creates a new GitHub App with the given manifest configuration.
+func (c *Client) CreateApp(m *AppManifest) (*http.Response, error) {
+	u, err := c.baseURL.Parse("/settings/apps/new")
+	if err != nil {
+		return nil, err
+	}
+
+	body, err := json.Marshal(map[string]*AppManifest{"manifest": m})
+	if err != nil {
+		return nil, err
+	}
+
+	return c.Client.Post(u.String(), "json", bytes.NewReader(body))
+}
diff --git a/scrape/example/scrape/main.go b/scrape/example/scrape/main.go
new file mode 100644
index 00000000..a55b88eb
--- /dev/null
+++ b/scrape/example/scrape/main.go
@@ -0,0 +1,61 @@
+// The scrape tool demonstrates use of the github.com/google/go-github/scrape
+// package to fetch data from GitHub.  The tool lists whether third-party app
+// restrictions are enabled for an organization, and lists information about
+// OAuth apps requested for the org.
+package main
+
+import (
+	"bufio"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"strings"
+
+	"github.com/google/go-github/scrape"
+)
+
+var (
+	username = flag.String("username", "", "github auth: username")
+	password = flag.String("password", "", "github auth: password")
+	otpseed  = flag.String("otpseed", "", "github auth: otp seed")
+	org      = flag.String("org", "", "github org to get data for")
+)
+
+func main() {
+	flag.Parse()
+
+	// prompt for password and otpseed in case the user didn't want to specify as flags
+	reader := bufio.NewReader(os.Stdin)
+	if *password == "" {
+		fmt.Print("password: ")
+		*password, _ = reader.ReadString('\n')
+		*password = strings.TrimSpace(*password)
+	}
+	if *otpseed == "" {
+		fmt.Print("OTP seed: ")
+		*otpseed, _ = reader.ReadString('\n')
+		*otpseed = strings.TrimSpace(*otpseed)
+	}
+
+	client := scrape.NewClient(nil)
+
+	if err := client.Authenticate(*username, *password, *otpseed); err != nil {
+		log.Fatal(err)
+	}
+
+	enabled, err := client.AppRestrictionsEnabled(*org)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Printf("App restrictions enabled for %q: %t\n", *org, enabled)
+
+	apps, err := client.ListOAuthApps(*org)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Printf("OAuth apps for %q: \n", *org)
+	for _, app := range apps {
+		fmt.Printf("\t%+v\n", app)
+	}
+}
diff --git a/scrape/forms.go b/scrape/forms.go
new file mode 100644
index 00000000..ee559395
--- /dev/null
+++ b/scrape/forms.go
@@ -0,0 +1,98 @@
+// Copyright 2013 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// forms.go contains logic for parsing and submitting HTML forms.  None of this
+// is specific to go-github in any way, and could easily be pulled out into a
+// general purpose scraping library in the future.
+
+package scrape
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/PuerkitoBio/goquery"
+	"golang.org/x/net/html"
+)
+
+// htmlForm represents the basic elements of an HTML Form.
+type htmlForm struct {
+	// Action is the URL where the form will be submitted
+	Action string
+	// Method is the HTTP method to use when submitting the form
+	Method string
+	// Values contains form values to be submitted
+	Values url.Values
+}
+
+// parseForms parses and returns all form elements beneath node.  Form values
+// include all nested input elements within the form (textarea is not currently
+// supported).
+//
+// In the future, we might want to allow a custom selector to be passed in to
+// further restrict what forms will be returned.
+func parseForms(node *html.Node) (forms []htmlForm) {
+	if node == nil {
+		return nil
+	}
+
+	doc := goquery.NewDocumentFromNode(node)
+	doc.Find("form").Each(func(_ int, s *goquery.Selection) {
+		form := htmlForm{Values: url.Values{}}
+		form.Action, _ = s.Attr("action")
+		form.Method, _ = s.Attr("method")
+
+		s.Find("input").Each(func(_ int, s *goquery.Selection) {
+			name, _ := s.Attr("name")
+			value, _ := s.Attr("value")
+			if name != "" {
+				form.Values.Add(name, value)
+			}
+		})
+		forms = append(forms, form)
+	})
+
+	return forms
+}
+
+// fetchAndSubmitForm will fetch the page at urlStr, then parse and submit the first form found.
+// setValues will be called with the parsed form values, allowing the caller to set any custom
+// form values. Form submission will always use the POST method, regardless of the value of the
+// method attribute in the form.  The response from submitting the parsed form is returned.
+func fetchAndSubmitForm(client *http.Client, urlStr string, setValues func(url.Values)) (*http.Response, error) {
+	resp, err := client.Get(urlStr)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching url %q: %v", urlStr, err)
+	}
+
+	defer resp.Body.Close()
+	root, err := html.Parse(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing response: %v", err)
+	}
+
+	forms := parseForms(root)
+	if len(forms) == 0 {
+		return nil, fmt.Errorf("no forms found at %q", urlStr)
+	}
+	form := forms[0]
+
+	actionURL, err := url.Parse(form.Action)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing form action URL %q: %v", form.Action, err)
+	}
+	actionURL = resp.Request.URL.ResolveReference(actionURL)
+
+	// allow caller to fill out the form
+	setValues(form.Values)
+
+	resp, err = client.PostForm(actionURL.String(), form.Values)
+	if err != nil {
+		return nil, fmt.Errorf("error posting form: %v", err)
+	}
+
+	return resp, nil
+}
diff --git a/scrape/go.mod b/scrape/go.mod
new file mode 100644
index 00000000..f936c07d
--- /dev/null
+++ b/scrape/go.mod
@@ -0,0 +1,11 @@
+module github.com/google/go-github/scrape
+
+go 1.13
+
+require (
+	github.com/PuerkitoBio/goquery v1.5.0
+	github.com/google/go-cmp v0.3.1
+	github.com/google/go-github/v28 v28.1.1
+	github.com/xlzd/gotp v0.0.0-20181030022105-c8557ba2c119
+	golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582
+)
diff --git a/scrape/go.sum b/scrape/go.sum
new file mode 100644
index 00000000..7f226791
--- /dev/null
+++ b/scrape/go.sum
@@ -0,0 +1,25 @@
+github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
+github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
+github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
+github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo=
+github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM=
+github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
+github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/xlzd/gotp v0.0.0-20181030022105-c8557ba2c119 h1:YyPWX3jLOtYKulBR6AScGIs74lLrJcgeKRwcbAuQOG4=
+github.com/xlzd/gotp v0.0.0-20181030022105-c8557ba2c119/go.mod h1:/nuTSlK+okRfR/vnIPqR89fFKonnWPiZymN5ydRJkX8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 h1:p9xBe/w/OzkeYVKm234g55gMdD1nSIooTir5kV11kfA=
+golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
diff --git a/scrape/payment.go b/scrape/payment.go
new file mode 100644
index 00000000..fff9a753
--- /dev/null
+++ b/scrape/payment.go
@@ -0,0 +1,51 @@
+// Copyright 2013 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// apps.go contains functions for accessing data about applications installed
+// on a GitHub organization.
+
+package scrape
+
+import (
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+)
+
+// OrgPaymentInformation returns payment information for the specified org.
+func (c *Client) OrgPaymentInformation(org string) (PaymentInformation, error) {
+	var info PaymentInformation
+
+	doc, err := c.get("/organizations/%s/settings/billing/payment_information", org)
+	if err != nil {
+		return info, err
+	}
+
+	doc.Find("main h4.mb-1").Each(func(i int, s *goquery.Selection) {
+		name := strings.TrimSpace(strings.ToLower(s.Text()))
+		value := strings.Join(strings.Fields(strings.TrimSpace(s.NextFiltered("p").Text())), " ")
+
+		switch name {
+		case "payment method":
+			info.PaymentMethod = value
+		case "last payment":
+			info.LastPayment = value
+		case "coupon":
+			info.Coupon = value
+		case "extra information":
+			info.ExtraInformation = value
+		}
+	})
+
+	return info, nil
+}
+
+// PaymentInformation for an organization on a paid plan.
+type PaymentInformation struct {
+	PaymentMethod    string
+	LastPayment      string
+	Coupon           string
+	ExtraInformation string
+}
diff --git a/scrape/scrape.go b/scrape/scrape.go
new file mode 100644
index 00000000..c3be9af1
--- /dev/null
+++ b/scrape/scrape.go
@@ -0,0 +1,153 @@
+// Copyright 2013 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package scrape provides a client for interacting with GitHub using screen
+// scraping.
+//
+// This is intended to be used as a supplement to the standard go-github
+// library to access data that is not currently exposed by either the official
+// REST or GraphQL APIs.
+//
+// Because of the nature of screen scraping, this package should be treated as
+// HIGHLY EXPERIMENTAL, and potentially unstable. We make no promises relating
+// to compatibility or stability of the exported API. Even though this package
+// is distributed as part of the go-github library, it is explicitly exempt
+// from any stability promises that my be implied by the library version
+// number.
+package scrape
+
+import (
+	"bytes"
+	"encoding/gob"
+	"fmt"
+	"log"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/xlzd/gotp"
+	"golang.org/x/net/publicsuffix"
+)
+
+var defaultBaseURL = "https://github.com/"
+
+// Client is a GitHub scraping client.
+type Client struct {
+	*http.Client
+
+	// base URL for github.com pages.  Exposed primarily for testing.  Also
+	// used for saving and restoring cookies on the Client.
+	baseURL *url.URL
+}
+
+// NewClient constructs a new Client.  If transport is nil, a default transport is used.
+func NewClient(transport http.RoundTripper) *Client {
+	jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
+	if err != nil {
+		log.Fatalf("error creating cookiejar: %v", err)
+	}
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		Client: &http.Client{
+			Transport: transport,
+			Jar:       jar,
+		},
+		baseURL: baseURL,
+	}
+}
+
+// SaveCookies returns an encoded form of the github.com cookies set on this
+// client.  If Authenticate() has been called, this should include the
+// github.com session cookie.  These cookies can be loaded onto a new client by
+// calling LoadCookies.
+//
+// GitHub session cookies are bearer tokens that are not tied to any particular
+// client, so should be treated with the same sensitivity as the account
+// credentials.
+func (c *Client) SaveCookies() ([]byte, error) {
+	cookies := c.Client.Jar.Cookies(c.baseURL)
+
+	var b bytes.Buffer
+	err := gob.NewEncoder(&b).Encode(cookies)
+	return b.Bytes(), err
+}
+
+// LoadCookies loads the provided cookies for github.com.
+func (c *Client) LoadCookies(v []byte) error {
+	var cookies []*http.Cookie
+	r := bytes.NewReader(v)
+	err := gob.NewDecoder(r).Decode(&cookies)
+	if err != nil {
+		return err
+	}
+
+	c.Client.Jar.SetCookies(c.baseURL, cookies)
+	return nil
+}
+
+// get fetches a urlStr (a GitHub URL relative to the client's baseURL), and
+// returns the parsed response document.
+func (c *Client) get(urlStr string, a ...interface{}) (*goquery.Document, error) {
+	u, err := c.baseURL.Parse(fmt.Sprintf(urlStr, a...))
+	if err != nil {
+		return nil, fmt.Errorf("error parsing URL: %q: %v", urlStr, err)
+	}
+	resp, err := c.Client.Get(u.String())
+	if err != nil {
+		return nil, fmt.Errorf("error fetching url %q: %v", u, err)
+	}
+	if resp.StatusCode == http.StatusNotFound {
+		return nil, fmt.Errorf("received %v response fetching URL %q", resp.StatusCode, u)
+	}
+
+	defer resp.Body.Close()
+	doc, err := goquery.NewDocumentFromReader(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing response: %v", err)
+	}
+
+	return doc, nil
+}
+
+// Authenticate client to GitHub with the provided username, password, and if
+// two-factor auth is enabled for the account, otpseed.
+//
+// otpseed is the OTP Secret provided from GitHub as part of two-factor
+// application enrollment.  When registering the application, click the "enter
+// this text code" link on the QR Code page to see the raw OTP Secret.
+func (c *Client) Authenticate(username, password, otpseed string) error {
+	setPassword := func(values url.Values) {
+		values.Set("login", username)
+		values.Set("password", password)
+	}
+	resp, err := fetchAndSubmitForm(c.Client, "https://github.com/login", setPassword)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("received %v response submitting login form", resp.StatusCode)
+	}
+
+	if otpseed == "" {
+		return nil
+	}
+
+	setOTP := func(values url.Values) {
+		otp := gotp.NewDefaultTOTP(strings.ToUpper(otpseed)).Now()
+		values.Set("otp", otp)
+	}
+	resp, err = fetchAndSubmitForm(c.Client, "https://github.com/sessions/two-factor", setOTP)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("received %v response submitting otp form", resp.StatusCode)
+	}
+
+	return nil
+}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..d89f367e
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,69 @@
+on: [push, pull_request]
+name: tests
+env:
+  GO111MODULE: on
+
+jobs:
+  linux:
+    strategy:
+      matrix:
+        go-version: [1.x, 1.12.x]
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/setup-go@v1
+      with:
+        go-version: ${{ matrix.go-version }}
+    - uses: actions/checkout@v1
+
+    - name: Cache go modules
+      uses: actions/cache@preview
+      with:
+        path: ~/go/pkg/mod
+        key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
+        restore-keys: ${{ runner.os }}-go-
+
+    - name: Run go fmt
+      run: diff -u <(echo -n) <(gofmt -d -s .)
+
+    - name: Ensure go generate produces a zero diff
+      run:  go generate -x ./... && git diff --exit-code; code=$?; git checkout -- .; (exit $code)
+
+    - name: Run go vet
+      run: go vet ./...
+
+    - name: Run go test
+      run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
+
+    - name: Ensure integration tests build
+      # don't actually run tests since they hit live GitHub API
+      run: go test -v -tags=integration -run=^$ ./test/integration
+
+    - name: Run scrape tests
+      run: cd scrape && go test ./...
+
+    - name: Upload coverage to Codecov
+      env:
+        CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+      run: bash <(curl -s https://codecov.io/bash)
+
+  # On Windows, just run the local tests.  Don't bother with checking gofmt, go
+  # vet, or uploading results to Codecov
+  windows:
+    strategy:
+      matrix:
+        go-version: [1.x, 1.12.x]
+    runs-on: windows-latest
+
+    steps:
+    - uses: actions/setup-go@v1
+      with:
+        go-version: ${{ matrix.go-version }}
+    - uses: actions/checkout@v1
+    - name: Cache go modules
+      uses: actions/cache@preview
+      with:
+        path: ~/go/pkg/mod
+        key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
+        restore-keys: ${{ runner.os }}-go-
+    - run: go test ./...
diff --git a/github/apps_manifest_test.go b/github/apps_manifest_test.go
new file mode 100644
index 00000000..92a2be9d
--- /dev/null
+++ b/github/apps_manifest_test.go
@@ -0,0 +1,53 @@
+// Copyright 2019 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+)
+
+const (
+	manifestJSON = `{
+	"id": 1,
+  "client_id": "a" ,
+  "client_secret": "b",
+  "webhook_secret": "c",
+  "pem": "key"
+}
+`
+)
+
+func TestGetConfig(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/app-manifests/code/conversions", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		testHeader(t, r, "Accept", mediaTypeAppManifestPreview)
+		fmt.Fprint(w, manifestJSON)
+	})
+
+	cfg, _, err := client.Apps.CompleteAppManifest(context.Background(), "code")
+	if err != nil {
+		t.Errorf("AppManifest.GetConfig returned error: %v", err)
+	}
+
+	want := &AppConfig{
+		ID:            Int64(1),
+		ClientID:      String("a"),
+		ClientSecret:  String("b"),
+		WebhookSecret: String("c"),
+		PEM:           String("key"),
+	}
+
+	if !reflect.DeepEqual(cfg, want) {
+		t.Errorf("GetConfig returned %+v, want %+v", cfg, want)
+	}
+}
diff --git a/github/checks_test.go b/github/checks_test.go
index 848c7e7d..e510e32f 100644
--- a/github/checks_test.go
+++ b/github/checks_test.go
@@ -102,11 +102,10 @@ func TestChecksService_CreateCheckRun(t *testing.T) {
 	})
 	startedAt, _ := time.Parse(time.RFC3339, "2018-05-04T01:14:52Z")
 	checkRunOpt := CreateCheckRunOptions{
-		HeadBranch: "master",
-		Name:       "testCreateCheckRun",
-		HeadSHA:    "deadbeef",
-		Status:     String("in_progress"),
-		StartedAt:  &Timestamp{startedAt},
+		Name:      "testCreateCheckRun",
+		HeadSHA:   "deadbeef",
+		Status:    String("in_progress"),
+		StartedAt: &Timestamp{startedAt},
 		Output: &CheckRunOutput{
 			Title:   String("Mighty test report"),
 			Summary: String(""),
@@ -200,7 +199,6 @@ func TestChecksService_UpdateCheckRun(t *testing.T) {
 	})
 	startedAt, _ := time.Parse(time.RFC3339, "2018-05-04T01:14:52Z")
 	updateCheckRunOpt := UpdateCheckRunOptions{
-		HeadBranch:  String("master"),
 		Name:        "testUpdateCheckRun",
 		HeadSHA:     String("deadbeef"),
 		Status:      String("completed"),
diff --git a/github/git_commits_test.go b/github/git_commits_test.go
index f316f01e..6e858baf 100644
--- a/github/git_commits_test.go
+++ b/github/git_commits_test.go
@@ -18,6 +18,106 @@ import (
 	"golang.org/x/crypto/openpgp"
 )
 
+func TestCommit_Marshal(t *testing.T) {
+	testJSONMarshal(t, &Commit{}, "{}")
+
+	u := &Commit{
+		SHA: String("s"),
+		Author: &CommitAuthor{
+			Date:  &referenceTime,
+			Name:  String("n"),
+			Email: String("e"),
+			Login: String("u"),
+		},
+		Committer: &CommitAuthor{
+			Date:  &referenceTime,
+			Name:  String("n"),
+			Email: String("e"),
+			Login: String("u"),
+		},
+		Message: String("m"),
+		Tree: &Tree{
+			SHA: String("s"),
+			Entries: []TreeEntry{{
+				SHA:     String("s"),
+				Path:    String("p"),
+				Mode:    String("m"),
+				Type:    String("t"),
+				Size:    Int(1),
+				Content: String("c"),
+				URL:     String("u"),
+			}},
+			Truncated: Bool(false),
+		},
+		Parents: nil,
+		Stats: &CommitStats{
+			Additions: Int(1),
+			Deletions: Int(1),
+			Total:     Int(1),
+		},
+		HTMLURL: String("h"),
+		URL:     String("u"),
+		Verification: &SignatureVerification{
+			Verified:  Bool(false),
+			Reason:    String("r"),
+			Signature: String("s"),
+			Payload:   String("p"),
+		},
+		NodeID:       String("n"),
+		CommentCount: Int(1),
+		SigningKey:   &openpgp.Entity{},
+	}
+
+	want := `{
+		"sha": "s",
+		"author": {
+			"date": ` + referenceTimeStr + `,
+			"name": "n",
+			"email": "e",
+			"username": "u"
+		},
+		"committer": {
+			"date": ` + referenceTimeStr + `,
+			"name": "n",
+			"email": "e",
+			"username": "u"
+		},
+		"message": "m",
+		"tree": {
+			"sha": "s",
+			"tree": [
+				{
+					"sha": "s",
+					"path": "p",
+					"mode": "m",
+					"type": "t",
+					"size": 1,
+					"content": "c",
+					"url": "u"
+				}
+			],
+			"truncated": false
+		},
+		"stats": {
+			"additions": 1,
+			"deletions": 1,
+			"total": 1
+		},
+		"html_url": "h",
+		"url": "u",
+		"verification": {
+			"verified": false,
+			"reason": "r",
+			"signature": "s",
+			"payload": "p"
+		},
+		"node_id": "n",
+		"comment_count": 1
+	}`
+
+	testJSONMarshal(t, u, want)
+}
+
 func TestGitService_GetCommit(t *testing.T) {
 	client, mux, _, teardown := setup()
 	defer teardown()
diff --git a/github/github-stringify_test.go b/github/github-stringify_test.go
index 22e1aafb..28ac8125 100644
--- a/github/github-stringify_test.go
+++ b/github/github-stringify_test.go
@@ -307,6 +307,25 @@ func TestDraftReviewComment_String(t *testing.T) {
 	}
 }
 
+func TestEnterprise_String(t *testing.T) {
+	v := Enterprise{
+		ID:          Int(0),
+		Slug:        String(""),
+		Name:        String(""),
+		NodeID:      String(""),
+		AvatarURL:   String(""),
+		Description: String(""),
+		WebsiteURL:  String(""),
+		HTMLURL:     String(""),
+		CreatedAt:   &Timestamp{},
+		UpdatedAt:   &Timestamp{},
+	}
+	want := `github.Enterprise{ID:0, Slug:"", Name:"", NodeID:"", AvatarURL:"", Description:"", WebsiteURL:"", HTMLURL:"", CreatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, UpdatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}}`
+	if got := v.String(); got != want {
+		t.Errorf("Enterprise.String = %v, want %v", got, want)
+	}
+}
+
 func TestEvent_String(t *testing.T) {
 	v := Event{
 		Type:   String(""),
@@ -852,8 +871,10 @@ func TestPlan_String(t *testing.T) {
 		Space:         Int(0),
 		Collaborators: Int(0),
 		PrivateRepos:  Int(0),
+		FilledSeats:   Int(0),
+		Seats:         Int(0),
 	}
-	want := `github.Plan{Name:"", Space:0, Collaborators:0, PrivateRepos:0}`
+	want := `github.Plan{Name:"", Space:0, Collaborators:0, PrivateRepos:0, FilledSeats:0, Seats:0}`
 	if got := v.String(); got != want {
 		t.Errorf("Plan.String = %v, want %v", got, want)
 	}
diff --git a/github/issues_comments_test.go b/github/issues_comments_test.go
index 6911a798..69acb242 100644
--- a/github/issues_comments_test.go
+++ b/github/issues_comments_test.go
@@ -31,10 +31,11 @@ func TestIssuesService_ListComments_allIssues(t *testing.T) {
 		fmt.Fprint(w, `[{"id":1}]`)
 	})
 
+	since := time.Date(2002, time.February, 10, 15, 30, 0, 0, time.UTC)
 	opt := &IssueListCommentsOptions{
-		Sort:        "updated",
-		Direction:   "desc",
-		Since:       time.Date(2002, time.February, 10, 15, 30, 0, 0, time.UTC),
+		Sort:        String("updated"),
+		Direction:   String("desc"),
+		Since:       &since,
 		ListOptions: ListOptions{Page: 2},
 	}
 	comments, _, err := client.Issues.ListComments(context.Background(), "o", "r", 0, opt)
diff --git a/github/messages_test.go b/github/messages_test.go
index 105904c6..30f7d95f 100644
--- a/github/messages_test.go
+++ b/github/messages_test.go
@@ -339,6 +339,10 @@ func TestParseWebHook(t *testing.T) {
 			payload:     &TeamAddEvent{},
 			messageType: "team_add",
 		},
+		{
+			payload:     &UserEvent{},
+			messageType: "user",
+		},
 		{
 			payload:     &WatchEvent{},
 			messageType: "watch",
diff --git a/github/orgs_test.go b/github/orgs_test.go
index 3b938fc9..9383bf11 100644
--- a/github/orgs_test.go
+++ b/github/orgs_test.go
@@ -172,3 +172,61 @@ func TestOrganizationsService_Edit_invalidOrg(t *testing.T) {
 	_, _, err := client.Organizations.Edit(context.Background(), "%", nil)
 	testURLParseError(t, err)
 }
+
+func TestOrganizationsService_ListInstallations(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/orgs/o/installations", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testHeader(t, r, "Accept", mediaTypeIntegrationPreview)
+		fmt.Fprint(w, `{"total_count": 1, "installations": [{ "id": 1, "app_id": 5}]}`)
+	})
+
+	apps, _, err := client.Organizations.ListInstallations(context.Background(), "o", nil)
+	if err != nil {
+		t.Errorf("Organizations.ListInstallations returned error: %v", err)
+	}
+
+	want := &OrganizationInstallations{TotalCount: Int(1), Installations: []*Installation{{ID: Int64(1), AppID: Int64(5)}}}
+	if !reflect.DeepEqual(apps, want) {
+		t.Errorf("Organizations.ListInstallations returned %+v, want %+v", apps, want)
+	}
+}
+
+func TestOrganizationsService_ListInstallations_invalidOrg(t *testing.T) {
+	client, _, _, teardown := setup()
+	defer teardown()
+
+	_, _, err := client.Organizations.ListInstallations(context.Background(), "%", nil)
+	testURLParseError(t, err)
+
+}
+
+func TestOrganizationsService_ListInstallations_withListOptions(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/orgs/o/installations", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testHeader(t, r, "Accept", mediaTypeIntegrationPreview)
+		testFormValues(t, r, values{"page": "2"})
+		fmt.Fprint(w, `{"total_count": 2, "installations": [{ "id": 2, "app_id": 10}]}`)
+	})
+
+	apps, _, err := client.Organizations.ListInstallations(context.Background(), "o", &ListOptions{Page: 2})
+	if err != nil {
+		t.Errorf("Organizations.ListInstallations returned error: %v", err)
+	}
+
+	want := &OrganizationInstallations{TotalCount: Int(2), Installations: []*Installation{{ID: Int64(2), AppID: Int64(10)}}}
+	if !reflect.DeepEqual(apps, want) {
+		t.Errorf("Organizations.ListInstallations returned %+v, want %+v", apps, want)
+	}
+
+	// Test ListOptions failure
+	_, _, err = client.Organizations.ListInstallations(context.Background(), "%", &ListOptions{})
+	if err == nil {
+		t.Error("Organizations.ListInstallations returned error: nil")
+	}
+}
diff --git a/github/pulls_reviews_test.go b/github/pulls_reviews_test.go
index 3b78bc9e..25aaca1c 100644
--- a/github/pulls_reviews_test.go
+++ b/github/pulls_reviews_test.go
@@ -14,6 +14,88 @@ import (
 	"testing"
 )
 
+func TestReviewers_marshall(t *testing.T) {
+	testJSONMarshal(t, &Reviewers{}, "{}")
+
+	u := &Reviewers{
+		Users: []*User{{
+			Login:       String("l"),
+			ID:          Int64(1),
+			AvatarURL:   String("a"),
+			GravatarID:  String("g"),
+			Name:        String("n"),
+			Company:     String("c"),
+			Blog:        String("b"),
+			Location:    String("l"),
+			Email:       String("e"),
+			Hireable:    Bool(true),
+			PublicRepos: Int(1),
+			Followers:   Int(1),
+			Following:   Int(1),
+			CreatedAt:   &Timestamp{referenceTime},
+			URL:         String("u"),
+		}},
+		Teams: []*Team{{
+			ID:              Int64(1),
+			NodeID:          String("node"),
+			Name:            String("n"),
+			Description:     String("d"),
+			URL:             String("u"),
+			Slug:            String("s"),
+			Permission:      String("p"),
+			Privacy:         String("priv"),
+			MembersCount:    Int(1),
+			ReposCount:      Int(1),
+			Organization:    nil,
+			MembersURL:      String("m"),
+			RepositoriesURL: String("r"),
+			Parent:          nil,
+			LDAPDN:          String("l"),
+		}},
+	}
+
+	want := `{
+		"users" : [
+			{
+				"login": "l",
+				"id": 1,
+				"avatar_url": "a",
+				"gravatar_id": "g",
+				"name": "n",
+				"company": "c",
+				"blog": "b",
+				"location": "l",
+				"email": "e",
+				"hireable": true,
+				"public_repos": 1,
+				"followers": 1,
+				"following": 1,
+				"created_at": ` + referenceTimeStr + `,
+				"url": "u"
+			}
+		], 
+		"teams" : [
+			{
+				"id": 1,
+				"node_id": "node",
+				"name": "n",
+				"description": "d",
+				"url": "u",
+				"slug": "s",
+				"permission": "p",
+				"privacy": "priv",
+				"members_count": 1,
+				"repos_count": 1,
+				"members_url": "m",
+				"repositories_url": "r",
+				"ldap_dn": "l"
+			}
+		]
+	}`
+
+	testJSONMarshal(t, u, want)
+}
+
 func TestPullRequestsService_ListReviews(t *testing.T) {
 	client, mux, _, teardown := setup()
 	defer teardown()
diff --git a/github/reactions_test.go b/github/reactions_test.go
index e1baec3f..ef1660d9 100644
--- a/github/reactions_test.go
+++ b/github/reactions_test.go
@@ -12,6 +12,53 @@ import (
 	"testing"
 )
 
+func TestReaction_Marshal(t *testing.T) {
+	testJSONMarshal(t, &Reaction{}, "{}")
+
+	r := &Reaction{
+		ID:      Int64(1),
+		User:    nil,
+		NodeID:  String("n"),
+		Content: String("+1"),
+	}
+
+	want := `{
+		"id": 1,
+		"node_id": "n",
+		"content": "+1"
+	}`
+
+	testJSONMarshal(t, r, want)
+}
+
+func TestReactions_Marshal(t *testing.T) {
+	testJSONMarshal(t, &Reactions{}, "{}")
+
+	r := &Reactions{
+		TotalCount: Int(1),
+		PlusOne:    Int(1),
+		MinusOne:   Int(1),
+		Laugh:      Int(1),
+		Confused:   Int(1),
+		Heart:      Int(1),
+		Hooray:     Int(1),
+		URL:        String("u"),
+	}
+
+	want := `{
+		"total_count": 1,
+		"+1": 1,
+		"-1": 1,
+		"laugh": 1,
+		"confused": 1,
+		"heart": 1,
+		"hooray": 1,
+		"url": "u"
+	}`
+
+	testJSONMarshal(t, r, want)
+}
+
 func TestReactionsService_ListCommentReactions(t *testing.T) {
 	client, mux, _, teardown := setup()
 	defer teardown()
diff --git a/github/repos_contents_test.go b/github/repos_contents_test.go
index 29bab8d2..f1225258 100644
--- a/github/repos_contents_test.go
+++ b/github/repos_contents_test.go
@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"reflect"
 	"testing"
 )
@@ -375,7 +376,46 @@ func TestRepositoriesService_GetArchiveLink(t *testing.T) {
 		testMethod(t, r, "GET")
 		http.Redirect(w, r, "http://github.com/a", http.StatusFound)
 	})
-	url, resp, err := client.Repositories.GetArchiveLink(context.Background(), "o", "r", Tarball, &RepositoryContentGetOptions{})
+	url, resp, err := client.Repositories.GetArchiveLink(context.Background(), "o", "r", Tarball, &RepositoryContentGetOptions{}, true)
+	if err != nil {
+		t.Errorf("Repositories.GetArchiveLink returned error: %v", err)
+	}
+	if resp.StatusCode != http.StatusFound {
+		t.Errorf("Repositories.GetArchiveLink returned status: %d, want %d", resp.StatusCode, http.StatusFound)
+	}
+	want := "http://github.com/a"
+	if url.String() != want {
+		t.Errorf("Repositories.GetArchiveLink returned %+v, want %+v", url.String(), want)
+	}
+}
+
+func TestRepositoriesService_GetArchiveLink_StatusMovedPermanently_dontFollowRedirects(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+	mux.HandleFunc("/repos/o/r/tarball", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		http.Redirect(w, r, "http://github.com/a", http.StatusMovedPermanently)
+	})
+	_, resp, _ := client.Repositories.GetArchiveLink(context.Background(), "o", "r", Tarball, &RepositoryContentGetOptions{}, false)
+	if resp.StatusCode != http.StatusMovedPermanently {
+		t.Errorf("Repositories.GetArchiveLink returned status: %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
+	}
+}
+
+func TestRepositoriesService_GetArchiveLink_StatusMovedPermanently_followRedirects(t *testing.T) {
+	client, mux, serverURL, teardown := setup()
+	defer teardown()
+	// Mock a redirect link, which leads to an archive link
+	mux.HandleFunc("/repos/o/r/tarball", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		redirectURL, _ := url.Parse(serverURL + baseURLPath + "/redirect")
+		http.Redirect(w, r, redirectURL.String(), http.StatusMovedPermanently)
+	})
+	mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		http.Redirect(w, r, "http://github.com/a", http.StatusFound)
+	})
+	url, resp, err := client.Repositories.GetArchiveLink(context.Background(), "o", "r", Tarball, &RepositoryContentGetOptions{}, true)
 	if err != nil {
 		t.Errorf("Repositories.GetArchiveLink returned error: %v", err)
 	}
diff --git a/github/repos_pages_test.go b/github/repos_pages_test.go
index 62d42470..ec77f717 100644
--- a/github/repos_pages_test.go
+++ b/github/repos_pages_test.go
@@ -10,7 +10,6 @@ import (
 	"fmt"
 	"net/http"
 	"reflect"
-	"strings"
 	"testing"
 )
 
@@ -20,8 +19,7 @@ func TestRepositoriesService_EnablePages(t *testing.T) {
 
 	mux.HandleFunc("/repos/o/r/pages", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "POST")
-		wantAcceptHeaders := []string{mediaTypeEnablePagesAPIPreview, mediaTypePagesPreview}
-		testHeader(t, r, "Accept", strings.Join(wantAcceptHeaders, ", "))
+		testHeader(t, r, "Accept", mediaTypeEnablePagesAPIPreview)
 		fmt.Fprint(w, `{"url":"u","status":"s","cname":"c","custom_404":false,"html_url":"h", "source": {"branch":"master", "path":"/"}}`)
 	})
 
@@ -58,7 +56,6 @@ func TestRepositoriesService_GetPagesInfo(t *testing.T) {
 
 	mux.HandleFunc("/repos/o/r/pages", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "GET")
-		testHeader(t, r, "Accept", mediaTypePagesPreview)
 		fmt.Fprint(w, `{"url":"u","status":"s","cname":"c","custom_404":false,"html_url":"h"}`)
 	})
 
@@ -157,7 +154,6 @@ func TestRepositoriesService_RequestPageBuild(t *testing.T) {
 
 	mux.HandleFunc("/repos/o/r/pages/builds", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "POST")
-		testHeader(t, r, "Accept", mediaTypePagesPreview)
 		fmt.Fprint(w, `{"url":"u","status":"s"}`)
 	})
 
diff --git a/github/repos_test.go b/github/repos_test.go
index 5ed6682c..d0fdc563 100644
--- a/github/repos_test.go
+++ b/github/repos_test.go
@@ -907,7 +907,7 @@ func TestRepositoriesService_GetBranchProtection(t *testing.T) {
 		},
 		RequiredPullRequestReviews: &PullRequestReviewsEnforcement{
 			DismissStaleReviews: true,
-			DismissalRestrictions: DismissalRestrictions{
+			DismissalRestrictions: &DismissalRestrictions{
 				Users: []*User{
 					{Login: String("u"), ID: Int64(3)},
 				},
@@ -936,6 +936,70 @@ func TestRepositoriesService_GetBranchProtection(t *testing.T) {
 	}
 }
 
+func TestRepositoriesService_GetBranchProtection_noDismissalRestrictions(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/branches/b/protection", func(w http.ResponseWriter, r *http.Request) {
+
+		testMethod(t, r, "GET")
+		// TODO: remove custom Accept header when this API fully launches
+		testHeader(t, r, "Accept", mediaTypeRequiredApprovingReviewsPreview)
+		fmt.Fprintf(w, `{
+				"required_status_checks":{
+					"strict":true,
+					"contexts":["continuous-integration"]
+				},
+				"required_pull_request_reviews":{
+					"dismiss_stale_reviews":true,
+					"require_code_owner_reviews":true,
+					"required_approving_review_count":1
+					},
+					"enforce_admins":{
+						"url":"/repos/o/r/branches/b/protection/enforce_admins",
+						"enabled":true
+					},
+					"restrictions":{
+						"users":[{"id":1,"login":"u"}],
+						"teams":[{"id":2,"slug":"t"}]
+					}
+				}`)
+	})
+
+	protection, _, err := client.Repositories.GetBranchProtection(context.Background(), "o", "r", "b")
+	if err != nil {
+		t.Errorf("Repositories.GetBranchProtection returned error: %v", err)
+	}
+
+	want := &Protection{
+		RequiredStatusChecks: &RequiredStatusChecks{
+			Strict:   true,
+			Contexts: []string{"continuous-integration"},
+		},
+		RequiredPullRequestReviews: &PullRequestReviewsEnforcement{
+			DismissStaleReviews:          true,
+			DismissalRestrictions:        nil,
+			RequireCodeOwnerReviews:      true,
+			RequiredApprovingReviewCount: 1,
+		},
+		EnforceAdmins: &AdminEnforcement{
+			URL:     String("/repos/o/r/branches/b/protection/enforce_admins"),
+			Enabled: true,
+		},
+		Restrictions: &BranchRestrictions{
+			Users: []*User{
+				{Login: String("u"), ID: Int64(1)},
+			},
+			Teams: []*Team{
+				{Slug: String("t"), ID: Int64(2)},
+			},
+		},
+	}
+	if !reflect.DeepEqual(protection, want) {
+		t.Errorf("Repositories.GetBranchProtection returned %+v, want %+v", protection, want)
+	}
+}
+
 func TestRepositoriesService_UpdateBranchProtection(t *testing.T) {
 	client, mux, _, teardown := setup()
 	defer teardown()
@@ -1007,7 +1071,7 @@ func TestRepositoriesService_UpdateBranchProtection(t *testing.T) {
 		},
 		RequiredPullRequestReviews: &PullRequestReviewsEnforcement{
 			DismissStaleReviews: true,
-			DismissalRestrictions: DismissalRestrictions{
+			DismissalRestrictions: &DismissalRestrictions{
 				Users: []*User{
 					{Login: String("uu"), ID: Int64(3)},
 				},
@@ -1201,7 +1265,7 @@ func TestRepositoriesService_GetPullRequestReviewEnforcement(t *testing.T) {
 
 	want := &PullRequestReviewsEnforcement{
 		DismissStaleReviews: true,
-		DismissalRestrictions: DismissalRestrictions{
+		DismissalRestrictions: &DismissalRestrictions{
 			Users: []*User{
 				{Login: String("u"), ID: Int64(1)},
 			},
@@ -1257,7 +1321,7 @@ func TestRepositoriesService_UpdatePullRequestReviewEnforcement(t *testing.T) {
 
 	want := &PullRequestReviewsEnforcement{
 		DismissStaleReviews: true,
-		DismissalRestrictions: DismissalRestrictions{
+		DismissalRestrictions: &DismissalRestrictions{
 			Users: []*User{
 				{Login: String("u"), ID: Int64(1)},
 			},
@@ -1281,8 +1345,8 @@ func TestRepositoriesService_DisableDismissalRestrictions(t *testing.T) {
 		testMethod(t, r, "PATCH")
 		// TODO: remove custom Accept header when this API fully launches
 		testHeader(t, r, "Accept", mediaTypeRequiredApprovingReviewsPreview)
-		testBody(t, r, `{"dismissal_restrictions":[]}`+"\n")
-		fmt.Fprintf(w, `{"dismissal_restrictions":{"users":[],"teams":[]},"dismiss_stale_reviews":true,"require_code_owner_reviews":true,"required_approving_review_count":1}`)
+		testBody(t, r, `{"dismissal_restrictions":{}}`+"\n")
+		fmt.Fprintf(w, `{"dismiss_stale_reviews":true,"require_code_owner_reviews":true,"required_approving_review_count":1}`)
 	})
 
 	enforcement, _, err := client.Repositories.DisableDismissalRestrictions(context.Background(), "o", "r", "b")
@@ -1291,11 +1355,8 @@ func TestRepositoriesService_DisableDismissalRestrictions(t *testing.T) {
 	}
 
 	want := &PullRequestReviewsEnforcement{
-		DismissStaleReviews: true,
-		DismissalRestrictions: DismissalRestrictions{
-			Users: []*User{},
-			Teams: []*Team{},
-		},
+		DismissStaleReviews:          true,
+		DismissalRestrictions:        nil,
 		RequireCodeOwnerReviews:      true,
 		RequiredApprovingReviewCount: 1,
 	}
diff --git a/github/search_test.go b/github/search_test.go
index 4b686884..7b607569 100644
--- a/github/search_test.go
+++ b/github/search_test.go
@@ -47,6 +47,37 @@ func TestSearchService_Repositories(t *testing.T) {
 	}
 }
 
+func TestSearchService_Topics(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/search/topics", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testFormValues(t, r, values{
+			"q":        "blah",
+			"page":     "2",
+			"per_page": "2",
+		})
+
+		fmt.Fprint(w, `{"total_count": 4, "incomplete_results": false, "items": [{"name":"blah"},{"name":"blahblah"}]}`)
+	})
+
+	opts := &SearchOptions{ListOptions: ListOptions{Page: 2, PerPage: 2}}
+	result, _, err := client.Search.Topics(context.Background(), "blah", opts)
+	if err != nil {
+		t.Errorf("Search.Topics returned error: %v", err)
+	}
+
+	want := &TopicsSearchResult{
+		Total:             Int(4),
+		IncompleteResults: Bool(false),
+		Topics:            []*TopicResult{{Name: String("blah")}, {Name: String("blahblah")}},
+	}
+	if !reflect.DeepEqual(result, want) {
+		t.Errorf("Search.Topics returned %+v, want %+v", result, want)
+	}
+}
+
 func TestSearchService_Commits(t *testing.T) {
 	client, mux, _, teardown := setup()
 	defer teardown()
diff --git a/github/teams_test.go b/github/teams_test.go
index 6210bb59..97dc325d 100644
--- a/github/teams_test.go
+++ b/github/teams_test.go
@@ -608,7 +608,6 @@ func TestTeamsService_ListIDPGroupsInOrganization(t *testing.T) {
 
 	mux.HandleFunc("/orgs/o/team-sync/groups", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "GET")
-		testHeader(t, r, "Accept", mediaTypeTeamSyncPreview)
 		testFormValues(t, r, values{
 			"page": "2",
 		})
@@ -641,7 +640,6 @@ func TestTeamsService_ListIDPGroupsForTeam(t *testing.T) {
 
 	mux.HandleFunc("/teams/1/team-sync/group-mappings", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "GET")
-		testHeader(t, r, "Accept", mediaTypeTeamSyncPreview)
 		fmt.Fprint(w, `{"groups": [{"group_id": "1",  "group_name": "n", "group_description": "d"}]}`)
 	})
 
@@ -670,7 +668,6 @@ func TestTeamsService_CreateOrUpdateIDPGroupConnections(t *testing.T) {
 
 	mux.HandleFunc("/teams/1/team-sync/group-mappings", func(w http.ResponseWriter, r *http.Request) {
 		testMethod(t, r, "PATCH")
-		testHeader(t, r, "Accept", mediaTypeTeamSyncPreview)
 		fmt.Fprint(w, `{"groups": [{"group_id": "1",  "group_name": "n", "group_description": "d"}]}`)
 	})
 
diff --git a/github/users_projects_test.go b/github/users_projects_test.go
new file mode 100644
index 00000000..b186fbf0
--- /dev/null
+++ b/github/users_projects_test.go
@@ -0,0 +1,68 @@
+// Copyright 2019 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+)
+
+func TestUsersService_ListProjects(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/users/u/projects", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testHeader(t, r, "Accept", mediaTypeProjectsPreview)
+		testFormValues(t, r, values{"state": "open", "page": "2"})
+		fmt.Fprint(w, `[{"id":1}]`)
+	})
+
+	opt := &ProjectListOptions{State: "open", ListOptions: ListOptions{Page: 2}}
+	projects, _, err := client.Users.ListProjects(context.Background(), "u", opt)
+	if err != nil {
+		t.Errorf("Users.ListProjects returned error: %v", err)
+	}
+
+	want := []*Project{{ID: Int64(1)}}
+	if !reflect.DeepEqual(projects, want) {
+		t.Errorf("Users.ListProjects returned %+v, want %+v", projects, want)
+	}
+}
+
+func TestUsersService_CreateProject(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	input := &CreateUserProjectOptions{Name: "Project Name", Body: String("Project body.")}
+
+	mux.HandleFunc("/users/projects", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		testHeader(t, r, "Accept", mediaTypeProjectsPreview)
+
+		v := &CreateUserProjectOptions{}
+		json.NewDecoder(r.Body).Decode(v)
+		if !reflect.DeepEqual(v, input) {
+			t.Errorf("Request body = %+v, want %+v", v, input)
+		}
+
+		fmt.Fprint(w, `{"id":1}`)
+	})
+
+	project, _, err := client.Users.CreateProject(context.Background(), input)
+	if err != nil {
+		t.Errorf("Users.CreateProject returned error: %v", err)
+	}
+
+	want := &Project{ID: Int64(1)}
+	if !reflect.DeepEqual(project, want) {
+		t.Errorf("Users.CreateProject returned %+v, want %+v", project, want)
+	}
+}
diff --git a/scrape/apps_test.go b/scrape/apps_test.go
new file mode 100644
index 00000000..b895b667
--- /dev/null
+++ b/scrape/apps_test.go
@@ -0,0 +1,105 @@
+package scrape
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-github/v28/github"
+)
+
+func Test_AppRestrictionsEnabled(t *testing.T) {
+	tests := []struct {
+		description string
+		testFile    string
+		org         string
+		want        bool
+	}{
+		{
+			description: "return true for enabled orgs",
+			testFile:    "access-restrictions-enabled.html",
+			want:        true,
+		},
+		{
+			description: "return false for disabled orgs",
+			testFile:    "access-restrictions-disabled.html",
+			want:        false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.description, func(t *testing.T) {
+			client, mux, cleanup := setup()
+			defer cleanup()
+
+			mux.HandleFunc("/organizations/o/settings/oauth_application_policy", func(w http.ResponseWriter, r *http.Request) {
+				copyTestFile(w, tt.testFile)
+			})
+
+			got, err := client.AppRestrictionsEnabled("o")
+			if err != nil {
+				t.Errorf("AppRestrictionsEnabled returned err: %v", err)
+			}
+			if want := tt.want; got != want {
+				t.Errorf("AppRestrictionsEnabled returned %t, want %t", got, want)
+			}
+
+		})
+	}
+}
+
+func Test_ListOAuthApps(t *testing.T) {
+	client, mux, cleanup := setup()
+	defer cleanup()
+
+	mux.HandleFunc("/organizations/e/settings/oauth_application_policy", func(w http.ResponseWriter, r *http.Request) {
+		copyTestFile(w, "access-restrictions-enabled.html")
+	})
+
+	got, err := client.ListOAuthApps("e")
+	if err != nil {
+		t.Errorf("ListOAuthApps(e) returned err: %v", err)
+	}
+	want := []OAuthApp{
+		{
+			ID:          22222,
+			Name:        "Coveralls",
+			Description: "Test coverage history and statistics.",
+			State:       OAuthAppRequested,
+			RequestedBy: "willnorris",
+		},
+		{
+			ID:    530107,
+			Name:  "Google Cloud Platform",
+			State: OAuthAppApproved,
+		},
+		{
+			ID:          231424,
+			Name:        "GitKraken",
+			Description: "An intuitive, cross-platform Git client that doesn't suck, built by @axosoft and made with @nodegit & @ElectronJS.",
+			State:       OAuthAppDenied,
+		},
+	}
+	if !cmp.Equal(got, want) {
+		t.Errorf("ListOAuthApps(o) returned %v, want %v", got, want)
+	}
+
+}
+
+func Test_CreateApp(t *testing.T) {
+	client, mux, cleanup := setup()
+	defer cleanup()
+
+	mux.HandleFunc("/apps/settings/new", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	if _, err := client.CreateApp(&AppManifest{
+		URL: github.String("https://example.com"),
+		HookAttributes: map[string]string{
+			"url": "https://example.com/hook",
+		},
+	}); err != nil {
+		t.Fatalf("CreateApp: %v", err)
+	}
+}
diff --git a/scrape/forms_test.go b/scrape/forms_test.go
new file mode 100644
index 00000000..f45010cf
--- /dev/null
+++ b/scrape/forms_test.go
@@ -0,0 +1,78 @@
+package scrape
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/net/html"
+)
+
+func Test_ParseForms(t *testing.T) {
+	tests := []struct {
+		description string
+		html        string
+		forms       []htmlForm
+	}{
+		{"no forms", `<html></html>`, nil},
+		{"empty form", `<html><form></form></html>`, []htmlForm{{Values: url.Values{}}}},
+		{
+			"single form with one value",
+			`<html><form action="a" method="m"><input name="n1" value="v1"></form></html>`,
+			[]htmlForm{{Action: "a", Method: "m", Values: url.Values{"n1": {"v1"}}}},
+		},
+		{
+			"two forms",
+			`<html>
+			  <form action="a1" method="m1"><input name="n1" value="v1"></form>
+			  <form action="a2" method="m2"><input name="n2" value="v2"></form>
+			</html>`,
+			[]htmlForm{
+				{Action: "a1", Method: "m1", Values: url.Values{"n1": {"v1"}}},
+				{Action: "a2", Method: "m2", Values: url.Values{"n2": {"v2"}}},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.description, func(t *testing.T) {
+			node, err := html.Parse(strings.NewReader(tt.html))
+			if err != nil {
+				t.Errorf("error parsing html: %v", err)
+			}
+			if got, want := parseForms(node), tt.forms; !cmp.Equal(got, want) {
+				t.Errorf("parseForms(%q) returned %+v, want %+v", tt.html, got, want)
+			}
+		})
+	}
+}
+
+func Test_FetchAndSumbitForm(t *testing.T) {
+	client, mux, cleanup := setup()
+	defer cleanup()
+	var submitted bool
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, `<html><form action="/submit">
+		  <input type="hidden" name="hidden" value="h">
+		  <input type="text" name="name">
+		</form></html>`)
+	})
+	mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
+		r.ParseForm()
+		want := url.Values{"hidden": {"h"}, "name": {"n"}}
+		if got := r.Form; !cmp.Equal(got, want) {
+			t.Errorf("submitted form contained values %v, want %v", got, want)
+		}
+		submitted = true
+	})
+
+	setValues := func(values url.Values) { values.Set("name", "n") }
+	fetchAndSubmitForm(client.Client, client.baseURL.String()+"/", setValues)
+	if !submitted {
+		t.Error("form was never submitted")
+	}
+}
diff --git a/scrape/scrape_test.go b/scrape/scrape_test.go
new file mode 100644
index 00000000..74e867b6
--- /dev/null
+++ b/scrape/scrape_test.go
@@ -0,0 +1,33 @@
+package scrape
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+)
+
+// setup a test HTTP server along with a scrape.Client that is configured to
+// talk to that test server. Tests should register handlers on the mux which
+// provide mock responses for the GitHub pages being tested.
+func setup() (client *Client, mux *http.ServeMux, cleanup func()) {
+	mux = http.NewServeMux()
+	server := httptest.NewServer(mux)
+
+	client = NewClient(nil)
+	client.baseURL, _ = url.Parse(server.URL + "/")
+
+	return client, mux, server.Close
+}
+
+func copyTestFile(w io.Writer, filename string) error {
+	f, err := os.Open("testdata/" + filename)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	_, err = io.Copy(w, f)
+	return err
+}
diff --git a/scrape/testdata/access-restrictions-disabled.html b/scrape/testdata/access-restrictions-disabled.html
new file mode 100644
index 00000000..97e81339
--- /dev/null
+++ b/scrape/testdata/access-restrictions-disabled.html
@@ -0,0 +1,39 @@
+<!-- fetched from https://github.com/organizations/google-test3/settings/oauth_application_policy on 2019-10-15
+     Some extraneous markup removed to keep the file size smaller -->
+<html lang="en">
+  <body class="logged-in env-production min-width-lg">
+    
+  <div class="application-main " data-commit-hovercards-enabled>
+      <main id="js-pjax-container" data-pjax-container>
+        
+<div itemscope itemtype="http://schema.org/Organization">
+
+  <div class="container-lg px-3 d-flex">
+
+    <div class="col-9">
+      
+  <div class="boxed-group oauth-application-whitelist ">
+    <h3>Third-party application access policy</h3>
+    <div class="boxed-group-inner">
+        <p>
+          Policy: <strong>No restrictions</strong> <svg class="octicon octicon-alert" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"/></svg>
+        </p>
+        <p>
+          All applications authorized by organization members have access to <strong>google-test</strong>’s data.
+        </p>
+        <a class="btn" href="/organizations/google-test/settings/oauth_application_policy/confirm">Setup application access restrictions</a>
+      <p class="help">
+        <svg class="octicon octicon-question" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M6 10h2v2H6v-2zm4-3.5C10 8.64 8 9 8 9H6c0-.55.45-1 1-1h.5c.28 0 .5-.22.5-.5v-1c0-.28-.22-.5-.5-.5h-1c-.28 0-.5.22-.5.5V7H4c0-1.5 1.5-3 3-3s3 1 3 2.5zM7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7z"/></svg>
+        When authorized, applications can act on behalf of organization members. Your access policy determines which applications can access data in your organization. <a href="https://help.github.com/articles/about-third-party-application-restrictions/">Read more about third-party access and organizations.</a>
+      </p>
+    </div>
+  </div>
+
+    </div>
+  </div>
+</div>
+
+      </main>
+  </div>
+  </body>
+</html>
diff --git a/scrape/testdata/access-restrictions-enabled.html b/scrape/testdata/access-restrictions-enabled.html
new file mode 100644
index 00000000..39ef4b4c
--- /dev/null
+++ b/scrape/testdata/access-restrictions-enabled.html
@@ -0,0 +1,117 @@
+<!-- fetched from https://github.com/organizations/google-test3/settings/oauth_application_policy on 2019-10-15
+     Some extraneous markup removed to keep the file size smaller -->
+<html lang="en">
+  <body class="logged-in env-production min-width-lg">
+    
+  <div class="application-main " data-commit-hovercards-enabled>
+      <main id="js-pjax-container" data-pjax-container>
+        
+
+
+<div itemscope itemtype="http://schema.org/Organization">
+
+  <div class="container-lg px-3 d-flex">
+
+    <div class="col-9">
+      
+  <div class="boxed-group oauth-application-whitelist is-selectable">
+    <h3>Third-party application access policy</h3>
+    <div class="boxed-group-inner">
+        <p>
+          Policy: <strong>Access restricted</strong> <svg class="octicon octicon-check" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M12 5l-8 8-4-4 1.5-1.5L4 10l6.5-6.5L12 5z"/></svg>
+        </p>
+        <p>
+          Only approved applications can access data in this organization. Applications owned by <strong>google-test</strong> always have access.
+        </p>
+        
+<details
+  class="details-reset details-overlay details-overlay-dark lh-default text-gray-dark d-inline-block text-left"
+  >
+  <summary
+      class="btn btn-danger"
+     
+      
+      
+      
+      >
+    
+    Remove restrictions
+  </summary>
+  <details-dialog
+    aria-label="Are you sure?"
+    class="Box Box--overlay d-flex flex-column anim-fade-in fast "
+    
+    >
+    <div class="Box-header">
+      <button class="Box-btn-octicon btn-octicon float-right" type="button" aria-label="Close dialog" data-close-dialog>
+        <svg class="octicon octicon-x" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z"/></svg>
+      </button>
+      <h3 class="Box-title ">Are you sure?</h3>
+    </div>
+    
+          <div class="flash flash-full flash-warn">
+            You’re about to remove all third-party application restrictions. Please read this carefully.
+          </div>
+          <div class="Box-body">
+            <p>Removing third-party application restrictions will immediately give member authorized applications access to private data in the <strong>google-test</strong> organization.</p>
+            <p class="mb-0">Please be sure you want to do this.</p>
+          </div>
+          <div class="Box-footer">
+            <!-- '"` --><!-- </textarea></xmp> --></option></form><form action="/orgs/google-test/application_access" accept-charset="UTF-8" method="post"><input name="utf8" type="hidden" value="&#x2713;" /><input type="hidden" name="_method" value="put" /><input type="hidden" name="authenticity_token" value="S2KgMWzbT4gdPkJs7vxVqfwsObkhjk+bto0ulPixFALoIG2N9rlX7UhmoSm/H6uk9VIccrTHnr7WTBOFtlTogQ==" />
+              <input type="hidden" name="restrict_access" id="restrict_access" value="off" class="form-control" />
+              <button type="submit" class="btn btn-block btn-danger">
+                Yes, remove application restrictions
+              </button>
+</form>          </div>
+
+  </details-dialog>
+</details>
+          <ul class="boxed-group-list">
+            <li>
+              <div class="float-right request-indicator">
+                Approval requested by <a class="requestor" href="/willnorris">willnorris</a> &#8212; 
+                <a href="/orgs/google-test/policies/applications/22222">Review</a>
+              </div>
+              <img class="avatar float-left" src="https://avatars3.githubusercontent.com/oa/22222?s=40&amp;u=90a5890234f8061be4c6c0b29b4fa7594f2ba2d7&amp;v=4" height="20" width="20" alt="" />
+              <span class="request-info js-details-container Details">
+                <strong title="Coveralls">Coveralls</strong> <span class="hidden-text-expander inline"><button type="button" class="ellipsis-expander js-details-target" aria-expanded="false">&hellip;</button></span> <span class="application-description">Test coverage history and statistics.</span>
+              </span>
+            </li>
+            <li>
+              <span class="float-right request-indicator">
+                <span class="approved-request">
+                  <svg class="octicon octicon-check" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M12 5l-8 8-4-4 1.5-1.5L4 10l6.5-6.5L12 5z"/></svg> Approved</span> &#8212; <a class="edit-link" href="/orgs/google-test/policies/applications/530107">
+                    <svg aria-label="Update policy" class="octicon octicon-pencil" viewBox="0 0 14 16" version="1.1" width="14" height="16" role="img"><path fill-rule="evenodd" d="M0 12v3h3l8-8-3-3-8 8zm3 2H1v-2h1v1h1v1zm10.3-9.3L12 6 9 3l1.3-1.3a.996.996 0 0 1 1.41 0l1.59 1.59c.39.39.39 1.02 0 1.41z"/></svg>
+</a>              </span>
+              <img class="avatar float-left" src="https://avatars0.githubusercontent.com/oa/530107?s=40&amp;u=c2e8a853e3ef18a65cc0d3628cb3d1c12363ea90&amp;v=4" height="20" width="20" alt="" />
+              <span class="request-info js-details-container Details">
+                <strong title="Google Cloud Platform">Google Cloud Platform</strong>
+              </span>
+            </li>
+            <li>
+              <span class="float-right request-indicator">
+                <span class="denied-request">
+                  <svg class="octicon octicon-x" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z"/></svg> Denied</span> &#8212; <a class="edit-link" href="/orgs/google-test/policies/applications/231424">
+                    <svg aria-label="Update policy" class="octicon octicon-pencil" viewBox="0 0 14 16" version="1.1" width="14" height="16" role="img"><path fill-rule="evenodd" d="M0 12v3h3l8-8-3-3-8 8zm3 2H1v-2h1v1h1v1zm10.3-9.3L12 6 9 3l1.3-1.3a.996.996 0 0 1 1.41 0l1.59 1.59c.39.39.39 1.02 0 1.41z"/></svg>
+</a>              </span>
+              <img class="avatar float-left" src="https://avatars2.githubusercontent.com/oa/231424?s=40&amp;u=136599829eeacb9107cf422a684d11c5757c0eee&amp;v=4" height="20" width="20" alt="" />
+              <span class="request-info js-details-container Details">
+                <strong title="GitKraken">GitKraken</strong> <span class="hidden-text-expander inline"><button type="button" class="ellipsis-expander js-details-target" aria-expanded="false">&hellip;</button></span> <span class="application-description">An intuitive, cross-platform Git client that doesn&#39;t suck, built by @axosoft and made with @nodegit &amp; @ElectronJS.</span>
+              </span>
+            </li>
+          </ul>
+      <p class="help">
+        <svg class="octicon octicon-question" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M6 10h2v2H6v-2zm4-3.5C10 8.64 8 9 8 9H6c0-.55.45-1 1-1h.5c.28 0 .5-.22.5-.5v-1c0-.28-.22-.5-.5-.5h-1c-.28 0-.5.22-.5.5V7H4c0-1.5 1.5-3 3-3s3 1 3 2.5zM7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7z"/></svg>
+        When authorized, applications can act on behalf of organization members. Your access policy determines which applications can access data in your organization. <a href="https://help.github.com/articles/about-third-party-application-restrictions/">Read more about third-party access and organizations.</a>
+      </p>
+    </div>
+  </div>
+
+    </div>
+  </div>
+</div>
+
+      </main>
+  </div>
+  </body>
+</html>