Repository: google/go-github
Pull request #1446 (2020-03-01 06:01:55)

Implement support for actions runners
This PR adds support for the GitHub Actions [Runners API](https://developer.github.com/v3/actions/self_hosted_runners/).

Relates to: #1399

For the [list self hosted runners](https://developer.github.com/v3/actions/self_hosted_runners/#list-self-hosted-runners-for-a-repository) endpoint I sent an email to support regarding the example showing the array nested two levels deep which doesn't match the actual responses I was getting, and inquiring about if they intended to include a `total_count` field like most other endpoints that support pagination.

One of the things I'm not sure about is the return and struct field types, specifically which ones should be references (e.g. `*[]Type` or `*[]*Type` for a list return type, or something else?)


Patch:
diff --git a/github/actions_runners.go b/github/actions_runners.go
new file mode 100644
index 00000000..bab39631
--- /dev/null
+++ b/github/actions_runners.go
@@ -0,0 +1,155 @@
+// Copyright 2020 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"
+)
+
+// RunnerApplicationDownload represents a binary for the self-hosted runner application that can be downloaded.
+type RunnerApplicationDownload struct {
+	OS           *string `json:"os,omitempty"`
+	Architecture *string `json:"architecture,omitempty"`
+	DownloadURL  *string `json:"download_url,omitempty"`
+	Filename     *string `json:"filename,omitempty"`
+}
+
+// ListRunnerApplicationDownloads lists self-hosted runner application binaries that can be downloaded and run.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#list-downloads-for-the-self-hosted-runner-application
+func (s *ActionsService) ListRunnerApplicationDownloads(ctx context.Context, owner, repo string) ([]*RunnerApplicationDownload, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners/downloads", owner, repo)
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var rads []*RunnerApplicationDownload
+	resp, err := s.client.Do(ctx, req, &rads)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return rads, resp, nil
+}
+
+// RegistrationToken represents a token that can be used to add a self-hosted runner to a repository.
+type RegistrationToken struct {
+	Token     *string    `json:"token,omitempty"`
+	ExpiresAt *Timestamp `json:"expires_at,omitempty"`
+}
+
+// CreateRegistrationToken creates a token that can be used to add a self-hosted runner.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#create-a-registration-token
+func (s *ActionsService) CreateRegistrationToken(ctx context.Context, owner, repo string) (*RegistrationToken, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners/registration-token", owner, repo)
+
+	req, err := s.client.NewRequest("POST", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	registrationToken := new(RegistrationToken)
+	resp, err := s.client.Do(ctx, req, registrationToken)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return registrationToken, resp, nil
+}
+
+// Runner represents a self-hosted runner registered with a repository.
+type Runner struct {
+	ID     *int64  `json:"id,omitempty"`
+	Name   *string `json:"name,omitempty"`
+	OS     *string `json:"os,omitempty"`
+	Status *string `json:"status,omitempty"`
+}
+
+// ListRunners lists all the self-hosted runners for a repository.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#list-self-hosted-runners-for-a-repository
+func (s *ActionsService) ListRunners(ctx context.Context, owner, repo string, opts *ListOptions) ([]*Runner, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners", owner, repo)
+	u, err := addOptions(u, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var runners []*Runner
+	resp, err := s.client.Do(ctx, req, &runners)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return runners, resp, nil
+}
+
+// GetRunner gets a specific self-hosted runner for a repository using its runner ID.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#get-a-self-hosted-runner
+func (s *ActionsService) GetRunner(ctx context.Context, owner, repo string, runnerID int64) (*Runner, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners/%v", owner, repo, runnerID)
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	runner := new(Runner)
+	resp, err := s.client.Do(ctx, req, runner)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return runner, resp, nil
+}
+
+// RemoveToken represents a token that can be used to remove a self-hosted runner from a repository.
+type RemoveToken struct {
+	Token     *string    `json:"token,omitempty"`
+	ExpiresAt *Timestamp `json:"expires_at,omitempty"`
+}
+
+// CreateRemoveToken creates a token that can be used to remove a self-hosted runner from a repository.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#create-a-remove-token
+func (s *ActionsService) CreateRemoveToken(ctx context.Context, owner, repo string) (*RemoveToken, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners/remove-token", owner, repo)
+
+	req, err := s.client.NewRequest("POST", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	removeToken := new(RemoveToken)
+	resp, err := s.client.Do(ctx, req, removeToken)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return removeToken, resp, nil
+}
+
+// RemoveRunner forces the removal of a self-hosted runner in a repository using the runner id.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#remove-a-self-hosted-runner
+func (s *ActionsService) RemoveRunner(ctx context.Context, owner, repo string, runnerID int64) (*Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runners/%v", owner, repo, runnerID)
+
+	req, err := s.client.NewRequest("DELETE", u, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.client.Do(ctx, req, nil)
+}
diff --git a/github/actions_workflow_jobs.go b/github/actions_workflow_jobs.go
index d9adad7d..e3593b6a 100644
--- a/github/actions_workflow_jobs.go
+++ b/github/actions_workflow_jobs.go
@@ -96,7 +96,7 @@ func (s *ActionsService) GetWorkflowJobByID(ctx context.Context, owner, repo str
 func (s *ActionsService) GetWorkflowJobLogs(ctx context.Context, owner, repo string, jobID int64, followRedirects bool) (*url.URL, *Response, error) {
 	u := fmt.Sprintf("repos/%v/%v/actions/jobs/%v/logs", owner, repo, jobID)
 
-	resp, err := s.getWorkflowJobLogsFromURL(ctx, u, followRedirects)
+	resp, err := s.getWorkflowLogsFromURL(ctx, u, followRedirects)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -108,7 +108,7 @@ func (s *ActionsService) GetWorkflowJobLogs(ctx context.Context, owner, repo str
 	return parsedURL, newResponse(resp), err
 }
 
-func (s *ActionsService) getWorkflowJobLogsFromURL(ctx context.Context, u string, followRedirects bool) (*http.Response, error) {
+func (s *ActionsService) getWorkflowLogsFromURL(ctx context.Context, u string, followRedirects bool) (*http.Response, error) {
 	req, err := s.client.NewRequest("GET", u, nil)
 	if err != nil {
 		return nil, err
@@ -130,7 +130,7 @@ func (s *ActionsService) getWorkflowJobLogsFromURL(ctx context.Context, u string
 	// If redirect response is returned, follow it
 	if followRedirects && resp.StatusCode == http.StatusMovedPermanently {
 		u = resp.Header.Get("Location")
-		resp, err = s.getWorkflowJobLogsFromURL(ctx, u, false)
+		resp, err = s.getWorkflowLogsFromURL(ctx, u, false)
 	}
 	return resp, err
 
diff --git a/github/actions_workflow_runs.go b/github/actions_workflow_runs.go
new file mode 100644
index 00000000..b365bb1c
--- /dev/null
+++ b/github/actions_workflow_runs.go
@@ -0,0 +1,181 @@
+// Copyright 2020 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"
+	"net/url"
+)
+
+// WorkflowRun represents a repository action workflow run.
+type WorkflowRun struct {
+	ID             *int64         `json:"id,omitempty"`
+	NodeID         *string        `json:"node_id,omitempty"`
+	HeadBranch     *string        `json:"head_branch,omitempty"`
+	HeadSHA        *string        `json:"head_sha,omitempty"`
+	RunNumber      *int           `json:"run_number,omitempty"`
+	Event          *string        `json:"event,omitempty"`
+	Status         *string        `json:"status,omitempty"`
+	Conclusion     *string        `json:"conclusion,omitempty"`
+	URL            *string        `json:"url,omitempty"`
+	HTMLURL        *string        `json:"html_url,omitempty"`
+	PullRequests   []*PullRequest `json:"pull_requests,omitempty"`
+	CreatedAt      *Timestamp     `json:"created_at,omitempty"`
+	UpdatedAt      *Timestamp     `json:"updated_at,omitempty"`
+	JobsURL        *string        `json:"jobs_url,omitempty"`
+	LogsURL        *string        `json:"logs_url,omitempty"`
+	CheckSuiteURL  *string        `json:"check_suite_url,omitempty"`
+	ArtifactsURL   *string        `json:"artifacts_url,omitempty"`
+	CancelURL      *string        `json:"cancel_url,omitempty"`
+	RerunURL       *string        `json:"rerun_url,omitempty"`
+	HeadCommit     *HeadCommit    `json:"head_commit,omitempty"`
+	WorkflowURL    *string        `json:"workflow_url,omitempty"`
+	Repository     *Repository    `json:"repository,omitempty"`
+	HeadRepository *Repository    `json:"head_repository,omitempty"`
+}
+
+// WorkflowRuns represents a slice of repository action workflow run.
+type WorkflowRuns struct {
+	TotalCount   *int           `json:"total_count,omitempty"`
+	WorkflowRuns []*WorkflowRun `json:"workflow_runs,omitempty"`
+}
+
+// ListWorkflowRunsOptions specifies optional parameters to ListWorkflowRuns.
+type ListWorkflowRunsOptions struct {
+	Actor  string `url:"actor,omitempty"`
+	Branch string `url:"branch,omitempty"`
+	Event  string `url:"event,omitempty"`
+	Status string `url:"status,omitempty"`
+	ListOptions
+}
+
+func (s *ActionsService) listWorkflowRuns(ctx context.Context, endpoint string, opts *ListWorkflowRunsOptions) (*WorkflowRuns, *Response, error) {
+	u, err := addOptions(endpoint, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	runs := new(WorkflowRuns)
+	resp, err := s.client.Do(ctx, req, &runs)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return runs, resp, nil
+}
+
+// ListWorkflowRunsByID lists all workflow runs by workflow ID.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#list-workflow-runs
+func (s *ActionsService) ListWorkflowRunsByID(ctx context.Context, owner, repo string, workflowID int64, opts *ListWorkflowRunsOptions) (*WorkflowRuns, *Response, error) {
+	u := fmt.Sprintf("repos/%s/%s/actions/workflows/%v/runs", owner, repo, workflowID)
+	return s.listWorkflowRuns(ctx, u, opts)
+}
+
+// ListWorkflowRunsByFileName lists all workflow runs by workflow file name.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#list-workflow-runs
+func (s *ActionsService) ListWorkflowRunsByFileName(ctx context.Context, owner, repo, workflowFileName string, opts *ListWorkflowRunsOptions) (*WorkflowRuns, *Response, error) {
+	u := fmt.Sprintf("repos/%s/%s/actions/workflows/%v/runs", owner, repo, workflowFileName)
+	return s.listWorkflowRuns(ctx, u, opts)
+}
+
+// ListRepositoryWorkflowRuns lists all workflow runs for a repository.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#list-repository-workflow-runs
+func (s *ActionsService) ListRepositoryWorkflowRuns(ctx context.Context, owner, repo string, opts *ListOptions) (*WorkflowRuns, *Response, error) {
+	u := fmt.Sprintf("repos/%s/%s/actions/runs", owner, repo)
+	u, err := addOptions(u, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	runs := new(WorkflowRuns)
+	resp, err := s.client.Do(ctx, req, &runs)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return runs, resp, nil
+}
+
+// GetWorkflowRunByID gets a specific workflow run by ID.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#get-a-workflow-run
+func (s *ActionsService) GetWorkflowRunByID(ctx context.Context, owner, repo string, runID int64) (*WorkflowRun, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runs/%v", owner, repo, runID)
+
+	req, err := s.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	run := new(WorkflowRun)
+	resp, err := s.client.Do(ctx, req, run)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return run, resp, nil
+}
+
+// RerunWorkflow re-runs a workflow by ID.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#re-run-a-workflow
+func (s *ActionsService) RerunWorkflowByID(ctx context.Context, owner, repo string, runID int64) (*Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runs/%v/rerun", owner, repo, runID)
+
+	req, err := s.client.NewRequest("POST", u, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.client.Do(ctx, req, nil)
+}
+
+// CancelWorkflowRunByID cancels a workflow run by ID.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#cancel-a-workflow-run
+func (s *ActionsService) CancelWorkflowRunByID(ctx context.Context, owner, repo string, runID int64) (*Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runs/%v/cancel", owner, repo, runID)
+
+	req, err := s.client.NewRequest("POST", u, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.client.Do(ctx, req, nil)
+}
+
+// GetWorkflowRunLogs gets a redirect URL to download a plain text file of logs for a workflow run.
+//
+// GitHub API docs: https://developer.github.com/v3/actions/workflow_runs/#list-workflow-run-logs
+func (s *ActionsService) GetWorkflowRunLogs(ctx context.Context, owner, repo string, runID int64, followRedirects bool) (*url.URL, *Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/actions/runs/%v/logs", owner, repo, runID)
+
+	resp, err := s.getWorkflowLogsFromURL(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
+}
diff --git a/github/event_types.go b/github/event_types.go
index bf145de0..2c18de6c 100644
--- a/github/event_types.go
+++ b/github/event_types.go
@@ -649,13 +649,13 @@ type PullRequestReviewCommentEvent struct {
 //
 // GitHub API docs: https://developer.github.com/v3/activity/events/types/#pushevent
 type PushEvent struct {
-	PushID       *int64             `json:"push_id,omitempty"`
-	Head         *string            `json:"head,omitempty"`
-	Ref          *string            `json:"ref,omitempty"`
-	Size         *int               `json:"size,omitempty"`
-	Commits      []*PushEventCommit `json:"commits,omitempty"`
-	Before       *string            `json:"before,omitempty"`
-	DistinctSize *int               `json:"distinct_size,omitempty"`
+	PushID       *int64        `json:"push_id,omitempty"`
+	Head         *string       `json:"head,omitempty"`
+	Ref          *string       `json:"ref,omitempty"`
+	Size         *int          `json:"size,omitempty"`
+	Commits      []*HeadCommit `json:"commits,omitempty"`
+	Before       *string       `json:"before,omitempty"`
+	DistinctSize *int          `json:"distinct_size,omitempty"`
 
 	// The following fields are only populated by Webhook events.
 	After        *string              `json:"after,omitempty"`
@@ -665,7 +665,7 @@ type PushEvent struct {
 	BaseRef      *string              `json:"base_ref,omitempty"`
 	Compare      *string              `json:"compare,omitempty"`
 	Repo         *PushEventRepository `json:"repository,omitempty"`
-	HeadCommit   *PushEventCommit     `json:"head_commit,omitempty"`
+	HeadCommit   *HeadCommit          `json:"head_commit,omitempty"`
 	Pusher       *User                `json:"pusher,omitempty"`
 	Sender       *User                `json:"sender,omitempty"`
 	Installation *Installation        `json:"installation,omitempty"`
@@ -675,8 +675,8 @@ func (p PushEvent) String() string {
 	return Stringify(p)
 }
 
-// PushEventCommit represents a git commit in a GitHub PushEvent.
-type PushEventCommit struct {
+// HeadCommit represents a git commit in a GitHub PushEvent.
+type HeadCommit struct {
 	Message  *string       `json:"message,omitempty"`
 	Author   *CommitAuthor `json:"author,omitempty"`
 	URL      *string       `json:"url,omitempty"`
@@ -695,7 +695,7 @@ type PushEventCommit struct {
 	Modified  []string      `json:"modified,omitempty"`
 }
 
-func (p PushEventCommit) String() string {
+func (p HeadCommit) String() string {
 	return Stringify(p)
 }
 
diff --git a/github/github-accessors.go b/github/github-accessors.go
index 2ad2af18..8598de67 100644
--- a/github/github-accessors.go
+++ b/github/github-accessors.go
@@ -3628,6 +3628,78 @@ func (g *Grant) GetURL() string {
 	return *g.URL
 }
 
+// GetAuthor returns the Author field.
+func (h *HeadCommit) GetAuthor() *CommitAuthor {
+	if h == nil {
+		return nil
+	}
+	return h.Author
+}
+
+// GetCommitter returns the Committer field.
+func (h *HeadCommit) GetCommitter() *CommitAuthor {
+	if h == nil {
+		return nil
+	}
+	return h.Committer
+}
+
+// GetDistinct returns the Distinct field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetDistinct() bool {
+	if h == nil || h.Distinct == nil {
+		return false
+	}
+	return *h.Distinct
+}
+
+// GetID returns the ID field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetID() string {
+	if h == nil || h.ID == nil {
+		return ""
+	}
+	return *h.ID
+}
+
+// GetMessage returns the Message field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetMessage() string {
+	if h == nil || h.Message == nil {
+		return ""
+	}
+	return *h.Message
+}
+
+// GetSHA returns the SHA field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetSHA() string {
+	if h == nil || h.SHA == nil {
+		return ""
+	}
+	return *h.SHA
+}
+
+// GetTimestamp returns the Timestamp field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetTimestamp() Timestamp {
+	if h == nil || h.Timestamp == nil {
+		return Timestamp{}
+	}
+	return *h.Timestamp
+}
+
+// GetTreeID returns the TreeID field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetTreeID() string {
+	if h == nil || h.TreeID == nil {
+		return ""
+	}
+	return *h.TreeID
+}
+
+// GetURL returns the URL field if it's non-nil, zero value otherwise.
+func (h *HeadCommit) GetURL() string {
+	if h == nil || h.URL == nil {
+		return ""
+	}
+	return *h.URL
+}
+
 // GetActive returns the Active field if it's non-nil, zero value otherwise.
 func (h *Hook) GetActive() bool {
 	if h == nil || h.Active == nil {
@@ -9197,7 +9269,7 @@ func (p *PushEvent) GetHead() string {
 }
 
 // GetHeadCommit returns the HeadCommit field.
-func (p *PushEvent) GetHeadCommit() *PushEventCommit {
+func (p *PushEvent) GetHeadCommit() *HeadCommit {
 	if p == nil {
 		return nil
 	}
@@ -9260,78 +9332,6 @@ func (p *PushEvent) GetSize() int {
 	return *p.Size
 }
 
-// GetAuthor returns the Author field.
-func (p *PushEventCommit) GetAuthor() *CommitAuthor {
-	if p == nil {
-		return nil
-	}
-	return p.Author
-}
-
-// GetCommitter returns the Committer field.
-func (p *PushEventCommit) GetCommitter() *CommitAuthor {
-	if p == nil {
-		return nil
-	}
-	return p.Committer
-}
-
-// GetDistinct returns the Distinct field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetDistinct() bool {
-	if p == nil || p.Distinct == nil {
-		return false
-	}
-	return *p.Distinct
-}
-
-// GetID returns the ID field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetID() string {
-	if p == nil || p.ID == nil {
-		return ""
-	}
-	return *p.ID
-}
-
-// GetMessage returns the Message field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetMessage() string {
-	if p == nil || p.Message == nil {
-		return ""
-	}
-	return *p.Message
-}
-
-// GetSHA returns the SHA field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetSHA() string {
-	if p == nil || p.SHA == nil {
-		return ""
-	}
-	return *p.SHA
-}
-
-// GetTimestamp returns the Timestamp field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetTimestamp() Timestamp {
-	if p == nil || p.Timestamp == nil {
-		return Timestamp{}
-	}
-	return *p.Timestamp
-}
-
-// GetTreeID returns the TreeID field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetTreeID() string {
-	if p == nil || p.TreeID == nil {
-		return ""
-	}
-	return *p.TreeID
-}
-
-// GetURL returns the URL field if it's non-nil, zero value otherwise.
-func (p *PushEventCommit) GetURL() string {
-	if p == nil || p.URL == nil {
-		return ""
-	}
-	return *p.URL
-}
-
 // GetEmail returns the Email field if it's non-nil, zero value otherwise.
 func (p *PushEventRepoOwner) GetEmail() string {
 	if p == nil || p.Email == nil {
@@ -9780,6 +9780,22 @@ func (r *Reference) GetURL() string {
 	return *r.URL
 }
 
+// GetExpiresAt returns the ExpiresAt field if it's non-nil, zero value otherwise.
+func (r *RegistrationToken) GetExpiresAt() Timestamp {
+	if r == nil || r.ExpiresAt == nil {
+		return Timestamp{}
+	}
+	return *r.ExpiresAt
+}
+
+// GetToken returns the Token field if it's non-nil, zero value otherwise.
+func (r *RegistrationToken) GetToken() string {
+	if r == nil || r.Token == nil {
+		return ""
+	}
+	return *r.Token
+}
+
 // GetBrowserDownloadURL returns the BrowserDownloadURL field if it's non-nil, zero value otherwise.
 func (r *ReleaseAsset) GetBrowserDownloadURL() string {
 	if r == nil || r.BrowserDownloadURL == nil {
@@ -9924,6 +9940,22 @@ func (r *ReleaseEvent) GetSender() *User {
 	return r.Sender
 }
 
+// GetExpiresAt returns the ExpiresAt field if it's non-nil, zero value otherwise.
+func (r *RemoveToken) GetExpiresAt() Timestamp {
+	if r == nil || r.ExpiresAt == nil {
+		return Timestamp{}
+	}
+	return *r.ExpiresAt
+}
+
+// GetToken returns the Token field if it's non-nil, zero value otherwise.
+func (r *RemoveToken) GetToken() string {
+	if r == nil || r.Token == nil {
+		return ""
+	}
+	return *r.Token
+}
+
 // GetFrom returns the From field if it's non-nil, zero value otherwise.
 func (r *Rename) GetFrom() string {
 	if r == nil || r.From == nil {
@@ -11572,6 +11604,70 @@ func (r *ReviewersRequest) GetNodeID() string {
 	return *r.NodeID
 }
 
+// GetID returns the ID field if it's non-nil, zero value otherwise.
+func (r *Runner) GetID() int64 {
+	if r == nil || r.ID == nil {
+		return 0
+	}
+	return *r.ID
+}
+
+// GetName returns the Name field if it's non-nil, zero value otherwise.
+func (r *Runner) GetName() string {
+	if r == nil || r.Name == nil {
+		return ""
+	}
+	return *r.Name
+}
+
+// GetOS returns the OS field if it's non-nil, zero value otherwise.
+func (r *Runner) GetOS() string {
+	if r == nil || r.OS == nil {
+		return ""
+	}
+	return *r.OS
+}
+
+// GetStatus returns the Status field if it's non-nil, zero value otherwise.
+func (r *Runner) GetStatus() string {
+	if r == nil || r.Status == nil {
+		return ""
+	}
+	return *r.Status
+}
+
+// GetArchitecture returns the Architecture field if it's non-nil, zero value otherwise.
+func (r *RunnerApplicationDownload) GetArchitecture() string {
+	if r == nil || r.Architecture == nil {
+		return ""
+	}
+	return *r.Architecture
+}
+
+// GetDownloadURL returns the DownloadURL field if it's non-nil, zero value otherwise.
+func (r *RunnerApplicationDownload) GetDownloadURL() string {
+	if r == nil || r.DownloadURL == nil {
+		return ""
+	}
+	return *r.DownloadURL
+}
+
+// GetFilename returns the Filename field if it's non-nil, zero value otherwise.
+func (r *RunnerApplicationDownload) GetFilename() string {
+	if r == nil || r.Filename == nil {
+		return ""
+	}
+	return *r.Filename
+}
+
+// GetOS returns the OS field if it's non-nil, zero value otherwise.
+func (r *RunnerApplicationDownload) GetOS() string {
+	if r == nil || r.OS == nil {
+		return ""
+	}
+	return *r.OS
+}
+
 // GetName returns the Name field if it's non-nil, zero value otherwise.
 func (s *ServiceHook) GetName() string {
 	if s == nil || s.Name == nil {
@@ -14164,6 +14260,190 @@ func (w *WorkflowJob) GetURL() string {
 	return *w.URL
 }
 
+// GetArtifactsURL returns the ArtifactsURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetArtifactsURL() string {
+	if w == nil || w.ArtifactsURL == nil {
+		return ""
+	}
+	return *w.ArtifactsURL
+}
+
+// GetCancelURL returns the CancelURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetCancelURL() string {
+	if w == nil || w.CancelURL == nil {
+		return ""
+	}
+	return *w.CancelURL
+}
+
+// GetCheckSuiteURL returns the CheckSuiteURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetCheckSuiteURL() string {
+	if w == nil || w.CheckSuiteURL == nil {
+		return ""
+	}
+	return *w.CheckSuiteURL
+}
+
+// GetConclusion returns the Conclusion field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetConclusion() string {
+	if w == nil || w.Conclusion == nil {
+		return ""
+	}
+	return *w.Conclusion
+}
+
+// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetCreatedAt() Timestamp {
+	if w == nil || w.CreatedAt == nil {
+		return Timestamp{}
+	}
+	return *w.CreatedAt
+}
+
+// GetEvent returns the Event field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetEvent() string {
+	if w == nil || w.Event == nil {
+		return ""
+	}
+	return *w.Event
+}
+
+// GetHeadBranch returns the HeadBranch field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetHeadBranch() string {
+	if w == nil || w.HeadBranch == nil {
+		return ""
+	}
+	return *w.HeadBranch
+}
+
+// GetHeadCommit returns the HeadCommit field.
+func (w *WorkflowRun) GetHeadCommit() *HeadCommit {
+	if w == nil {
+		return nil
+	}
+	return w.HeadCommit
+}
+
+// GetHeadRepository returns the HeadRepository field.
+func (w *WorkflowRun) GetHeadRepository() *Repository {
+	if w == nil {
+		return nil
+	}
+	return w.HeadRepository
+}
+
+// GetHeadSHA returns the HeadSHA field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetHeadSHA() string {
+	if w == nil || w.HeadSHA == nil {
+		return ""
+	}
+	return *w.HeadSHA
+}
+
+// GetHTMLURL returns the HTMLURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetHTMLURL() string {
+	if w == nil || w.HTMLURL == nil {
+		return ""
+	}
+	return *w.HTMLURL
+}
+
+// GetID returns the ID field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetID() int64 {
+	if w == nil || w.ID == nil {
+		return 0
+	}
+	return *w.ID
+}
+
+// GetJobsURL returns the JobsURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetJobsURL() string {
+	if w == nil || w.JobsURL == nil {
+		return ""
+	}
+	return *w.JobsURL
+}
+
+// GetLogsURL returns the LogsURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetLogsURL() string {
+	if w == nil || w.LogsURL == nil {
+		return ""
+	}
+	return *w.LogsURL
+}
+
+// GetNodeID returns the NodeID field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetNodeID() string {
+	if w == nil || w.NodeID == nil {
+		return ""
+	}
+	return *w.NodeID
+}
+
+// GetRepository returns the Repository field.
+func (w *WorkflowRun) GetRepository() *Repository {
+	if w == nil {
+		return nil
+	}
+	return w.Repository
+}
+
+// GetRerunURL returns the RerunURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetRerunURL() string {
+	if w == nil || w.RerunURL == nil {
+		return ""
+	}
+	return *w.RerunURL
+}
+
+// GetRunNumber returns the RunNumber field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetRunNumber() int {
+	if w == nil || w.RunNumber == nil {
+		return 0
+	}
+	return *w.RunNumber
+}
+
+// GetStatus returns the Status field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetStatus() string {
+	if w == nil || w.Status == nil {
+		return ""
+	}
+	return *w.Status
+}
+
+// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetUpdatedAt() Timestamp {
+	if w == nil || w.UpdatedAt == nil {
+		return Timestamp{}
+	}
+	return *w.UpdatedAt
+}
+
+// GetURL returns the URL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetURL() string {
+	if w == nil || w.URL == nil {
+		return ""
+	}
+	return *w.URL
+}
+
+// GetWorkflowURL returns the WorkflowURL field if it's non-nil, zero value otherwise.
+func (w *WorkflowRun) GetWorkflowURL() string {
+	if w == nil || w.WorkflowURL == nil {
+		return ""
+	}
+	return *w.WorkflowURL
+}
+
+// GetTotalCount returns the TotalCount field if it's non-nil, zero value otherwise.
+func (w *WorkflowRuns) GetTotalCount() int {
+	if w == nil || w.TotalCount == nil {
+		return 0
+	}
+	return *w.TotalCount
+}
+
 // GetTotalCount returns the TotalCount field if it's non-nil, zero value otherwise.
 func (w *Workflows) GetTotalCount() int {
 	if w == nil || w.TotalCount == nil {
diff --git a/github/pulls.go b/github/pulls.go
index bd799ced..00692088 100644
--- a/github/pulls.go
+++ b/github/pulls.go
@@ -449,7 +449,7 @@ type PullRequestOptions struct {
 }
 
 type pullRequestMergeRequest struct {
-	CommitMessage string `json:"commit_message"`
+	CommitMessage string `json:"commit_message,omitempty"`
 	CommitTitle   string `json:"commit_title,omitempty"`
 	MergeMethod   string `json:"merge_method,omitempty"`
 	SHA           string `json:"sha,omitempty"`
diff --git a/github/reactions.go b/github/reactions.go
index 43c863af..c2c9ced3 100644
--- a/github/reactions.go
+++ b/github/reactions.go
@@ -8,6 +8,7 @@ package github
 import (
 	"context"
 	"fmt"
+	"net/http"
 )
 
 // ReactionsService provides access to the reactions-related functions in the
@@ -109,6 +110,36 @@ func (s ReactionsService) CreateCommentReaction(ctx context.Context, owner, repo
 	return m, resp, nil
 }
 
+// DeleteCommentReaction deletes the reaction for a commit comment.
+//
+// GitHub API docs: https://developer.github.com/v3/reactions/#delete-a-commit-comment-reaction
+func (s *ReactionsService) DeleteCommentReaction(ctx context.Context, owner, repo string, commentID, reactionID int64) (*Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/comments/%v/reactions/%v", owner, repo, commentID, reactionID)
+
+	return s.deleteCommentReaction(ctx, u)
+}
+
+// DeleteCommentReactionByRepoID deletes the reaction for a commit comment by repository ID.
+//
+// GitHub API docs: https://developer.github.com/v3/reactions/#delete-a-commit-comment-reaction
+func (s *ReactionsService) DeleteCommentReactionByRepoID(ctx context.Context, repoID, commentID, reactionID int64) (*Response, error) {
+	u := fmt.Sprintf("repositories/%v/comments/%v/reactions/%v", repoID, commentID, reactionID)
+
+	return s.deleteCommentReaction(ctx, u)
+}
+
+func (s ReactionsService) deleteCommentReaction(ctx context.Context, url string) (*Response, error) {
+	req, err := s.client.NewRequest(http.MethodDelete, url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: remove custom Accept headers when APIs fully launch.
+	req.Header.Set("Accept", mediaTypeReactionsPreview)
+
+	return s.client.Do(ctx, req, nil)
+}
+
 // ListIssueReactions lists the reactions for an issue.
 //
 // GitHub API docs: https://developer.github.com/v3/reactions/#list-reactions-for-an-issue
diff --git a/scrape/forms.go b/scrape/forms.go
index ee559395..6e01171e 100644
--- a/scrape/forms.go
+++ b/scrape/forms.go
@@ -13,6 +13,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"github.com/PuerkitoBio/goquery"
 	"golang.org/x/net/html"
@@ -29,8 +30,8 @@ type htmlForm struct {
 }
 
 // parseForms parses and returns all form elements beneath node.  Form values
-// include all nested input elements within the form (textarea is not currently
-// supported).
+// include all input and textarea elements within the form. The values of radio
+// and checkbox inputs are included only if they are checked.
 //
 // In the future, we might want to allow a custom selector to be passed in to
 // further restrict what forms will be returned.
@@ -47,10 +48,28 @@ func parseForms(node *html.Node) (forms []htmlForm) {
 
 		s.Find("input").Each(func(_ int, s *goquery.Selection) {
 			name, _ := s.Attr("name")
+			if name == "" {
+				return
+			}
+
+			typ, _ := s.Attr("type")
+			typ = strings.ToLower(typ)
+			_, checked := s.Attr("checked")
+			if (typ == "radio" || typ == "checkbox") && !checked {
+				return
+			}
+
 			value, _ := s.Attr("value")
-			if name != "" {
-				form.Values.Add(name, value)
+			form.Values.Add(name, value)
+		})
+		s.Find("textarea").Each(func(_ int, s *goquery.Selection) {
+			name, _ := s.Attr("name")
+			if name == "" {
+				return
 			}
+
+			value := s.Text()
+			form.Values.Add(name, value)
 		})
 		forms = append(forms, form)
 	})
@@ -87,7 +106,9 @@ func fetchAndSubmitForm(client *http.Client, urlStr string, setValues func(url.V
 	actionURL = resp.Request.URL.ResolveReference(actionURL)
 
 	// allow caller to fill out the form
-	setValues(form.Values)
+	if setValues != nil {
+		setValues(form.Values)
+	}
 
 	resp, err = client.PostForm(actionURL.String(), form.Values)
 	if err != nil {


Test patch:
diff --git a/github/actions_runners_test.go b/github/actions_runners_test.go
new file mode 100644
index 00000000..6739b28a
--- /dev/null
+++ b/github/actions_runners_test.go
@@ -0,0 +1,147 @@
+// Copyright 2020 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"
+	"time"
+)
+
+func TestActionsService_ListRunnerApplicationDownloads(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners/downloads", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		fmt.Fprint(w, `[{"os":"osx","architecture":"x64","download_url":"https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-osx-x64-2.164.0.tar.gz","filename":"actions-runner-osx-x64-2.164.0.tar.gz"},{"os":"linux","architecture":"x64","download_url":"https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-x64-2.164.0.tar.gz","filename":"actions-runner-linux-x64-2.164.0.tar.gz"},{"os": "linux","architecture":"arm","download_url":"https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-arm-2.164.0.tar.gz","filename":"actions-runner-linux-arm-2.164.0.tar.gz"},{"os":"win","architecture":"x64","download_url":"https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-win-x64-2.164.0.zip","filename":"actions-runner-win-x64-2.164.0.zip"},{"os":"linux","architecture":"arm64","download_url":"https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-arm64-2.164.0.tar.gz","filename":"actions-runner-linux-arm64-2.164.0.tar.gz"}]`)
+	})
+
+	downloads, _, err := client.Actions.ListRunnerApplicationDownloads(context.Background(), "o", "r")
+	if err != nil {
+		t.Errorf("Actions.ListRunnerApplicationDownloads returned error: %v", err)
+	}
+
+	want := []*RunnerApplicationDownload{
+		{OS: String("osx"), Architecture: String("x64"), DownloadURL: String("https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-osx-x64-2.164.0.tar.gz"), Filename: String("actions-runner-osx-x64-2.164.0.tar.gz")},
+		{OS: String("linux"), Architecture: String("x64"), DownloadURL: String("https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-x64-2.164.0.tar.gz"), Filename: String("actions-runner-linux-x64-2.164.0.tar.gz")},
+		{OS: String("linux"), Architecture: String("arm"), DownloadURL: String("https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-arm-2.164.0.tar.gz"), Filename: String("actions-runner-linux-arm-2.164.0.tar.gz")},
+		{OS: String("win"), Architecture: String("x64"), DownloadURL: String("https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-win-x64-2.164.0.zip"), Filename: String("actions-runner-win-x64-2.164.0.zip")},
+		{OS: String("linux"), Architecture: String("arm64"), DownloadURL: String("https://github.com/actions/runner/releases/download/v2.164.0/actions-runner-linux-arm64-2.164.0.tar.gz"), Filename: String("actions-runner-linux-arm64-2.164.0.tar.gz")},
+	}
+	if !reflect.DeepEqual(downloads, want) {
+		t.Errorf("Actions.ListRunnerApplicationDownloads returned %+v, want %+v", downloads, want)
+	}
+}
+
+func TestActionsService_CreateRegistrationToken(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners/registration-token", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		fmt.Fprint(w, `{"token":"LLBF3JGZDX3P5PMEXLND6TS6FCWO6","expires_at":"2020-01-22T12:13:35.123Z"}`)
+	})
+
+	token, _, err := client.Actions.CreateRegistrationToken(context.Background(), "o", "r")
+	if err != nil {
+		t.Errorf("Actions.CreateRegistrationToken returned error: %v", err)
+	}
+
+	want := &RegistrationToken{Token: String("LLBF3JGZDX3P5PMEXLND6TS6FCWO6"),
+		ExpiresAt: &Timestamp{time.Date(2020, time.January, 22, 12, 13, 35,
+			123000000, time.UTC)}}
+	if !reflect.DeepEqual(token, want) {
+		t.Errorf("Actions.CreateRegistrationToken returned %+v, want %+v", token, want)
+	}
+}
+
+func TestActionsService_ListRunners(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testFormValues(t, r, values{"per_page": "2", "page": "2"})
+		fmt.Fprint(w, `[{"id":23,"name":"MBP","os":"macos","status":"online"},{"id":24,"name":"iMac","os":"macos","status":"offline"}]`)
+	})
+
+	opts := &ListOptions{Page: 2, PerPage: 2}
+	runners, _, err := client.Actions.ListRunners(context.Background(), "o", "r", opts)
+	if err != nil {
+		t.Errorf("Actions.ListRunners returned error: %v", err)
+	}
+
+	want := []*Runner{
+		{ID: Int64(23), Name: String("MBP"), OS: String("macos"), Status: String("online")},
+		{ID: Int64(24), Name: String("iMac"), OS: String("macos"), Status: String("offline")},
+	}
+	if !reflect.DeepEqual(runners, want) {
+		t.Errorf("Actions.ListRunners returned %+v, want %+v", runners, want)
+	}
+}
+
+func TestActionsService_GetRunner(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners/23", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		fmt.Fprint(w, `{"id":23,"name":"MBP","os":"macos","status":"online"}`)
+	})
+
+	runner, _, err := client.Actions.GetRunner(context.Background(), "o", "r", 23)
+	if err != nil {
+		t.Errorf("Actions.GetRunner returned error: %v", err)
+	}
+
+	want := &Runner{
+		ID:     Int64(23),
+		Name:   String("MBP"),
+		OS:     String("macos"),
+		Status: String("online"),
+	}
+	if !reflect.DeepEqual(runner, want) {
+		t.Errorf("Actions.GetRunner returned %+v, want %+v", runner, want)
+	}
+}
+
+func TestActionsService_CreateRemoveToken(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners/remove-token", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		fmt.Fprint(w, `{"token":"AABF3JGZDX3P5PMEXLND6TS6FCWO6","expires_at":"2020-01-29T12:13:35.123Z"}`)
+	})
+
+	token, _, err := client.Actions.CreateRemoveToken(context.Background(), "o", "r")
+	if err != nil {
+		t.Errorf("Actions.CreateRemoveToken returned error: %v", err)
+	}
+
+	want := &RemoveToken{Token: String("AABF3JGZDX3P5PMEXLND6TS6FCWO6"), ExpiresAt: &Timestamp{time.Date(2020, time.January, 29, 12, 13, 35, 123000000, time.UTC)}}
+	if !reflect.DeepEqual(token, want) {
+		t.Errorf("Actions.CreateRemoveToken returned %+v, want %+v", token, want)
+	}
+}
+
+func TestActionsService_RemoveRunner(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runners/21", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+	})
+
+	_, err := client.Actions.RemoveRunner(context.Background(), "o", "r", 21)
+	if err != nil {
+		t.Errorf("Actions.RemoveRunner returned error: %v", err)
+	}
+}
diff --git a/github/actions_workflow_runs_test.go b/github/actions_workflow_runs_test.go
new file mode 100644
index 00000000..b31ca18d
--- /dev/null
+++ b/github/actions_workflow_runs_test.go
@@ -0,0 +1,202 @@
+// Copyright 2020 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"
+	"net/url"
+	"reflect"
+	"testing"
+	"time"
+)
+
+func TestActionsService_ListWorkflowRunsByID(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/workflows/29679449/runs", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testFormValues(t, r, values{"per_page": "2", "page": "2"})
+		fmt.Fprint(w, `{"total_count":4,"workflow_runs":[{"id":399444496,"run_number":296,"created_at":"2019-01-02T15:04:05Z","updated_at":"2020-01-02T15:04:05Z"},{"id":399444497,"run_number":296,"created_at":"2019-01-02T15:04:05Z","updated_at":"2020-01-02T15:04:05Z"}]}`)
+	})
+
+	opts := &ListWorkflowRunsOptions{ListOptions: ListOptions{Page: 2, PerPage: 2}}
+	runs, _, err := client.Actions.ListWorkflowRunsByID(context.Background(), "o", "r", 29679449, opts)
+	if err != nil {
+		t.Errorf("Actions.ListWorkFlowRunsByID returned error: %v", err)
+	}
+
+	want := &WorkflowRuns{
+		TotalCount: Int(4),
+		WorkflowRuns: []*WorkflowRun{
+			{ID: Int64(399444496), RunNumber: Int(296), CreatedAt: &Timestamp{time.Date(2019, time.January, 02, 15, 04, 05, 0, time.UTC)}, UpdatedAt: &Timestamp{time.Date(2020, time.January, 02, 15, 04, 05, 0, time.UTC)}},
+			{ID: Int64(399444497), RunNumber: Int(296), CreatedAt: &Timestamp{time.Date(2019, time.January, 02, 15, 04, 05, 0, time.UTC)}, UpdatedAt: &Timestamp{time.Date(2020, time.January, 02, 15, 04, 05, 0, time.UTC)}},
+		},
+	}
+	if !reflect.DeepEqual(runs, want) {
+		t.Errorf("Actions.ListWorkflowRunsByID returned %+v, want %+v", runs, want)
+	}
+}
+
+func TestActionsService_ListWorkflowRunsFileName(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/workflows/29679449/runs", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		testFormValues(t, r, values{"per_page": "2", "page": "2"})
+		fmt.Fprint(w, `{"total_count":4,"workflow_runs":[{"id":399444496,"run_number":296,"created_at":"2019-01-02T15:04:05Z","updated_at":"2020-01-02T15:04:05Z"},{"id":399444497,"run_number":296,"created_at":"2019-01-02T15:04:05Z","updated_at":"2020-01-02T15:04:05Z"}]}`)
+	})
+
+	opts := &ListWorkflowRunsOptions{ListOptions: ListOptions{Page: 2, PerPage: 2}}
+	runs, _, err := client.Actions.ListWorkflowRunsByFileName(context.Background(), "o", "r", "29679449", opts)
+	if err != nil {
+		t.Errorf("Actions.ListWorkFlowRunsByFileName returned error: %v", err)
+	}
+
+	want := &WorkflowRuns{
+		TotalCount: Int(4),
+		WorkflowRuns: []*WorkflowRun{
+			{ID: Int64(399444496), RunNumber: Int(296), CreatedAt: &Timestamp{time.Date(2019, time.January, 02, 15, 04, 05, 0, time.UTC)}, UpdatedAt: &Timestamp{time.Date(2020, time.January, 02, 15, 04, 05, 0, time.UTC)}},
+			{ID: Int64(399444497), RunNumber: Int(296), CreatedAt: &Timestamp{time.Date(2019, time.January, 02, 15, 04, 05, 0, time.UTC)}, UpdatedAt: &Timestamp{time.Date(2020, time.January, 02, 15, 04, 05, 0, time.UTC)}},
+		},
+	}
+	if !reflect.DeepEqual(runs, want) {
+		t.Errorf("Actions.ListWorkflowRunsByFileName returned %+v, want %+v", runs, want)
+	}
+}
+
+func TestActionsService_GetWorkflowRunByID(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runs/29679449", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		fmt.Fprint(w, `{"id":399444496,"run_number":296,"created_at":"2019-01-02T15:04:05Z","updated_at":"2020-01-02T15:04:05Z"}}`)
+	})
+
+	runs, _, err := client.Actions.GetWorkflowRunByID(context.Background(), "o", "r", 29679449)
+	if err != nil {
+		t.Errorf("Actions.ListWorkFlowRunsByFileName returned error: %v", err)
+	}
+
+	want := &WorkflowRun{
+		ID:        Int64(399444496),
+		RunNumber: Int(296),
+		CreatedAt: &Timestamp{time.Date(2019, time.January, 02, 15, 04, 05, 0, time.UTC)},
+		UpdatedAt: &Timestamp{time.Date(2020, time.January, 02, 15, 04, 05, 0, time.UTC)},
+	}
+
+	if !reflect.DeepEqual(runs, want) {
+		t.Errorf("Actions.GetWorkflowRunByID returned %+v, want %+v", runs, want)
+	}
+}
+
+func TestActionsService_RerunWorkflowRunByID(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runs/3434/rerun", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	resp, err := client.Actions.RerunWorkflowByID(context.Background(), "o", "r", 3434)
+	if err != nil {
+		t.Errorf("Actions.RerunWorkflowByID returned error: %v", err)
+	}
+	if resp.StatusCode != http.StatusCreated {
+		t.Errorf("Actions.RerunWorkflowRunByID returned status: %d, want %d", resp.StatusCode, http.StatusCreated)
+	}
+}
+
+func TestActionsService_CancelWorkflowRunByID(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runs/3434/cancel", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		w.WriteHeader(http.StatusAccepted)
+	})
+
+	resp, err := client.Actions.CancelWorkflowRunByID(context.Background(), "o", "r", 3434)
+	if _, ok := err.(*AcceptedError); !ok {
+		t.Errorf("Actions.CancelWorkflowRunByID returned error: %v (want AcceptedError)", err)
+	}
+	if resp.StatusCode != http.StatusAccepted {
+		t.Errorf("Actions.CancelWorkflowRunByID returned status: %d, want %d", resp.StatusCode, http.StatusAccepted)
+	}
+}
+
+func TestActionsService_GetWorkflowRunLogs(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runs/399444496/logs", 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.Actions.GetWorkflowRunLogs(context.Background(), "o", "r", 399444496, true)
+	if err != nil {
+		t.Errorf("Actions.GetWorkflowRunLogs returned error: %v", err)
+	}
+	if resp.StatusCode != http.StatusFound {
+		t.Errorf("Actions.GetWorkflowRunLogs returned status: %d, want %d", resp.StatusCode, http.StatusFound)
+	}
+	want := "http://github.com/a"
+	if url.String() != want {
+		t.Errorf("Actions.GetWorkflowRunLogs returned %+v, want %+v", url.String(), want)
+	}
+}
+
+func TestActionsService_GetWorkflowRunLogs_StatusMovedPermanently_dontFollowRedirects(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/actions/runs/399444496/logs", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		http.Redirect(w, r, "http://github.com/a", http.StatusMovedPermanently)
+	})
+
+	_, resp, _ := client.Actions.GetWorkflowRunLogs(context.Background(), "o", "r", 399444496, false)
+	if resp.StatusCode != http.StatusMovedPermanently {
+		t.Errorf("Actions.GetWorkflowJobLogs returned status: %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
+	}
+}
+
+func TestActionsService_GetWorkflowRunLogs_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/actions/runs/399444496/logs", 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.Actions.GetWorkflowRunLogs(context.Background(), "o", "r", 399444496, true)
+	if err != nil {
+		t.Errorf("Actions.GetWorkflowJobLogs returned error: %v", err)
+	}
+
+	if resp.StatusCode != http.StatusFound {
+		t.Errorf("Actions.GetWorkflowJobLogs returned status: %d, want %d", resp.StatusCode, http.StatusFound)
+	}
+
+	want := "http://github.com/a"
+	if url.String() != want {
+		t.Errorf("Actions.GetWorkflowJobLogs returned %+v, want %+v", url.String(), want)
+	}
+}
diff --git a/github/github-stringify_test.go b/github/github-stringify_test.go
index a8050964..375f3b6a 100644
--- a/github/github-stringify_test.go
+++ b/github/github-stringify_test.go
@@ -484,6 +484,24 @@ func TestGrant_String(t *testing.T) {
 	}
 }
 
+func TestHeadCommit_String(t *testing.T) {
+	v := HeadCommit{
+		Message:   String(""),
+		Author:    &CommitAuthor{},
+		URL:       String(""),
+		Distinct:  Bool(false),
+		SHA:       String(""),
+		ID:        String(""),
+		TreeID:    String(""),
+		Timestamp: &Timestamp{},
+		Committer: &CommitAuthor{},
+	}
+	want := `github.HeadCommit{Message:"", Author:github.CommitAuthor{}, URL:"", Distinct:false, SHA:"", ID:"", TreeID:"", Timestamp:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, Committer:github.CommitAuthor{}}`
+	if got := v.String(); got != want {
+		t.Errorf("HeadCommit.String = %v, want %v", got, want)
+	}
+}
+
 func TestHook_String(t *testing.T) {
 	v := Hook{
 		URL:    String(""),
@@ -1079,35 +1097,17 @@ func TestPushEvent_String(t *testing.T) {
 		BaseRef:      String(""),
 		Compare:      String(""),
 		Repo:         &PushEventRepository{},
-		HeadCommit:   &PushEventCommit{},
+		HeadCommit:   &HeadCommit{},
 		Pusher:       &User{},
 		Sender:       &User{},
 		Installation: &Installation{},
 	}
-	want := `github.PushEvent{PushID:0, Head:"", Ref:"", Size:0, Before:"", DistinctSize:0, After:"", Created:false, Deleted:false, Forced:false, BaseRef:"", Compare:"", Repo:github.PushEventRepository{}, HeadCommit:github.PushEventCommit{}, Pusher:github.User{}, Sender:github.User{}, Installation:github.Installation{}}`
+	want := `github.PushEvent{PushID:0, Head:"", Ref:"", Size:0, Before:"", DistinctSize:0, After:"", Created:false, Deleted:false, Forced:false, BaseRef:"", Compare:"", Repo:github.PushEventRepository{}, HeadCommit:github.HeadCommit{}, Pusher:github.User{}, Sender:github.User{}, Installation:github.Installation{}}`
 	if got := v.String(); got != want {
 		t.Errorf("PushEvent.String = %v, want %v", got, want)
 	}
 }
 
-func TestPushEventCommit_String(t *testing.T) {
-	v := PushEventCommit{
-		Message:   String(""),
-		Author:    &CommitAuthor{},
-		URL:       String(""),
-		Distinct:  Bool(false),
-		SHA:       String(""),
-		ID:        String(""),
-		TreeID:    String(""),
-		Timestamp: &Timestamp{},
-		Committer: &CommitAuthor{},
-	}
-	want := `github.PushEventCommit{Message:"", Author:github.CommitAuthor{}, URL:"", Distinct:false, SHA:"", ID:"", TreeID:"", Timestamp:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, Committer:github.CommitAuthor{}}`
-	if got := v.String(); got != want {
-		t.Errorf("PushEventCommit.String = %v, want %v", got, want)
-	}
-}
-
 func TestRate_String(t *testing.T) {
 	v := Rate{
 		Limit:     0,
diff --git a/github/reactions_test.go b/github/reactions_test.go
index c32274de..90b9eaaf 100644
--- a/github/reactions_test.go
+++ b/github/reactions_test.go
@@ -340,3 +340,35 @@ func TestReactionsService_DeleteReaction(t *testing.T) {
 		t.Errorf("DeleteReaction returned error: %v", err)
 	}
 }
+
+func TestReactionsService_DeleteCommitCommentReaction(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repos/o/r/comments/1/reactions/2", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+		testHeader(t, r, "Accept", mediaTypeReactionsPreview)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	if _, err := client.Reactions.DeleteCommentReaction(context.Background(), "o", "r", 1, 2); err != nil {
+		t.Errorf("DeleteCommentReaction returned error: %v", err)
+	}
+}
+
+func TestReactionsService_DeleteCommitCommentReactionByRepoID(t *testing.T) {
+	client, mux, _, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/repositories/1/comments/2/reactions/3", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+		testHeader(t, r, "Accept", mediaTypeReactionsPreview)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	if _, err := client.Reactions.DeleteCommentReactionByRepoID(context.Background(), 1, 2, 3); err != nil {
+		t.Errorf("DeleteCommentReactionByRepoID returned error: %v", err)
+	}
+}
diff --git a/github/strings_test.go b/github/strings_test.go
index e16d4a54..f86feb75 100644
--- a/github/strings_test.go
+++ b/github/strings_test.go
@@ -113,7 +113,7 @@ func TestString(t *testing.T) {
 		{DraftReviewComment{Position: Int(1)}, `github.DraftReviewComment{Position:1}`},
 		{PullRequestReviewRequest{Body: String("r")}, `github.PullRequestReviewRequest{Body:"r"}`},
 		{PullRequestReviewDismissalRequest{Message: String("r")}, `github.PullRequestReviewDismissalRequest{Message:"r"}`},
-		{PushEventCommit{SHA: String("s")}, `github.PushEventCommit{SHA:"s"}`},
+		{HeadCommit{SHA: String("s")}, `github.HeadCommit{SHA:"s"}`},
 		{PushEvent{PushID: Int64(1)}, `github.PushEvent{PushID:1}`},
 		{Reference{Ref: String("r")}, `github.Reference{Ref:"r"}`},
 		{ReleaseAsset{ID: Int64(1)}, `github.ReleaseAsset{ID:1}`},
diff --git a/scrape/forms_test.go b/scrape/forms_test.go
index f45010cf..2c7b9fef 100644
--- a/scrape/forms_test.go
+++ b/scrape/forms_test.go
@@ -35,6 +35,38 @@ func Test_ParseForms(t *testing.T) {
 				{Action: "a2", Method: "m2", Values: url.Values{"n2": {"v2"}}},
 			},
 		},
+		{
+			"form with radio buttons (none checked)",
+			`<html><form>
+			   <input type="radio" name="n1" value="v1">
+			   <input type="radio" name="n1" value="v2">
+			   <input type="radio" name="n1" value="v3">
+			</form></html>`,
+			[]htmlForm{{Values: url.Values{}}},
+		},
+		{
+			"form with radio buttons",
+			`<html><form>
+			   <input type="radio" name="n1" value="v1">
+			   <input type="radio" name="n1" value="v2">
+			   <input type="radio" name="n1" value="v3" checked>
+			</form></html>`,
+			[]htmlForm{{Values: url.Values{"n1": {"v3"}}}},
+		},
+		{
+			"form with checkboxes",
+			`<html><form>
+			   <input type="checkbox" name="n1" value="v1" checked>
+			   <input type="checkbox" name="n2" value="v2">
+			   <input type="checkbox" name="n3" value="v3" checked>
+			</form></html>`,
+			[]htmlForm{{Values: url.Values{"n1": {"v1"}, "n3": {"v3"}}}},
+		},
+		{
+			"single form with textarea",
+			`<html><form><textarea name="n1">v1</textarea></form></html>`,
+			[]htmlForm{{Values: url.Values{"n1": {"v1"}}}},
+		},
 	}
 
 	for _, tt := range tests {