From fe258966f74dbd5eb817cacedbf4a5761f5785bb Mon Sep 17 00:00:00 2001 From: David McPike Date: Mon, 1 Apr 2019 20:49:09 -0500 Subject: [PATCH 01/64] initial changes for azure devops support --- cmd/server.go | 25 +++++++++++++++++++++++++ server/events/models/models.go | 4 ++++ server/events/vcs/proxy.go | 6 +++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cmd/server.go b/cmd/server.go index b59f8be42..b1b59720e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -48,6 +48,10 @@ const ( CheckoutStrategyFlag = "checkout-strategy" DataDirFlag = "data-dir" DefaultTFVersionFlag = "default-tf-version" + DOHostnameFlag = "devops-hostname" + DOTokenFlag = "devops-token" + DOUserFlag = "devops-user" + DOWebhookSecretFlag = "devops-webhook-secret" // nolint: gosec GHHostnameFlag = "gh-hostname" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" @@ -73,6 +77,7 @@ const ( DefaultDataDir = "~/.atlantis" DefaultGHHostname = "github.com" DefaultGitlabHostname = "gitlab.com" + DefaultDOHostname = "dev.azure.com" DefaultLogLevel = "info" DefaultPort = 4141 ) @@ -123,6 +128,26 @@ var stringFlags = []stringFlag{ description: "Path to directory to store Atlantis data.", defaultValue: DefaultDataDir, }, + { + name: DOHostnameFlag, + description: "Hostname of your Azure Devops installation.", + defaultValue: DefaultGHHostname, + }, + { + name: DOUserFlag, + description: "Azure Devops username of API user.", + }, + { + name: DOTokenFlag, + description: "Azure Devops token of API user. Can also be specified via the ATLANTIS_DO_TOKEN environment variable.", + }, + { + name: DOWebhookSecretFlag, + description: "Secret used to validate Azure Devops webhooks (see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure Devops org. " + + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + + "Should be specified via the ATLANTIS_DO_WEBHOOK_SECRET environment variable.", + }, { name: GHHostnameFlag, description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.", diff --git a/server/events/models/models.go b/server/events/models/models.go index 9f2bd4c24..af173ab0f 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -263,6 +263,7 @@ const ( Gitlab BitbucketCloud BitbucketServer + AzureDevops ) func (h VCSHostType) String() string { @@ -276,6 +277,9 @@ func (h VCSHostType) String() string { case BitbucketServer: return "BitbucketServer" } + case AzureDevops: + return "AzureDevops" + } return "" } diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 2db123b8c..c2824846c 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -25,7 +25,7 @@ type ClientProxy struct { clients map[models.VCSHostType]Client } -func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client) *ClientProxy { +func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client, azuredevopsClient Client) *ClientProxy { if githubClient == nil { githubClient = &NotConfiguredVCSClient{} } @@ -38,12 +38,16 @@ func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClie if bitbucketServerClient == nil { bitbucketServerClient = &NotConfiguredVCSClient{} } + if azuredevopsClient == nil { + azuredevopsClient = &NotConfiguredVCSClient{} + } return &ClientProxy{ clients: map[models.VCSHostType]Client{ models.Github: githubClient, models.Gitlab: gitlabClient, models.BitbucketCloud: bitbucketCloudClient, models.BitbucketServer: bitbucketServerClient, + models.AzureDevops: azuredevopsClient }, } } From ce380ec27b4edd6291cb857a0a2a080020ae7d94 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Tue, 2 Apr 2019 22:00:13 -0500 Subject: [PATCH 02/64] initial azuredevops client - copy from bitbucketcloud --- Gopkg.toml | 4 + server/events/vcs/azuredevops/azuredevops.go | 6 + server/events/vcs/azuredevops/client.go | 225 ++++++++++++++++++ server/events/vcs/azuredevops/client_test.go | 214 +++++++++++++++++ server/events/vcs/azuredevops/models.go | 88 +++++++ .../testdata/pull-approved-by-author.json | 166 +++++++++++++ .../testdata/pull-approved-multiple.json | 190 +++++++++++++++ .../azuredevops/testdata/pull-approved.json | 160 +++++++++++++ .../azuredevops/testdata/pull-unapproved.json | 135 +++++++++++ 9 files changed, 1188 insertions(+) create mode 100644 server/events/vcs/azuredevops/azuredevops.go create mode 100644 server/events/vcs/azuredevops/client.go create mode 100644 server/events/vcs/azuredevops/client_test.go create mode 100644 server/events/vcs/azuredevops/models.go create mode 100644 server/events/vcs/azuredevops/testdata/pull-approved-by-author.json create mode 100644 server/events/vcs/azuredevops/testdata/pull-approved-multiple.json create mode 100644 server/events/vcs/azuredevops/testdata/pull-approved.json create mode 100644 server/events/vcs/azuredevops/testdata/pull-unapproved.json diff --git a/Gopkg.toml b/Gopkg.toml index f99c50bc8..c29d48de7 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -96,6 +96,10 @@ branch = "master" name = "github.com/lkysow/go-gitlab" +[[constraint]] + branch = "master" + name = "github.com/benmatselby/go-azuredevops/azuredevops" + [[constraint]] name = "github.com/go-test/deep" version = "1.0.1" diff --git a/server/events/vcs/azuredevops/azuredevops.go b/server/events/vcs/azuredevops/azuredevops.go new file mode 100644 index 000000000..9f38e6f41 --- /dev/null +++ b/server/events/vcs/azuredevops/azuredevops.go @@ -0,0 +1,6 @@ +// Package bitbucketcloud holds code for Bitbucket Cloud aka (bitbucket.org). +// It is separate from bitbucketserver because Bitbucket Server has different +// APIs. +package azuredevops + +const BaseURL = "https://dev.azure.com" diff --git a/server/events/vcs/azuredevops/client.go b/server/events/vcs/azuredevops/client.go new file mode 100644 index 000000000..138261574 --- /dev/null +++ b/server/events/vcs/azuredevops/client.go @@ -0,0 +1,225 @@ +package azuredevops + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "gopkg.in/go-playground/validator.v9" +) + +type Client struct { + HTTPClient *http.Client + Username string + Password string + BaseURL string + AtlantisURL string +} + +// NewClient builds a Azure Devops cloud client. atlantisURL is the +// URL for Atlantis that will be linked to from the build status icons. This +// linking is annoying because we don't have anywhere good to link but a URL is +// required. +func NewClient(httpClient *http.Client, username string, password string, atlantisURL string) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{ + HTTPClient: httpClient, + Username: username, + Password: password, + BaseURL: BaseURL, + AtlantisURL: atlantisURL, + } +} + +// GetModifiedFiles returns the names of files that were modified in the merge request. +// The names include the path to the file from the repo root, ex. parent/child/file.txt. +func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { + var files []string + + nextPageURL := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/diffstat", b.BaseURL, repo.FullName, pull.Num) + // We'll only loop 1000 times as a safety measure. + maxLoops := 1000 + for i := 0; i < maxLoops; i++ { + resp, err := b.makeRequest("GET", nextPageURL, nil) + if err != nil { + return nil, err + } + var diffStat DiffStat + if err := json.Unmarshal(resp, &diffStat); err != nil { + return nil, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(diffStat); err != nil { + return nil, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + } + for _, v := range diffStat.Values { + if v.Old != nil { + files = append(files, *v.Old.Path) + } + if v.New != nil { + files = append(files, *v.New.Path) + } + } + if diffStat.Next == nil || *diffStat.Next == "" { + break + } + nextPageURL = *diffStat.Next + } + + // Now ensure all files are unique. + hash := make(map[string]bool) + var unique []string + for _, f := range files { + if !hash[f] { + unique = append(unique, f) + hash[f] = true + } + } + return unique, nil +} + +// CreateComment creates a comment on the merge request. +func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string) error { + // NOTE: I tried to find the maximum size of a comment for Azure Devops.org but + // I got up to 200k chars without issue so for now I'm not going to bother + // to detect this. + bodyBytes, err := json.Marshal(map[string]map[string]string{"content": { + "raw": comment, + }}) + if err != nil { + return errors.Wrap(err, "json encoding") + } + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum) + _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) + return err +} + +// PullIsApproved returns true if the merge request was approved. +func (b *Client) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num) + resp, err := b.makeRequest("GET", path, nil) + if err != nil { + return false, err + } + var pullResp PullRequest + if err := json.Unmarshal(resp, &pullResp); err != nil { + return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(pullResp); err != nil { + return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + } + for _, participant := range pullResp.Participants { + // Azure Devops allows the author to approve their own pull request. This + // defeats the purpose of approvals so we don't count that approval. + if *participant.Approved && *participant.User.Username != pull.Author { + return true, nil + } + } + return false, nil +} + +// PullIsMergeable returns true if the merge request has no conflicts and can be merged. +func (b *Client) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) { + // NOTE: The 1.0 API is deprecated, but the 2.0 API does not provide this endpoint. + path := fmt.Sprintf("%s/1.0/repositories/%s/pullrequests/%d/conflict-status", b.BaseURL, repo.FullName, pull.Num) + resp, err := b.makeRequest("GET", path, nil) + if err != nil { + return false, err + } + var conflictStatus ConflictStatus + if err := json.Unmarshal(resp, &conflictStatus); err != nil { + return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(conflictStatus); err != nil { + return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + } + if !*conflictStatus.MergeImpossible && !*conflictStatus.IsConflicted { + return true, nil + } + return false, nil +} + +// UpdateStatus updates the status of a commit. +func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { + bbState := "FAILED" + switch status { + case models.PendingCommitStatus: + bbState = "INPROGRESS" + case models.SuccessCommitStatus: + bbState = "SUCCESSFUL" + case models.FailedCommitStatus: + bbState = "FAILED" + } + + // URL is a required field for Azure Devops statuses. We default to the + // Atlantis server's URL. + if url == "" { + url = b.AtlantisURL + } + + bodyBytes, err := json.Marshal(map[string]string{ + "key": src, + "url": url, + "state": bbState, + "description": description, + }) + + path := fmt.Sprintf("%s/2.0/repositories/%s/commit/%s/statuses/build", b.BaseURL, repo.FullName, pull.HeadCommit) + if err != nil { + return errors.Wrap(err, "json encoding") + } + _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) + return err +} + +// MergePull merges the pull request. +func (b *Client) MergePull(pull models.PullRequest) error { + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/merge", b.BaseURL, pull.BaseRepo.FullName, pull.Num) + _, err := b.makeRequest("POST", path, nil) + return err +} + +// prepRequest adds auth and necessary headers. +func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, path, body) + if err != nil { + return nil, err + } + req.SetBasicAuth(b.Username, b.Password) + if body != nil { + req.Header.Add("Content-Type", "application/json") + } + // Add this header to disable CSRF checks. + // See https://confluence.atlassian.com/cloudkb/xsrf-check-failed-when-calling-cloud-apis-826874382.html + req.Header.Add("X-Atlassian-Token", "no-check") + return req, nil +} + +func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]byte, error) { + req, err := b.prepRequest(method, path, reqBody) + if err != nil { + return nil, errors.Wrap(err, "constructing request") + } + resp, err := b.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // nolint: errcheck + requestStr := fmt.Sprintf("%s %s", method, path) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + respBody, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) + } + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrapf(err, "reading response from request %q", requestStr) + } + return respBody, nil +} diff --git a/server/events/vcs/azuredevops/client_test.go b/server/events/vcs/azuredevops/client_test.go new file mode 100644 index 000000000..265209ce6 --- /dev/null +++ b/server/events/vcs/azuredevops/client_test.go @@ -0,0 +1,214 @@ +package azuredevops_test + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/runatlantis/atlantis/server/events/models" + . "github.com/runatlantis/atlantis/testing" +) + +// Should follow pagination properly. +func TestClient_GetModifiedFilesPagination(t *testing.T) { + respTemplate := ` +{ + "pagelen": 1, + "values": [ + { + "type": "diffstat", + "status": "modified", + "lines_removed": 1, + "lines_added": 2, + "old": { + "path": "%s", + "type": "commit_file", + "links": { + "self": { + "href": "https://dev.azure.com/2.0/repositories/bitbucket/geordi/src/e1749643d655d7c7014001a6c0f58abaf42ad850/setup.py" + } + } + }, + "new": { + "path": "%s", + "type": "commit_file", + "links": { + "self": { + "href": "https://dev.azure.com/2.0/repositories/bitbucket/geordi/src/d222fa235229c55dad20b190b0b571adf737d5a6/setup.py" + } + } + } + } + ], + "page": 1, + "size": 1 +` + firstResp := fmt.Sprintf(respTemplate, "file1.txt", "file2.txt") + secondResp := fmt.Sprintf(respTemplate, "file2.txt", "file3.txt") + var serverURL string + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/2.0/repositories/owner/repo/pullrequests/1/diffstat": + resp := firstResp + fmt.Sprintf(`,"next": "%s/2.0/repositories/owner/repo/pullrequests/1/diffstat?page=2"}`, serverURL) + w.Write([]byte(resp)) // nolint: errcheck + return + // The second should hit this URL. + case "/2.0/repositories/owner/repo/pullrequests/1/diffstat?page=2": + w.Write([]byte(secondResp + "}")) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + serverURL = testServer.URL + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + + files, err := client.GetModifiedFiles(models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "dev.azure.com", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) +} + +// If the "old" key in the list of files is nil we shouldn't error. +func TestClient_GetModifiedFilesOldNil(t *testing.T) { + resp := ` +{ + "pagelen": 500, + "values": [ + { + "status": "added", + "old": null, + "lines_removed": 0, + "lines_added": 2, + "new": { + "path": "parent/child/file1.txt", + "type": "commit_file", + "links": { + "self": { + "href": "https://dev.azure.com/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" + } + } + }, + "type": "diffstat" + } + ], + "page": 1, + "size": 1 +}` + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/2.0/repositories/owner/repo/pullrequests/1/diffstat": + w.Write([]byte(resp)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + + files, err := client.GetModifiedFiles(models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "dev.azure.com", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, []string{"parent/child/file1.txt"}, files) +} + +func TestClient_PullIsApproved(t *testing.T) { + cases := []struct { + description string + testdata string + exp bool + }{ + { + "no approvers", + "pull-unapproved.json", + false, + }, + { + "approver is the author", + "pull-approved-by-author.json", + false, + }, + { + "single approver", + "pull-approved.json", + true, + }, + { + "two approvers one author", + "pull-approved-multiple.json", + true, + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + json, err := ioutil.ReadFile(filepath.Join("testdata", c.testdata)) + Ok(t, err) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/2.0/repositories/owner/repo/pullrequests/1": + w.Write(json) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "https://dev.azure.com/owner/repo.git", "user", "token") + Ok(t, err) + approved, err := client.PullIsApproved(repo, models.PullRequest{ + Num: 1, + HeadBranch: "branch", + Author: "author", + BaseRepo: repo, + }) + Ok(t, err) + Equals(t, c.exp, approved) + }) + } +} diff --git a/server/events/vcs/azuredevops/models.go b/server/events/vcs/azuredevops/models.go new file mode 100644 index 000000000..fa99a95a2 --- /dev/null +++ b/server/events/vcs/azuredevops/models.go @@ -0,0 +1,88 @@ +package azuredevops + +const ( + PullCreatedHeader = "pullrequest:created" + PullUpdatedHeader = "pullrequest:updated" + PullFulfilledHeader = "pullrequest:fulfilled" + PullRejectedHeader = "pullrequest:rejected" + PullCommentCreatedHeader = "pullrequest:comment_created" +) + +type CommentEvent struct { + CommonEventData + Comment *Comment `json:"comment,omitempty" validate:"required"` +} + +type PullRequestEvent struct { + CommonEventData +} + +type CommonEventData struct { + Actor *Actor `json:"actor,omitempty" validate:"required"` + Repository *Repository `json:"repository,omitempty" validate:"required"` + PullRequest *PullRequest `json:"pullrequest,omitempty" validate:"required"` +} + +type DiffStat struct { + Values []DiffStatValue `json:"values,omitempty" validate:"required"` + Next *string `json:"next,omitempty"` +} +type DiffStatValue struct { + // Old is the old file, this can be null. + Old *DiffStatFile `json:"old,omitempty"` + // New is the new file, this can be null. + New *DiffStatFile `json:"new,omitempty"` +} +type DiffStatFile struct { + Path *string `json:"path,omitempty" validate:"required"` +} + +type Actor struct { + Nickname *string `json:"nickname,omitempty" validate:"required"` +} +type Repository struct { + FullName *string `json:"full_name,omitempty" validate:"required"` + Links Links `json:"links,omitempty" validate:"required"` +} +type PullRequest struct { + ID *int `json:"id,omitempty" validate:"required"` + Source *BranchMeta `json:"source,omitempty" validate:"required"` + Destination *BranchMeta `json:"destination,omitempty" validate:"required"` + Participants []Participant `json:"participants,omitempty" validate:"required"` + Links *Links `json:"links,omitempty" validate:"required"` + State *string `json:"state,omitempty" validate:"required"` +} +type Links struct { + HTML *Link `json:"html,omitempty" validate:"required"` +} +type Link struct { + HREF *string `json:"href,omitempty" validate:"required"` +} +type Participant struct { + Approved *bool `json:"approved,omitempty" validate:"required"` + User *struct { + Username *string `json:"username,omitempty" validate:"required"` + } `json:"user,omitempty" validate:"required"` +} +type BranchMeta struct { + Repository *Repository `json:"repository,omitempty" validate:"required"` + Commit *Commit `json:"commit,omitempty" validate:"required"` + Branch *Branch `json:"branch,omitempty" validate:"required"` +} +type Branch struct { + Name *string `json:"name,omitempty" validate:"required"` +} +type Commit struct { + Hash *string `json:"hash,omitempty" validate:"required"` +} +type Comment struct { + Content *CommentContent `json:"content,omitempty" validate:"required"` +} +type CommentContent struct { + Raw *string `json:"raw,omitempty" validate:"required"` +} + +type ConflictStatus struct { + MergeImpossible *bool `json:"mergeimpossible,omitempty" validate:"required"` + IsConflicted *bool `json:"isconflicted,omitempty" validate:"required"` +} diff --git a/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json b/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json new file mode 100644 index 000000000..87e1b13b5 --- /dev/null +++ b/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json @@ -0,0 +1,166 @@ +{ + "type": "pullrequest", + "description": "main.tf edited online with Bitbucket", + "links": { + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" + } + }, + "title": "main.tf edited online with Bitbucket", + "close_source_branch": true, + "reviewers": [], + "id": 5, + "destination": { + "commit": { + "hash": "fe607a7f5172", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "master" + } + }, + "created_on": "2018-07-25T12:23:21.100810+00:00", + "summary": { + "raw": "main.tf edited online with Bitbucket", + "markup": "markdown", + "html": "

main.tf edited online with Bitbucket

", + "type": "rendered" + }, + "source": { + "commit": { + "hash": "3428957ade18", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" + } + }, + "comment_count": 3, + "state": "OPEN", + "task_count": 0, + "participants": [ + { + "role": "PARTICIPANT", + "participated_on": "2018-07-28T00:06:42.255492+00:00", + "type": "participant", + "approved": true, + "user": { + "username": "author", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + } + } + ], + "reason": "", + "updated_on": "2018-07-28T00:06:42.257659+00:00", + "author": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "merge_commit": null, + "closed_by": null +} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json b/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json new file mode 100644 index 000000000..32ae46622 --- /dev/null +++ b/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json @@ -0,0 +1,190 @@ +{ + "type": "pullrequest", + "description": "main.tf edited online with Bitbucket", + "links": { + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" + } + }, + "title": "main.tf edited online with Bitbucket", + "close_source_branch": true, + "reviewers": [], + "id": 5, + "destination": { + "commit": { + "hash": "fe607a7f5172", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "master" + } + }, + "created_on": "2018-07-25T12:23:21.100810+00:00", + "summary": { + "raw": "main.tf edited online with Bitbucket", + "markup": "markdown", + "html": "

main.tf edited online with Bitbucket

", + "type": "rendered" + }, + "source": { + "commit": { + "hash": "3428957ade18", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" + } + }, + "comment_count": 3, + "state": "OPEN", + "task_count": 0, + "participants": [ + { + "role": "PARTICIPANT", + "participated_on": "2018-07-28T00:06:42.255492+00:00", + "type": "participant", + "approved": true, + "user": { + "username": "author", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + } + }, + { + "role": "PARTICIPANT", + "participated_on": "2018-07-28T00:06:42.255492+00:00", + "type": "participant", + "approved": true, + "user": { + "username": "approver", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + } + } + ], + "reason": "", + "updated_on": "2018-07-28T00:06:42.257659+00:00", + "author": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "merge_commit": null, + "closed_by": null +} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-approved.json b/server/events/vcs/azuredevops/testdata/pull-approved.json new file mode 100644 index 000000000..c40340d28 --- /dev/null +++ b/server/events/vcs/azuredevops/testdata/pull-approved.json @@ -0,0 +1,160 @@ +{ + "type": "pullrequest", + "description": "main.tf edited online with Bitbucket", + "links": { + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" + } + }, + "title": "main.tf edited online with Bitbucket", + "close_source_branch": true, + "reviewers": [], + "id": 5, + "destination": { + "commit": { + "hash": "fe607a7f5172", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "master" + } + }, + "created_on": "2018-07-25T12:23:21.100810+00:00", + "summary": { + "raw": "main.tf edited online with Bitbucket", + "markup": "markdown", + "html": "

main.tf edited online with Bitbucket

", + "type": "rendered" + }, + "source": { + "commit": { + "hash": "3428957ade18", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" + } + }, + "comment_count": 3, + "state": "OPEN", + "task_count": 0, + "participants": [ + { + "role": "PARTICIPANT", + "participated_on": "2018-07-28T00:06:42.255492+00:00", + "type": "participant", + "approved": true, + "user": { + "username": "approver", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + } + } + ], + "reason": "", + "updated_on": "2018-07-28T00:06:42.257659+00:00", + "author": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "merge_commit": null, + "closed_by": null +} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-unapproved.json b/server/events/vcs/azuredevops/testdata/pull-unapproved.json new file mode 100644 index 000000000..9d8de84ac --- /dev/null +++ b/server/events/vcs/azuredevops/testdata/pull-unapproved.json @@ -0,0 +1,135 @@ +{ + "type": "pullrequest", + "description": "main.tf edited online with Bitbucket", + "links": { + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" + } + }, + "title": "main.tf edited online with Bitbucket", + "close_source_branch": true, + "reviewers": [], + "id": 5, + "destination": { + "commit": { + "hash": "fe607a7f5172", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "master" + } + }, + "created_on": "2018-07-25T12:23:21.100810+00:00", + "summary": { + "raw": "main.tf edited online with Bitbucket", + "markup": "markdown", + "html": "

main.tf edited online with Bitbucket

", + "type": "rendered" + }, + "source": { + "commit": { + "hash": "3428957ade18", + "type": "commit", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" + } + } + }, + "repository": { + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + } + }, + "type": "repository", + "name": "atlantis-example", + "full_name": "lkysow/atlantis-example", + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + }, + "branch": { + "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" + } + }, + "comment_count": 3, + "state": "OPEN", + "task_count": 0, + "participants": [], + "reason": "", + "updated_on": "2018-07-28T00:06:42.257659+00:00", + "author": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "merge_commit": null, + "closed_by": null +} \ No newline at end of file From cfcf59d8e0b0e93af9f0346c22fe27344771ed65 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Wed, 3 Apr 2019 21:30:14 -0500 Subject: [PATCH 03/64] continue adding skeleton for azure devops support --- cmd/server.go | 72 +++++++++++-------- cmd/server_test.go | 31 +++++++- .../.vuepress/components/HomeCustom.vue | 2 +- runatlantis.io/docs/access-credentials.md | 8 ++- server/server.go | 4 +- 5 files changed, 84 insertions(+), 33 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index b1b59720e..aabf36f4b 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -36,6 +36,10 @@ import ( // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. + ADHostnameFlag = "azuredevops-hostname" + ADTokenFlag = "azuredevops-token" + ADUserFlag = "azuredevops-user" + ADWebhookSecretFlag = "azuredevops-webhook-secret" // nolint: gosec AllowForkPRsFlag = "allow-fork-prs" AllowRepoConfigFlag = "allow-repo-config" AtlantisURLFlag = "atlantis-url" @@ -48,10 +52,6 @@ const ( CheckoutStrategyFlag = "checkout-strategy" DataDirFlag = "data-dir" DefaultTFVersionFlag = "default-tf-version" - DOHostnameFlag = "devops-hostname" - DOTokenFlag = "devops-token" - DOUserFlag = "devops-user" - DOWebhookSecretFlag = "devops-webhook-secret" // nolint: gosec GHHostnameFlag = "gh-hostname" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" @@ -72,17 +72,37 @@ const ( TFETokenFlag = "tfe-token" // Flag defaults. + DefaultADHostname = azuredevops.BaseURL DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" DefaultGHHostname = "github.com" DefaultGitlabHostname = "gitlab.com" - DefaultDOHostname = "dev.azure.com" DefaultLogLevel = "info" DefaultPort = 4141 ) var stringFlags = []stringFlag{ + { + name: ADHostnameFlag, + description: "Hostname of your Azure Devops installation.", + defaultValue: DefaultADHostname, + }, + { + name: ADUserFlag, + description: "Azure Devops username of API user.", + }, + { + name: ADTokenFlag, + description: "Azure Devops token of API user. Can also be specified via the ATLANTIS_DO_TOKEN environment variable.", + }, + { + name: ADWebhookSecretFlag, + description: "Secret used to validate Azure Devops webhooks (see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure Devops org. " + + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + + "Should be specified via the ATLANTIS_DO_WEBHOOK_SECRET environment variable.", + }, { name: AtlantisURLFlag, description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.", @@ -128,26 +148,6 @@ var stringFlags = []stringFlag{ description: "Path to directory to store Atlantis data.", defaultValue: DefaultDataDir, }, - { - name: DOHostnameFlag, - description: "Hostname of your Azure Devops installation.", - defaultValue: DefaultGHHostname, - }, - { - name: DOUserFlag, - description: "Azure Devops username of API user.", - }, - { - name: DOTokenFlag, - description: "Azure Devops token of API user. Can also be specified via the ATLANTIS_DO_TOKEN environment variable.", - }, - { - name: DOWebhookSecretFlag, - description: "Secret used to validate Azure Devops webhooks (see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + - " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure Devops org. " + - "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + - "Should be specified via the ATLANTIS_DO_WEBHOOK_SECRET environment variable.", - }, { name: GHHostnameFlag, description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.", @@ -433,6 +433,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.DataDir == "" { c.DataDir = DefaultDataDir } + if c.AzuredevopsHostname == "" { + c.AzuredevopsHostname = DefaultADHostname + } if c.GithubHostname == "" { c.GithubHostname = DefaultGHHostname } @@ -468,14 +471,17 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { // 1. github user and token set // 2. gitlab user and token set // 3. bitbucket user and token set - // 4. any combination of the above - vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag) - if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) { + // 4. azuredevops user and token set + // 5. any combination of the above + vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag) + if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) + || ((userConfig.AzuredevopsUser == "") != (userConfig.AzuredevopsToken == "")) { return vcsErr } // At this point, we know that there can't be a single user/token without // its partner, but we haven't checked if any user/token is set at all. - if userConfig.GithubUser == "" && userConfig.GitlabUser == "" && userConfig.BitbucketUser == "" { + if userConfig.GithubUser == "" && userConfig.GitlabUser == "" && userConfig.BitbucketUser == "" + && userConfig.AzuredevopsUser == "" { return vcsErr } @@ -490,6 +496,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", BitbucketWebhookSecretFlag) } + if userConfig. == DefaultADHostname && userConfig.AzuredevopsToken != "" { + return fmt.Errorf("--%s cannot be specified for Azure Devops because it is not supported by Microsoft", BitbucketWebhookSecretFlag) + } + parsed, err := url.Parse(userConfig.BitbucketBaseURL) if err != nil { return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err) @@ -541,6 +551,7 @@ func (s *ServerCmd) trimAtSymbolFromUsers(userConfig *server.UserConfig) { userConfig.GithubUser = strings.TrimPrefix(userConfig.GithubUser, "@") userConfig.GitlabUser = strings.TrimPrefix(userConfig.GitlabUser, "@") userConfig.BitbucketUser = strings.TrimPrefix(userConfig.BitbucketUser, "@") + userConfig.AzuredevopsUser = strings.TrimPrefix(userConfig.AzuredevopsUser, "@") } func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { @@ -556,6 +567,9 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs") } + if userConfig.AzuredevopsUser != "" && userConfig.AzuredevopsHostname == DefaultADHostname && !s.SilenceOutput { + s.Logger.Warn("Azure Devops does not support webhook secrets without org identifier. This could allow attackers to spoof requests from Azure. Ensure you are whitelisting Azure Devops IPs") + } } // withErrPrint prints out any cmd errors to stderr. diff --git a/cmd/server_test.go b/cmd/server_test.go index fb78e641c..8d38f7c09 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -184,7 +184,7 @@ func TestExecute_ValidateSSLConfig(t *testing.T) { } func TestExecute_ValidateVCSConfig(t *testing.T) { - expErr := "--gh-user/--gh-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token must be set" + expErr := "--gh-user/--gh-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" cases := []struct { description string flags map[string]interface{} @@ -216,6 +216,13 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, true, }, + { + "just azuredevops token set", + map[string]interface{}{ + cmd.ADTokenFlag: "token", + }, + true, + }, { "just github user set", map[string]interface{}{ @@ -237,6 +244,13 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, true, }, + { + "just azuredevops user set", + map[string]interface{}{ + cmd.ADUserFlag: "user", + }, + true, + }, { "github user and gitlab token set", map[string]interface{}{ @@ -285,6 +299,14 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, false, }, + { + "azuredevops user and azuredevops token set and should be successful", + map[string]interface{}{ + cmd.ADUserFlag: "user", + cmd.ADTokenFlag: "token", + }, + false, + }, { "all set should be successful", map[string]interface{}{ @@ -294,6 +316,8 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { cmd.GitlabTokenFlag: "token", cmd.BitbucketUserFlag: "user", cmd.BitbucketTokenFlag: "token", + cmd.ADUserFlag: "user", + cmd.ADTokenFlag: "token", }, false, }, @@ -322,6 +346,8 @@ func TestExecute_Defaults(t *testing.T) { cmd.GitlabTokenFlag: "gitlab-token", cmd.BitbucketUserFlag: "bitbucket-user", cmd.BitbucketTokenFlag: "bitbucket-token", + cmd.ADUserFlag: "azuredevops-user", + cmd.ADTokenFlag: "azuredevops-token", cmd.RepoWhitelistFlag: "*", }) err := c.Execute() @@ -353,6 +379,9 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "https://api.bitbucket.org", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "https://dev.azure.org", passedConfig.ADBaseURL) + Equals(t, "azuredevops-token", passedConfig.AzuredevopsToken) + Equals(t, "azuredevops-user", passedConfig.AzuredevopsUser) Equals(t, "", passedConfig.BitbucketWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 4141, passedConfig.Port) diff --git a/runatlantis.io/.vuepress/components/HomeCustom.vue b/runatlantis.io/.vuepress/components/HomeCustom.vue index ad01f6dea..02ddb3666 100644 --- a/runatlantis.io/.vuepress/components/HomeCustom.vue +++ b/runatlantis.io/.vuepress/components/HomeCustom.vue @@ -129,7 +129,7 @@ Fargate, etc.
  • Listens for - webhooks from GitHub/GitLab/Bitbucket. + webhooks from GitHub/GitLab/Bitbucket/Azure Devops.
  • Runs terraform commands remotely and comments back with their output. diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md index 0e5d3203d..c67ed7efc 100644 --- a/runatlantis.io/docs/access-credentials.md +++ b/runatlantis.io/docs/access-credentials.md @@ -1,5 +1,5 @@ # Git Host Access Credentials -This doc describes how to create credentials on your Git host (GitHub, GitLab or Bitbucket) +This doc describes how to create credentials on your Git host (GitHub, GitLab, Bitbucket, or Azure Devops) that Atlantis will use to make API calls. [[toc]] @@ -42,5 +42,11 @@ generate an access token. Read on for the instructions for your Git host. - Give the token **Read** Project permissions and **Write** Pull request permissions - Click **Create** and record the access token +### Create an Azure Devops (dev.azure.com) Personal Access Token +- create a Personal access token by following [https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops) +- Label the password "atlantis" +- Under scope "Code", select **Read & Write** and **Status** so that Atlantis can read your pull requests and write comments to them +- record the access token + ## Next Steps Once you've got your user and access token, you're ready to create a webhook secret. See [Creating a Webhook Secret](webhook-secrets.html). diff --git a/server/server.go b/server/server.go index 686673e10..d76a918ff 100644 --- a/server/server.go +++ b/server/server.go @@ -39,6 +39,7 @@ import ( "github.com/runatlantis/atlantis/server/events/runtime" "github.com/runatlantis/atlantis/server/events/terraform" "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/webhooks" @@ -111,6 +112,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { var gitlabClient *vcs.GitlabClient var bitbucketCloudClient *bitbucketcloud.Client var bitbucketServerClient *bitbucketserver.Client + var azuredevopsClient *azuredevops.Client if userConfig.GithubUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.Github) var err error @@ -164,7 +166,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if err != nil { return nil, errors.Wrap(err, "initializing webhooks") } - vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient) + vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient} terraformClient, err := terraform.NewClient(logger, userConfig.DataDir, userConfig.TFEToken, userConfig.DefaultTFVersion, config.DefaultTFVersionFlag, &terraform.DefaultDownloader{}) // The flag.Lookup call is to detect if we're running in a unit test. If we From 03bfefe9f481e9d0c621f37c3fd00a9804a49e02 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Wed, 3 Apr 2019 23:52:48 -0500 Subject: [PATCH 04/64] continue azure devops support --- Gopkg.toml | 2 +- cmd/server.go | 2 +- cmd/server_test.go | 4 +- server/events/comment_parser.go | 9 ++-- server/events/comment_parser_test.go | 11 +++-- server/events/event_parser.go | 2 + server/events/event_parser_test.go | 2 + server/events/models/models.go | 1 - server/events/vcs/azuredevops/azuredevops.go | 2 +- server/events/vcs/azuredevops/client.go | 2 +- server/events/vcs/azuredevops/client_test.go | 23 ++++----- server/events/vcs/azuredevops/models.go | 8 +-- server/events/vcs/proxy.go | 2 +- server/events_controller.go | 51 ++++++++++++++++++++ server/server.go | 10 ++-- server/user_config.go | 51 +++++++++++--------- 16 files changed, 127 insertions(+), 55 deletions(-) diff --git a/Gopkg.toml b/Gopkg.toml index c29d48de7..db303bb4a 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -98,7 +98,7 @@ [[constraint]] branch = "master" - name = "github.com/benmatselby/go-azuredevops/azuredevops" + name = "github.com/benmatselby/go-azuredevops" [[constraint]] name = "github.com/go-test/deep" diff --git a/cmd/server.go b/cmd/server.go index 0b6c85f2a..1bb136a32 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -75,7 +75,7 @@ const ( TFETokenFlag = "tfe-token" // Flag defaults. - DefaultADHostname = "linkconsulting.visualstudio.com" + DefaultADHostname = "visualstudio.com" DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" diff --git a/cmd/server_test.go b/cmd/server_test.go index 1a3ca064d..8a30e4921 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -380,8 +380,8 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) Equals(t, "https://dev.azure.org", passedConfig.ADBaseURL) - Equals(t, "azuredevops-token", passedConfig.AzuredevopsToken) - Equals(t, "azuredevops-user", passedConfig.AzuredevopsUser) + Equals(t, "azuredevops-token", passedConfig.AzureDevopsToken) + Equals(t, "azuredevops-user", passedConfig.AzureDevopsUser) Equals(t, "", passedConfig.BitbucketWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 4141, passedConfig.Port) diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index f7b1223d2..8e81ce531 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -65,9 +65,10 @@ type CommentBuilder interface { // CommentParser implements CommentParsing type CommentParser struct { - GithubUser string - GitlabUser string - BitbucketUser string + GithubUser string + GitlabUser string + BitbucketUser string + AzureDevopsUser string } // CommentParseResult describes the result of parsing a comment as a command. @@ -125,6 +126,8 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen vcsUser = e.GitlabUser case models.BitbucketCloud, models.BitbucketServer: vcsUser = e.BitbucketUser + case models.AzureDevops: + vcsUser = e.AzureDevopsUser } executableNames := []string{"run", atlantisExecutable, "@" + vcsUser} if !e.stringInSlice(args[0], executableNames) { diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index 460b3c2dd..3012fcd6e 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -644,9 +644,10 @@ func TestBuildPlanApplyComment(t *testing.T) { func TestParse_VCSUsername(t *testing.T) { cp := events.CommentParser{ - GithubUser: "gh", - GitlabUser: "gl", - BitbucketUser: "bb", + GithubUser: "gh", + GitlabUser: "gl", + BitbucketUser: "bb", + AzureDevopsUser: "ad", } cases := []struct { vcs models.VCSHostType @@ -668,6 +669,10 @@ func TestParse_VCSUsername(t *testing.T) { vcs: models.BitbucketCloud, user: "bb", }, + { + vcs: models.AzureDevops, + user: "ad", + }, } for _, c := range cases { diff --git a/server/events/event_parser.go b/server/events/event_parser.go index cc3492924..bec50fb3a 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -245,6 +245,8 @@ type EventParser struct { BitbucketUser string BitbucketToken string BitbucketServerURL string + AzureDevopsUser string + AzureDevopsToken string } // GetBitbucketCloudPullEventType returns the type of the pull request diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index 9d8ae71e0..8d39bd047 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -38,6 +38,8 @@ var parser = events.EventParser{ BitbucketUser: "bitbucket-user", BitbucketToken: "bitbucket-token", BitbucketServerURL: "http://mycorp.com:7490", + AzureDevopsUser: "azuredevops-user", + AzureDevopsToken: "azuredevops-token", } func TestParseGithubRepo(t *testing.T) { diff --git a/server/events/models/models.go b/server/events/models/models.go index effd1f7a9..684d9df05 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -282,7 +282,6 @@ func (h VCSHostType) String() string { return "BitbucketCloud" case BitbucketServer: return "BitbucketServer" - } case AzureDevops: return "AzureDevops" } diff --git a/server/events/vcs/azuredevops/azuredevops.go b/server/events/vcs/azuredevops/azuredevops.go index 9f38e6f41..313007735 100644 --- a/server/events/vcs/azuredevops/azuredevops.go +++ b/server/events/vcs/azuredevops/azuredevops.go @@ -3,4 +3,4 @@ // APIs. package azuredevops -const BaseURL = "https://dev.azure.com" +const BaseURL = "https://visualstudio.com" diff --git a/server/events/vcs/azuredevops/client.go b/server/events/vcs/azuredevops/client.go index 138261574..9f25fad62 100644 --- a/server/events/vcs/azuredevops/client.go +++ b/server/events/vcs/azuredevops/client.go @@ -197,7 +197,7 @@ func (b *Client) prepRequest(method string, path string, body io.Reader) (*http. } // Add this header to disable CSRF checks. // See https://confluence.atlassian.com/cloudkb/xsrf-check-failed-when-calling-cloud-apis-826874382.html - req.Header.Add("X-Atlassian-Token", "no-check") + //req.Header.Add("X-Atlassian-Token", "no-check") return req, nil } diff --git a/server/events/vcs/azuredevops/client_test.go b/server/events/vcs/azuredevops/client_test.go index 265209ce6..d8c23ff43 100644 --- a/server/events/vcs/azuredevops/client_test.go +++ b/server/events/vcs/azuredevops/client_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" . "github.com/runatlantis/atlantis/testing" ) @@ -28,7 +29,7 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { "type": "commit_file", "links": { "self": { - "href": "https://dev.azure.com/2.0/repositories/bitbucket/geordi/src/e1749643d655d7c7014001a6c0f58abaf42ad850/setup.py" + "href": "https://visualstudio.com/org/2.0/repositories/bitbucket/geordi/src/e1749643d655d7c7014001a6c0f58abaf42ad850/setup.py" } } }, @@ -37,7 +38,7 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { "type": "commit_file", "links": { "self": { - "href": "https://dev.azure.com/2.0/repositories/bitbucket/geordi/src/d222fa235229c55dad20b190b0b571adf737d5a6/setup.py" + "href": "https://visualstudio.com/org/2.0/repositories/bitbucket/geordi/src/d222fa235229c55dad20b190b0b571adf737d5a6/setup.py" } } } @@ -69,7 +70,7 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { defer testServer.Close() serverURL = testServer.URL - client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") client.BaseURL = testServer.URL files, err := client.GetModifiedFiles(models.Repo{ @@ -79,8 +80,8 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ - Type: models.BitbucketCloud, - Hostname: "dev.azure.com", + Type: models.AzureDevops, + Hostname: "visualstudio.com", }, }, models.PullRequest{ Num: 1, @@ -105,7 +106,7 @@ func TestClient_GetModifiedFilesOldNil(t *testing.T) { "type": "commit_file", "links": { "self": { - "href": "https://dev.azure.com/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" + "href": "https://visualstudio.com/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" } } }, @@ -130,7 +131,7 @@ func TestClient_GetModifiedFilesOldNil(t *testing.T) { })) defer testServer.Close() - client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") client.BaseURL = testServer.URL files, err := client.GetModifiedFiles(models.Repo{ @@ -140,8 +141,8 @@ func TestClient_GetModifiedFilesOldNil(t *testing.T) { CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ - Type: models.BitbucketCloud, - Hostname: "dev.azure.com", + Type: models.AzureDevops, + Hostname: "visualstudio.com", }, }, models.PullRequest{ Num: 1, @@ -196,10 +197,10 @@ func TestClient_PullIsApproved(t *testing.T) { })) defer testServer.Close() - client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") client.BaseURL = testServer.URL - repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "https://dev.azure.com/owner/repo.git", "user", "token") + repo, err := models.NewRepo(models.AzureDevops, "owner/repo", "https://visualstudio.com/owner/repo.git", "user", "token") Ok(t, err) approved, err := client.PullIsApproved(repo, models.PullRequest{ Num: 1, diff --git a/server/events/vcs/azuredevops/models.go b/server/events/vcs/azuredevops/models.go index fa99a95a2..25b7859c4 100644 --- a/server/events/vcs/azuredevops/models.go +++ b/server/events/vcs/azuredevops/models.go @@ -1,11 +1,11 @@ package azuredevops const ( - PullCreatedHeader = "pullrequest:created" - PullUpdatedHeader = "pullrequest:updated" - PullFulfilledHeader = "pullrequest:fulfilled" + PullCreatedHeader = "git.pullrequest.created" + PullUpdatedHeader = "git.pullrequest.updated" + PullFulfilledHeader = "git.pullrequest.merged" PullRejectedHeader = "pullrequest:rejected" - PullCommentCreatedHeader = "pullrequest:comment_created" + PullCommentCreatedHeader = "workitem.commented" ) type CommentEvent struct { diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index c2824846c..976eb3e85 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -47,7 +47,7 @@ func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClie models.Gitlab: gitlabClient, models.BitbucketCloud: bitbucketCloudClient, models.BitbucketServer: bitbucketServerClient, - models.AzureDevops: azuredevopsClient + models.AzureDevops: azuredevopsClient, }, } } diff --git a/server/events_controller.go b/server/events_controller.go index 46160063a..8f196d772 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -31,6 +31,7 @@ import ( const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" +const azuredevopsHeader = "X-AzureDevops-Event" // bitbucketEventTypeHeader is the same in both cloud and server. const bitbucketEventTypeHeader = "X-Event-Key" @@ -69,6 +70,10 @@ type EventsController struct { // UI that identifies this call as coming from Bitbucket. If empty, no // request validation is done. BitbucketWebhookSecret []byte + // AzureDevopsWebhookSecret is the secret added to this webhook via the Azure + // Devops UI that identifies this call as coming from Bitbucket. If empty, no + // request validation is done. + AzureDevopsWebhookSecret []byte } // Post handles POST webhook requests. @@ -108,6 +113,14 @@ func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) { e.Logger.Debug("handling Bitbucket Server post") e.handleBitbucketServerPost(w, r) return + } else if r.Header.Get(azuredevopsHeader) != "" { + if !e.supportsHost(models.AzureDevops) { + e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support AzureDevops") + return + } + e.Logger.Debug("handling AzureDevops post") + e.handleAzureDevopsPost(w, r) + return } } e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request") @@ -195,6 +208,44 @@ func (e *EventsController) handleBitbucketServerPost(w http.ResponseWriter, r *h } } +func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) {} + +/*func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) { + eventType := r.Header.Get(azuredevopsEventTypeHeader) + reqID := r.Header.Get(azuredevopsRequestIDHeader) + sig := r.Header.Get(azuredevopsSignatureHeader) + defer r.Body.Close() // nolint: errcheck + body, err := ioutil.ReadAll(r.Body) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, azuredevopsRequestIDHeader, reqID) + return + } + if eventType == azuredevops.DiagnosticsPingHeader { + // Specially handle the diagnostics:ping event because Bitbucket Server + // doesn't send the signature with this event for some reason. + e.respond(w, logging.Info, http.StatusOK, "Successfully received %s event %s=%s", eventType, azuredevopsRequestIDHeader, reqID) + return + } + if len(e.AzureDevopsWebhookSecret) > 0 { + if err := azuredevops.ValidateSignature(body, sig, e.AzureDevopsWebhookSecret); err != nil { + e.respond(w, logging.Warn, http.StatusBadRequest, errors.Wrap(err, "request did not pass validation").Error()) + return + } + } + switch eventType { + case azuredevops.PullCreatedHeader, azuredevops.PullMergedHeader, azuredevops.PullDeclinedHeader, azuredevops.PullDeletedHeader: + e.Logger.Debug("handling as pull request state changed event") + e.handleBitbucketServerPullRequestEvent(w, eventType, body, reqID) + return + case azuredevops.PullCommentCreatedHeader: + e.Logger.Debug("handling as comment created event") + e.HandleBitbucketServerCommentEvent(w, body, reqID) + return + default: + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketServerRequestIDHeader, reqID) + } +}*/ + // HandleGithubCommentEvent handles comment events from GitHub where Atlantis // commands can come from. It's exported to make testing easier. func (e *EventsController) HandleGithubCommentEvent(w http.ResponseWriter, event *github.IssueCommentEvent, githubReqID string) { diff --git a/server/server.go b/server/server.go index 740690af2..beb5feca1 100644 --- a/server/server.go +++ b/server/server.go @@ -235,11 +235,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { BitbucketUser: userConfig.BitbucketUser, BitbucketToken: userConfig.BitbucketToken, BitbucketServerURL: userConfig.BitbucketBaseURL, + AzureDevopsUser: userConfig.AzureDevopsUser, + AzureDevopsToken: userConfig.AzureDevopsToken, } commentParser := &events.CommentParser{ - GithubUser: userConfig.GithubUser, - GitlabUser: userConfig.GitlabUser, - BitbucketUser: userConfig.BitbucketUser, + GithubUser: userConfig.GithubUser, + GitlabUser: userConfig.GitlabUser, + BitbucketUser: userConfig.BitbucketUser, + AzureDevopsUser: userConfig.AzureDevopsUser, } defaultTfVersion := terraformClient.DefaultVersion() pendingPlanFinder := &events.DefaultPendingPlanFinder{} @@ -324,6 +327,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { SupportedVCSHosts: supportedVCSHosts, VCSClient: vcsClient, BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), + AzureDevopsWebhookSecret: []byte(userConfig.AzureDevopsWebhookSecret), } return &Server{ AtlantisVersion: config.AtlantisVersion, diff --git a/server/user_config.go b/server/user_config.go index e401ee6c2..beb61ded9 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -6,29 +6,34 @@ import "github.com/runatlantis/atlantis/server/logging" // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type UserConfig struct { - AllowForkPRs bool `mapstructure:"allow-fork-prs"` - AllowRepoConfig bool `mapstructure:"allow-repo-config"` - AtlantisURL string `mapstructure:"atlantis-url"` - Automerge bool `mapstructure:"automerge"` - BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` - BitbucketToken string `mapstructure:"bitbucket-token"` - BitbucketUser string `mapstructure:"bitbucket-user"` - BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` - CheckoutStrategy string `mapstructure:"checkout-strategy"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RepoConfig string `mapstructure:"repo-config"` - RepoConfigJSON string `mapstructure:"repo-config-json"` - RepoWhitelist string `mapstructure:"repo-whitelist"` + AllowForkPRs bool `mapstructure:"allow-fork-prs"` + AllowRepoConfig bool `mapstructure:"allow-repo-config"` + AtlantisURL string `mapstructure:"atlantis-url"` + Automerge bool `mapstructure:"automerge"` + AzureDevopsBaseURL string `mapstructure:"azuredevops-base-url"` + AzureDevopsHostname string `mapstructure:"azuredevops-hostname"` + AzureDevopsToken string `mapstructure:"azuredevops-token"` + AzureDevopsUser string `mapstructure:"azuredevops-user"` + AzureDevopsWebhookSecret string `mapstructure:"azuredevops-webhook-secret"` + BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` + BitbucketToken string `mapstructure:"bitbucket-token"` + BitbucketUser string `mapstructure:"bitbucket-user"` + BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` + CheckoutStrategy string `mapstructure:"checkout-strategy"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + RepoConfig string `mapstructure:"repo-config"` + RepoConfigJSON string `mapstructure:"repo-config-json"` + RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. RequireApproval bool `mapstructure:"require-approval"` From 5a1eb59d2ae46e5eb7b10582fa7e42dd94d66d2e Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 6 Apr 2019 12:35:06 -0500 Subject: [PATCH 05/64] add project memeber to Repo struct, NewRepo, and tests --- server/events/models/models.go | 12 +++++++++++- server/events/models/models_test.go | 27 ++++++++++++++++++--------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/server/events/models/models.go b/server/events/models/models.go index 684d9df05..d569bc76c 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -39,6 +39,9 @@ type Repo struct { // This may contain /'s in the case of GitLab subgroups. // This may contain spaces in the case of Bitbucket Server. Owner string + // Project is juts the project name, only required for Azure Devops. This will never + // have /'s in it. + Project string // Name is just the repo name, ex. "atlantis". This will never have // /'s in it. Name string @@ -62,7 +65,7 @@ func (r Repo) ID() string { // cloneURL can be with or without .git at the end // ex. https://github.com/runatlantis/atlantis.git OR // https://github.com/runatlantis/atlantis -func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) { +func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) { if repoFullName == "" { return Repo{}, errors.New("repoFullName can't be empty") } @@ -70,6 +73,12 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU return Repo{}, errors.New("cloneURL can't be empty") } + if vcsHostType == AzureDevops { + if project == "" { + return Repo{}, errors.New("AzureDevops project name can't be empty") + } + } + if !strings.HasSuffix(cloneURL, ".git") { cloneURL += ".git" } @@ -118,6 +127,7 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU return Repo{ FullName: repoFullName, Owner: owner, + Project: project, Name: repo, CloneURL: authedCloneURL, SanitizedCloneURL: cloneURL, diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index fe6857cb5..1ea296343 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -23,29 +23,34 @@ import ( ) func TestNewRepo_EmptyRepoFullName(t *testing.T) { - _, err := models.NewRepo(models.Github, "", "https://github.com/notowner/repo.git", "u", "p") + _, err := models.NewRepo(models.Github, "", "", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, "repoFullName can't be empty", err) } func TestNewRepo_EmptyCloneURL(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", "", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", "", "", "u", "p") ErrEquals(t, "cloneURL can't be empty", err) } func TestNewRepo_InvalidCloneURL(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", ":", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", "", ":", "u", "p") ErrEquals(t, "invalid clone url: parse :.git: missing protocol scheme", err) } func TestNewRepo_CloneURLWrongRepo(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/notowner/repo.git", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", "", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, `expected clone url to have path "/owner/repo.git" but had "/notowner/repo.git"`, err) } +func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) { + _, err := models.NewRepo(models.AzureDevops, "owner/repo", "", "https://dev.azure.com/notowner/project/_git/repo.git", "u", "p") + ErrEquals(t, "AzureDevops project name can't be empty", err) +} + // For bitbucket server we don't validate the clone URL because the callers // are actually constructing it. func TestNewRepo_CloneURLBitbucketServer(t *testing.T) { - repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ FullName: "owner/repo", @@ -93,7 +98,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { for _, c := range cases { t.Run(c.repoFullName, func(t *testing.T) { cloneURL := fmt.Sprintf("https://github.com/%s.git", c.repoFullName) - _, err := models.NewRepo(models.Github, c.repoFullName, cloneURL, "u", "p") + _, err := models.NewRepo(models.Github, c.repoFullName, "", cloneURL, "u", "p") ErrEquals(t, c.expErr, err) }) } @@ -101,7 +106,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { // If the clone url doesn't end with .git it is appended func TestNewRepo_MissingDotGit(t *testing.T) { - repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p") + repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "", "https://bitbucket.org/owner/repo", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo.git") Equals(t, repo.SanitizedCloneURL, "https://bitbucket.org/owner/repo.git") @@ -109,7 +114,7 @@ func TestNewRepo_MissingDotGit(t *testing.T) { func TestNewRepo_HTTPAuth(t *testing.T) { // When the url has http the auth should be added. - repo, err := models.NewRepo(models.Github, "owner/repo", "http://github.com/owner/repo.git", "u", "p") + repo, err := models.NewRepo(models.Github, "owner/repo", "", "http://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ @@ -126,7 +131,7 @@ func TestNewRepo_HTTPAuth(t *testing.T) { func TestNewRepo_HTTPSAuth(t *testing.T) { // When the url has https the auth should be added. - repo, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/owner/repo.git", "u", "p") + repo, err := models.NewRepo(models.Github, "owner/repo", "", "https://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ @@ -196,6 +201,10 @@ func TestVCSHostType_ToString(t *testing.T) { models.BitbucketServer, "BitbucketServer", }, + { + models.AzureDevops, + "AzureDevops", + }, } for _, c := range cases { From c5eb11951eb0faac268a9bb93f592dcf3edb1404 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 6 Apr 2019 12:41:15 -0500 Subject: [PATCH 06/64] update NewRepo calls with project argument --- server/events/event_parser.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index bec50fb3a..d17aa25dd 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -300,6 +300,7 @@ func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.Co headRepo, err = models.NewRepo( models.BitbucketCloud, *event.PullRequest.Source.Repository.FullName, + "", *event.PullRequest.Source.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) @@ -309,6 +310,7 @@ func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.Co baseRepo, err = models.NewRepo( models.BitbucketCloud, *event.Repository.FullName, + "", *event.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) @@ -473,7 +475,7 @@ func (e *EventParser) ParseGithubPull(pull *github.PullRequest) (pullModel model // returns a repo into the Atlantis model. // See EventParsing for return value docs. func (e *EventParser) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) { - return models.NewRepo(models.Github, ghRepo.GetFullName(), ghRepo.GetCloneURL(), e.GithubUser, e.GithubToken) + return models.NewRepo(models.Github, ghRepo.GetFullName(), "", ghRepo.GetCloneURL(), e.GithubUser, e.GithubToken) } // ParseGitlabMergeRequestEvent parses GitLab merge request events. @@ -487,11 +489,11 @@ func (e *EventParser) ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) (pul // GitLab also has a "merged" state, but we map that to Closed so we don't // need to check for it. - baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken) + baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, "", event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } - headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken) + headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, "", event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } @@ -532,7 +534,7 @@ func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeComm // Parse the base repo first. repoFullName := event.Project.PathWithNamespace cloneURL := event.Project.GitHTTPURL - baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) + baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, "", cloneURL, e.GitlabUser, e.GitlabToken) if err != nil { return } @@ -543,7 +545,7 @@ func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeComm // Now parse the head repo. headRepoFullName := event.MergeRequest.Source.PathWithNamespace headCloneURL := event.MergeRequest.Source.GitHTTPURL - headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, headCloneURL, e.GitlabUser, e.GitlabToken) + headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, "", headCloneURL, e.GitlabUser, e.GitlabToken) return } @@ -621,6 +623,7 @@ func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver. headRepo, err = models.NewRepo( models.BitbucketServer, headRepoFullname, + "", headRepoCloneURL, e.BitbucketUser, e.BitbucketToken) @@ -634,6 +637,7 @@ func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver. baseRepo, err = models.NewRepo( models.BitbucketServer, baseRepoFullname, + "", baseRepoCloneURL, e.BitbucketUser, e.BitbucketToken) From 08d47de047db5260e17358131893e44e79f57c4a Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 6 Apr 2019 13:23:29 -0500 Subject: [PATCH 07/64] azure devops should ignore appending .git to clone url --- server/events/models/models.go | 3 ++- server/events/models/models_test.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/events/models/models.go b/server/events/models/models.go index d569bc76c..c58eb4062 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -79,7 +79,8 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, clone } } - if !strings.HasSuffix(cloneURL, ".git") { + // Azure Devops doesn't work with .git suffix on clone URLs + if !strings.HasSuffix(cloneURL, ".git") && vcsHostType != AzureDevops { cloneURL += ".git" } diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index 1ea296343..49667c159 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -43,7 +43,7 @@ func TestNewRepo_CloneURLWrongRepo(t *testing.T) { } func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) { - _, err := models.NewRepo(models.AzureDevops, "owner/repo", "", "https://dev.azure.com/notowner/project/_git/repo.git", "u", "p") + _, err := models.NewRepo(models.AzureDevops, "owner/repo", "", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") ErrEquals(t, "AzureDevops project name can't be empty", err) } @@ -104,7 +104,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { } } -// If the clone url doesn't end with .git it is appended +// If the clone url doesn't end with .git, and VCS is not Azure Devops, it is appended func TestNewRepo_MissingDotGit(t *testing.T) { repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "", "https://bitbucket.org/owner/repo", "u", "p") Ok(t, err) From a17e565ba6bec9cdb09c1a7b0c322eae53e8a7e4 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 7 Apr 2019 01:57:54 -0500 Subject: [PATCH 08/64] - switch to go-azuredevops package - remove ADHostname - add ADOrg and ADProject --- cmd/server.go | 38 +-- cmd/server_test.go | 3 +- server/events/vcs/azuredevops/azuredevops.go | 6 - server/events/vcs/azuredevops/client.go | 225 ------------------ server/events/vcs/azuredevops/client_test.go | 215 ----------------- server/events/vcs/azuredevops/models.go | 88 ------- .../testdata/pull-approved-by-author.json | 166 ------------- .../testdata/pull-approved-multiple.json | 190 --------------- .../azuredevops/testdata/pull-approved.json | 160 ------------- .../azuredevops/testdata/pull-unapproved.json | 135 ----------- server/server.go | 4 +- server/user_config.go | 4 +- 12 files changed, 28 insertions(+), 1206 deletions(-) delete mode 100644 server/events/vcs/azuredevops/azuredevops.go delete mode 100644 server/events/vcs/azuredevops/client.go delete mode 100644 server/events/vcs/azuredevops/client_test.go delete mode 100644 server/events/vcs/azuredevops/models.go delete mode 100644 server/events/vcs/azuredevops/testdata/pull-approved-by-author.json delete mode 100644 server/events/vcs/azuredevops/testdata/pull-approved-multiple.json delete mode 100644 server/events/vcs/azuredevops/testdata/pull-approved.json delete mode 100644 server/events/vcs/azuredevops/testdata/pull-unapproved.json diff --git a/cmd/server.go b/cmd/server.go index 1bb136a32..c319de1e4 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -23,7 +23,6 @@ import ( homedir "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server" - //"github.com/runatlantis/atlantis/server/events/vcs/azuredevops" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/yaml/valid" "github.com/runatlantis/atlantis/server/logging" @@ -37,7 +36,8 @@ import ( // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. - ADHostnameFlag = "azuredevops-hostname" + ADOrgFlag = "azuredevops-org" + ADProjectFlag = "azuredevops-project" ADTokenFlag = "azuredevops-token" ADUserFlag = "azuredevops-user" ADWebhookSecretFlag = "azuredevops-webhook-secret" // nolint: gosec @@ -75,7 +75,7 @@ const ( TFETokenFlag = "tfe-token" // Flag defaults. - DefaultADHostname = "visualstudio.com" + DefaultADOrg = "" DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" @@ -86,9 +86,13 @@ const ( ) var stringFlags = map[string]stringFlag{ - ADHostnameFlag: { - description: "Hostname of your Azure Devops installation.", - defaultValue: DefaultADHostname, + ADOrgFlag: { + description: "Organization name of your Azure Devops instance.", + defaultValue: "", + }, + ADProjectFlag: { + description: "Project name in your Azure Devops insance.", + defaultValue: "", }, ADUserFlag: { description: "Azure Devops username of API user.", @@ -420,8 +424,8 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.DataDir == "" { c.DataDir = DefaultDataDir } - if c.AzureDevopsHostname == "" { - c.AzureDevopsHostname = DefaultADHostname + if c.AzureDevopsOrg == "" { + c.AzureDevopsOrg = DefaultADOrg } if c.GithubHostname == "" { c.GithubHostname = DefaultGHHostname @@ -458,10 +462,11 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { // 1. github user and token set // 2. gitlab user and token set // 3. bitbucket user and token set - // 4. azuredevops user and token set + // 4. azuredevops user, token, org and project set // 5. any combination of the above - vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag) - if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "")) { + vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s/--%s/--%s must be set", GHUserFlag, GHTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag, ADOrgFlag, ADProjectFlag) + if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "") || + (userConfig.AzureDevopsOrg == "") != (userConfig.AzureDevopsProject == "")) { return vcsErr } // At this point, we know that there can't be a single user/token without @@ -481,10 +486,6 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", BitbucketWebhookSecretFlag) } - if userConfig.AzureDevopsUser == DefaultADHostname && userConfig.AzureDevopsToken != "" { - return fmt.Errorf("--%s cannot be specified for Azure Devops because it is not supported by Microsoft", BitbucketWebhookSecretFlag) - } - parsed, err := url.Parse(userConfig.BitbucketBaseURL) if err != nil { return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err) @@ -556,8 +557,11 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs") } - if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsHostname == DefaultADHostname && !s.SilenceOutput { - s.Logger.Warn("Azure Devops does not support webhook secrets without org identifier. This could allow attackers to spoof requests from Azure. Ensure you are whitelisting Azure Devops IPs") + if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookSecret == "" && !s.SilenceOutput { + s.Logger.Warn("No Azure Devops webhook secret set. This could allow attackers to spoof requests from Azure Devops.") + } + if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookSecret == "" && !s.SilenceOutput { + s.Logger.Warn("No Azure Devops webhook secret set. This could allow attackers to spoof requests from Azure Devops.") } } diff --git a/cmd/server_test.go b/cmd/server_test.go index 8a30e4921..ce1890eff 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -379,7 +379,8 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "https://api.bitbucket.org", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) - Equals(t, "https://dev.azure.org", passedConfig.ADBaseURL) + Equals(t, "azuredevops-org", passedConfig.AzureDevopsOrg) + Equals(t, "azuredevops-project", passedConfig.AzureDevopsProject) Equals(t, "azuredevops-token", passedConfig.AzureDevopsToken) Equals(t, "azuredevops-user", passedConfig.AzureDevopsUser) Equals(t, "", passedConfig.BitbucketWebhookSecret) diff --git a/server/events/vcs/azuredevops/azuredevops.go b/server/events/vcs/azuredevops/azuredevops.go deleted file mode 100644 index 313007735..000000000 --- a/server/events/vcs/azuredevops/azuredevops.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package bitbucketcloud holds code for Bitbucket Cloud aka (bitbucket.org). -// It is separate from bitbucketserver because Bitbucket Server has different -// APIs. -package azuredevops - -const BaseURL = "https://visualstudio.com" diff --git a/server/events/vcs/azuredevops/client.go b/server/events/vcs/azuredevops/client.go deleted file mode 100644 index 9f25fad62..000000000 --- a/server/events/vcs/azuredevops/client.go +++ /dev/null @@ -1,225 +0,0 @@ -package azuredevops - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "io/ioutil" - "net/http" - - "github.com/pkg/errors" - "github.com/runatlantis/atlantis/server/events/models" - "gopkg.in/go-playground/validator.v9" -) - -type Client struct { - HTTPClient *http.Client - Username string - Password string - BaseURL string - AtlantisURL string -} - -// NewClient builds a Azure Devops cloud client. atlantisURL is the -// URL for Atlantis that will be linked to from the build status icons. This -// linking is annoying because we don't have anywhere good to link but a URL is -// required. -func NewClient(httpClient *http.Client, username string, password string, atlantisURL string) *Client { - if httpClient == nil { - httpClient = http.DefaultClient - } - return &Client{ - HTTPClient: httpClient, - Username: username, - Password: password, - BaseURL: BaseURL, - AtlantisURL: atlantisURL, - } -} - -// GetModifiedFiles returns the names of files that were modified in the merge request. -// The names include the path to the file from the repo root, ex. parent/child/file.txt. -func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { - var files []string - - nextPageURL := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/diffstat", b.BaseURL, repo.FullName, pull.Num) - // We'll only loop 1000 times as a safety measure. - maxLoops := 1000 - for i := 0; i < maxLoops; i++ { - resp, err := b.makeRequest("GET", nextPageURL, nil) - if err != nil { - return nil, err - } - var diffStat DiffStat - if err := json.Unmarshal(resp, &diffStat); err != nil { - return nil, errors.Wrapf(err, "Could not parse response %q", string(resp)) - } - if err := validator.New().Struct(diffStat); err != nil { - return nil, errors.Wrapf(err, "API response %q was missing fields", string(resp)) - } - for _, v := range diffStat.Values { - if v.Old != nil { - files = append(files, *v.Old.Path) - } - if v.New != nil { - files = append(files, *v.New.Path) - } - } - if diffStat.Next == nil || *diffStat.Next == "" { - break - } - nextPageURL = *diffStat.Next - } - - // Now ensure all files are unique. - hash := make(map[string]bool) - var unique []string - for _, f := range files { - if !hash[f] { - unique = append(unique, f) - hash[f] = true - } - } - return unique, nil -} - -// CreateComment creates a comment on the merge request. -func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string) error { - // NOTE: I tried to find the maximum size of a comment for Azure Devops.org but - // I got up to 200k chars without issue so for now I'm not going to bother - // to detect this. - bodyBytes, err := json.Marshal(map[string]map[string]string{"content": { - "raw": comment, - }}) - if err != nil { - return errors.Wrap(err, "json encoding") - } - path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum) - _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) - return err -} - -// PullIsApproved returns true if the merge request was approved. -func (b *Client) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { - path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num) - resp, err := b.makeRequest("GET", path, nil) - if err != nil { - return false, err - } - var pullResp PullRequest - if err := json.Unmarshal(resp, &pullResp); err != nil { - return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) - } - if err := validator.New().Struct(pullResp); err != nil { - return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) - } - for _, participant := range pullResp.Participants { - // Azure Devops allows the author to approve their own pull request. This - // defeats the purpose of approvals so we don't count that approval. - if *participant.Approved && *participant.User.Username != pull.Author { - return true, nil - } - } - return false, nil -} - -// PullIsMergeable returns true if the merge request has no conflicts and can be merged. -func (b *Client) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) { - // NOTE: The 1.0 API is deprecated, but the 2.0 API does not provide this endpoint. - path := fmt.Sprintf("%s/1.0/repositories/%s/pullrequests/%d/conflict-status", b.BaseURL, repo.FullName, pull.Num) - resp, err := b.makeRequest("GET", path, nil) - if err != nil { - return false, err - } - var conflictStatus ConflictStatus - if err := json.Unmarshal(resp, &conflictStatus); err != nil { - return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) - } - if err := validator.New().Struct(conflictStatus); err != nil { - return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) - } - if !*conflictStatus.MergeImpossible && !*conflictStatus.IsConflicted { - return true, nil - } - return false, nil -} - -// UpdateStatus updates the status of a commit. -func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { - bbState := "FAILED" - switch status { - case models.PendingCommitStatus: - bbState = "INPROGRESS" - case models.SuccessCommitStatus: - bbState = "SUCCESSFUL" - case models.FailedCommitStatus: - bbState = "FAILED" - } - - // URL is a required field for Azure Devops statuses. We default to the - // Atlantis server's URL. - if url == "" { - url = b.AtlantisURL - } - - bodyBytes, err := json.Marshal(map[string]string{ - "key": src, - "url": url, - "state": bbState, - "description": description, - }) - - path := fmt.Sprintf("%s/2.0/repositories/%s/commit/%s/statuses/build", b.BaseURL, repo.FullName, pull.HeadCommit) - if err != nil { - return errors.Wrap(err, "json encoding") - } - _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) - return err -} - -// MergePull merges the pull request. -func (b *Client) MergePull(pull models.PullRequest) error { - path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/merge", b.BaseURL, pull.BaseRepo.FullName, pull.Num) - _, err := b.makeRequest("POST", path, nil) - return err -} - -// prepRequest adds auth and necessary headers. -func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequest(method, path, body) - if err != nil { - return nil, err - } - req.SetBasicAuth(b.Username, b.Password) - if body != nil { - req.Header.Add("Content-Type", "application/json") - } - // Add this header to disable CSRF checks. - // See https://confluence.atlassian.com/cloudkb/xsrf-check-failed-when-calling-cloud-apis-826874382.html - //req.Header.Add("X-Atlassian-Token", "no-check") - return req, nil -} - -func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]byte, error) { - req, err := b.prepRequest(method, path, reqBody) - if err != nil { - return nil, errors.Wrap(err, "constructing request") - } - resp, err := b.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() // nolint: errcheck - requestStr := fmt.Sprintf("%s %s", method, path) - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - respBody, _ := ioutil.ReadAll(resp.Body) - return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) - } - respBody, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrapf(err, "reading response from request %q", requestStr) - } - return respBody, nil -} diff --git a/server/events/vcs/azuredevops/client_test.go b/server/events/vcs/azuredevops/client_test.go deleted file mode 100644 index d8c23ff43..000000000 --- a/server/events/vcs/azuredevops/client_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package azuredevops_test - -import ( - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/runatlantis/atlantis/server/events/models" - "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" - . "github.com/runatlantis/atlantis/testing" -) - -// Should follow pagination properly. -func TestClient_GetModifiedFilesPagination(t *testing.T) { - respTemplate := ` -{ - "pagelen": 1, - "values": [ - { - "type": "diffstat", - "status": "modified", - "lines_removed": 1, - "lines_added": 2, - "old": { - "path": "%s", - "type": "commit_file", - "links": { - "self": { - "href": "https://visualstudio.com/org/2.0/repositories/bitbucket/geordi/src/e1749643d655d7c7014001a6c0f58abaf42ad850/setup.py" - } - } - }, - "new": { - "path": "%s", - "type": "commit_file", - "links": { - "self": { - "href": "https://visualstudio.com/org/2.0/repositories/bitbucket/geordi/src/d222fa235229c55dad20b190b0b571adf737d5a6/setup.py" - } - } - } - } - ], - "page": 1, - "size": 1 -` - firstResp := fmt.Sprintf(respTemplate, "file1.txt", "file2.txt") - secondResp := fmt.Sprintf(respTemplate, "file2.txt", "file3.txt") - var serverURL string - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.RequestURI { - // The first request should hit this URL. - case "/2.0/repositories/owner/repo/pullrequests/1/diffstat": - resp := firstResp + fmt.Sprintf(`,"next": "%s/2.0/repositories/owner/repo/pullrequests/1/diffstat?page=2"}`, serverURL) - w.Write([]byte(resp)) // nolint: errcheck - return - // The second should hit this URL. - case "/2.0/repositories/owner/repo/pullrequests/1/diffstat?page=2": - w.Write([]byte(secondResp + "}")) // nolint: errcheck - default: - t.Errorf("got unexpected request at %q", r.RequestURI) - http.Error(w, "not found", http.StatusNotFound) - return - } - })) - defer testServer.Close() - - serverURL = testServer.URL - client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") - client.BaseURL = testServer.URL - - files, err := client.GetModifiedFiles(models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - CloneURL: "", - SanitizedCloneURL: "", - VCSHost: models.VCSHost{ - Type: models.AzureDevops, - Hostname: "visualstudio.com", - }, - }, models.PullRequest{ - Num: 1, - }) - Ok(t, err) - Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) -} - -// If the "old" key in the list of files is nil we shouldn't error. -func TestClient_GetModifiedFilesOldNil(t *testing.T) { - resp := ` -{ - "pagelen": 500, - "values": [ - { - "status": "added", - "old": null, - "lines_removed": 0, - "lines_added": 2, - "new": { - "path": "parent/child/file1.txt", - "type": "commit_file", - "links": { - "self": { - "href": "https://visualstudio.com/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" - } - } - }, - "type": "diffstat" - } - ], - "page": 1, - "size": 1 -}` - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.RequestURI { - // The first request should hit this URL. - case "/2.0/repositories/owner/repo/pullrequests/1/diffstat": - w.Write([]byte(resp)) // nolint: errcheck - return - default: - t.Errorf("got unexpected request at %q", r.RequestURI) - http.Error(w, "not found", http.StatusNotFound) - return - } - })) - defer testServer.Close() - - client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") - client.BaseURL = testServer.URL - - files, err := client.GetModifiedFiles(models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - CloneURL: "", - SanitizedCloneURL: "", - VCSHost: models.VCSHost{ - Type: models.AzureDevops, - Hostname: "visualstudio.com", - }, - }, models.PullRequest{ - Num: 1, - }) - Ok(t, err) - Equals(t, []string{"parent/child/file1.txt"}, files) -} - -func TestClient_PullIsApproved(t *testing.T) { - cases := []struct { - description string - testdata string - exp bool - }{ - { - "no approvers", - "pull-unapproved.json", - false, - }, - { - "approver is the author", - "pull-approved-by-author.json", - false, - }, - { - "single approver", - "pull-approved.json", - true, - }, - { - "two approvers one author", - "pull-approved-multiple.json", - true, - }, - } - - for _, c := range cases { - t.Run(c.description, func(t *testing.T) { - json, err := ioutil.ReadFile(filepath.Join("testdata", c.testdata)) - Ok(t, err) - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.RequestURI { - // The first request should hit this URL. - case "/2.0/repositories/owner/repo/pullrequests/1": - w.Write(json) // nolint: errcheck - return - default: - t.Errorf("got unexpected request at %q", r.RequestURI) - http.Error(w, "not found", http.StatusNotFound) - return - } - })) - defer testServer.Close() - - client := azuredevops.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") - client.BaseURL = testServer.URL - - repo, err := models.NewRepo(models.AzureDevops, "owner/repo", "https://visualstudio.com/owner/repo.git", "user", "token") - Ok(t, err) - approved, err := client.PullIsApproved(repo, models.PullRequest{ - Num: 1, - HeadBranch: "branch", - Author: "author", - BaseRepo: repo, - }) - Ok(t, err) - Equals(t, c.exp, approved) - }) - } -} diff --git a/server/events/vcs/azuredevops/models.go b/server/events/vcs/azuredevops/models.go deleted file mode 100644 index 25b7859c4..000000000 --- a/server/events/vcs/azuredevops/models.go +++ /dev/null @@ -1,88 +0,0 @@ -package azuredevops - -const ( - PullCreatedHeader = "git.pullrequest.created" - PullUpdatedHeader = "git.pullrequest.updated" - PullFulfilledHeader = "git.pullrequest.merged" - PullRejectedHeader = "pullrequest:rejected" - PullCommentCreatedHeader = "workitem.commented" -) - -type CommentEvent struct { - CommonEventData - Comment *Comment `json:"comment,omitempty" validate:"required"` -} - -type PullRequestEvent struct { - CommonEventData -} - -type CommonEventData struct { - Actor *Actor `json:"actor,omitempty" validate:"required"` - Repository *Repository `json:"repository,omitempty" validate:"required"` - PullRequest *PullRequest `json:"pullrequest,omitempty" validate:"required"` -} - -type DiffStat struct { - Values []DiffStatValue `json:"values,omitempty" validate:"required"` - Next *string `json:"next,omitempty"` -} -type DiffStatValue struct { - // Old is the old file, this can be null. - Old *DiffStatFile `json:"old,omitempty"` - // New is the new file, this can be null. - New *DiffStatFile `json:"new,omitempty"` -} -type DiffStatFile struct { - Path *string `json:"path,omitempty" validate:"required"` -} - -type Actor struct { - Nickname *string `json:"nickname,omitempty" validate:"required"` -} -type Repository struct { - FullName *string `json:"full_name,omitempty" validate:"required"` - Links Links `json:"links,omitempty" validate:"required"` -} -type PullRequest struct { - ID *int `json:"id,omitempty" validate:"required"` - Source *BranchMeta `json:"source,omitempty" validate:"required"` - Destination *BranchMeta `json:"destination,omitempty" validate:"required"` - Participants []Participant `json:"participants,omitempty" validate:"required"` - Links *Links `json:"links,omitempty" validate:"required"` - State *string `json:"state,omitempty" validate:"required"` -} -type Links struct { - HTML *Link `json:"html,omitempty" validate:"required"` -} -type Link struct { - HREF *string `json:"href,omitempty" validate:"required"` -} -type Participant struct { - Approved *bool `json:"approved,omitempty" validate:"required"` - User *struct { - Username *string `json:"username,omitempty" validate:"required"` - } `json:"user,omitempty" validate:"required"` -} -type BranchMeta struct { - Repository *Repository `json:"repository,omitempty" validate:"required"` - Commit *Commit `json:"commit,omitempty" validate:"required"` - Branch *Branch `json:"branch,omitempty" validate:"required"` -} -type Branch struct { - Name *string `json:"name,omitempty" validate:"required"` -} -type Commit struct { - Hash *string `json:"hash,omitempty" validate:"required"` -} -type Comment struct { - Content *CommentContent `json:"content,omitempty" validate:"required"` -} -type CommentContent struct { - Raw *string `json:"raw,omitempty" validate:"required"` -} - -type ConflictStatus struct { - MergeImpossible *bool `json:"mergeimpossible,omitempty" validate:"required"` - IsConflicted *bool `json:"isconflicted,omitempty" validate:"required"` -} diff --git a/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json b/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json deleted file mode 100644 index 87e1b13b5..000000000 --- a/server/events/vcs/azuredevops/testdata/pull-approved-by-author.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "type": "pullrequest", - "description": "main.tf edited online with Bitbucket", - "links": { - "decline": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" - }, - "commits": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" - }, - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" - }, - "comments": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" - }, - "merge": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" - }, - "activity": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" - }, - "diff": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" - }, - "approve": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" - }, - "statuses": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" - } - }, - "title": "main.tf edited online with Bitbucket", - "close_source_branch": true, - "reviewers": [], - "id": 5, - "destination": { - "commit": { - "hash": "fe607a7f5172", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - }, - "avatar": { - "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "master" - } - }, - "created_on": "2018-07-25T12:23:21.100810+00:00", - "summary": { - "raw": "main.tf edited online with Bitbucket", - "markup": "markdown", - "html": "

    main.tf edited online with Bitbucket

    ", - "type": "rendered" - }, - "source": { - "commit": { - "hash": "3428957ade18", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - }, - "avatar": { - "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" - } - }, - "comment_count": 3, - "state": "OPEN", - "task_count": 0, - "participants": [ - { - "role": "PARTICIPANT", - "participated_on": "2018-07-28T00:06:42.255492+00:00", - "type": "participant", - "approved": true, - "user": { - "username": "author", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - } - } - ], - "reason": "", - "updated_on": "2018-07-28T00:06:42.257659+00:00", - "author": { - "username": "lkysow", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - }, - "merge_commit": null, - "closed_by": null -} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json b/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json deleted file mode 100644 index 32ae46622..000000000 --- a/server/events/vcs/azuredevops/testdata/pull-approved-multiple.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "type": "pullrequest", - "description": "main.tf edited online with Bitbucket", - "links": { - "decline": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" - }, - "commits": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" - }, - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" - }, - "comments": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" - }, - "merge": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" - }, - "activity": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" - }, - "diff": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" - }, - "approve": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" - }, - "statuses": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" - } - }, - "title": "main.tf edited online with Bitbucket", - "close_source_branch": true, - "reviewers": [], - "id": 5, - "destination": { - "commit": { - "hash": "fe607a7f5172", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - }, - "avatar": { - "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "master" - } - }, - "created_on": "2018-07-25T12:23:21.100810+00:00", - "summary": { - "raw": "main.tf edited online with Bitbucket", - "markup": "markdown", - "html": "

    main.tf edited online with Bitbucket

    ", - "type": "rendered" - }, - "source": { - "commit": { - "hash": "3428957ade18", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - }, - "avatar": { - "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" - } - }, - "comment_count": 3, - "state": "OPEN", - "task_count": 0, - "participants": [ - { - "role": "PARTICIPANT", - "participated_on": "2018-07-28T00:06:42.255492+00:00", - "type": "participant", - "approved": true, - "user": { - "username": "author", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - } - }, - { - "role": "PARTICIPANT", - "participated_on": "2018-07-28T00:06:42.255492+00:00", - "type": "participant", - "approved": true, - "user": { - "username": "approver", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - } - } - ], - "reason": "", - "updated_on": "2018-07-28T00:06:42.257659+00:00", - "author": { - "username": "lkysow", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - }, - "merge_commit": null, - "closed_by": null -} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-approved.json b/server/events/vcs/azuredevops/testdata/pull-approved.json deleted file mode 100644 index c40340d28..000000000 --- a/server/events/vcs/azuredevops/testdata/pull-approved.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "type": "pullrequest", - "description": "main.tf edited online with Bitbucket", - "links": { - "decline": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" - }, - "commits": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" - }, - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" - }, - "comments": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" - }, - "merge": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" - }, - "activity": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" - }, - "diff": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" - }, - "approve": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" - }, - "statuses": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" - } - }, - "title": "main.tf edited online with Bitbucket", - "close_source_branch": true, - "reviewers": [], - "id": 5, - "destination": { - "commit": { - "hash": "fe607a7f5172", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "master" - } - }, - "created_on": "2018-07-25T12:23:21.100810+00:00", - "summary": { - "raw": "main.tf edited online with Bitbucket", - "markup": "markdown", - "html": "

    main.tf edited online with Bitbucket

    ", - "type": "rendered" - }, - "source": { - "commit": { - "hash": "3428957ade18", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" - } - }, - "comment_count": 3, - "state": "OPEN", - "task_count": 0, - "participants": [ - { - "role": "PARTICIPANT", - "participated_on": "2018-07-28T00:06:42.255492+00:00", - "type": "participant", - "approved": true, - "user": { - "username": "approver", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - } - } - ], - "reason": "", - "updated_on": "2018-07-28T00:06:42.257659+00:00", - "author": { - "username": "lkysow", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - }, - "merge_commit": null, - "closed_by": null -} \ No newline at end of file diff --git a/server/events/vcs/azuredevops/testdata/pull-unapproved.json b/server/events/vcs/azuredevops/testdata/pull-unapproved.json deleted file mode 100644 index 9d8de84ac..000000000 --- a/server/events/vcs/azuredevops/testdata/pull-unapproved.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "type": "pullrequest", - "description": "main.tf edited online with Bitbucket", - "links": { - "decline": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/decline" - }, - "commits": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/commits" - }, - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5" - }, - "comments": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/comments" - }, - "merge": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/merge" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/5" - }, - "activity": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/activity" - }, - "diff": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/diff" - }, - "approve": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/approve" - }, - "statuses": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/5/statuses" - } - }, - "title": "main.tf edited online with Bitbucket", - "close_source_branch": true, - "reviewers": [], - "id": 5, - "destination": { - "commit": { - "hash": "fe607a7f5172", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/fe607a7f5172" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/fe607a7f5172" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "master" - } - }, - "created_on": "2018-07-25T12:23:21.100810+00:00", - "summary": { - "raw": "main.tf edited online with Bitbucket", - "markup": "markdown", - "html": "

    main.tf edited online with Bitbucket

    ", - "type": "rendered" - }, - "source": { - "commit": { - "hash": "3428957ade18", - "type": "commit", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3428957ade18" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example/commits/3428957ade18" - } - } - }, - "repository": { - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" - }, - "html": { - "href": "https://bitbucket.org/lkysow/atlantis-example" - } - }, - "type": "repository", - "name": "atlantis-example", - "full_name": "lkysow/atlantis-example", - "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" - }, - "branch": { - "name": "lkysow/maintf-edited-online-with-bitbucket-1532521398289" - } - }, - "comment_count": 3, - "state": "OPEN", - "task_count": 0, - "participants": [], - "reason": "", - "updated_on": "2018-07-28T00:06:42.257659+00:00", - "author": { - "username": "lkysow", - "display_name": "Luke", - "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", - "links": { - "self": { - "href": "https://api.bitbucket.org/2.0/users/lkysow" - }, - "html": { - "href": "https://bitbucket.org/lkysow/" - }, - "avatar": { - "href": "https://bitbucket.org/account/lkysow/avatar/" - } - }, - "type": "user", - "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" - }, - "merge_commit": null, - "closed_by": null -} \ No newline at end of file diff --git a/server/server.go b/server/server.go index beb5feca1..709e548df 100644 --- a/server/server.go +++ b/server/server.go @@ -32,6 +32,7 @@ import ( "github.com/runatlantis/atlantis/server/events/db" "github.com/runatlantis/atlantis/server/events/yaml/valid" + "github.com/benmatselby/go-azuredevops/azuredevops" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -41,7 +42,6 @@ import ( "github.com/runatlantis/atlantis/server/events/runtime" "github.com/runatlantis/atlantis/server/events/terraform" "github.com/runatlantis/atlantis/server/events/vcs" - "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/webhooks" @@ -237,6 +237,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { BitbucketServerURL: userConfig.BitbucketBaseURL, AzureDevopsUser: userConfig.AzureDevopsUser, AzureDevopsToken: userConfig.AzureDevopsToken, + AzureDevopsOrg: userConfig.AzureDevopsOrg, + AzureDevopsProject: userConfig.AzureDevopsProject, } commentParser := &events.CommentParser{ GithubUser: userConfig.GithubUser, diff --git a/server/user_config.go b/server/user_config.go index beb61ded9..22b400f9d 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -10,8 +10,8 @@ type UserConfig struct { AllowRepoConfig bool `mapstructure:"allow-repo-config"` AtlantisURL string `mapstructure:"atlantis-url"` Automerge bool `mapstructure:"automerge"` - AzureDevopsBaseURL string `mapstructure:"azuredevops-base-url"` - AzureDevopsHostname string `mapstructure:"azuredevops-hostname"` + AzureDevopsOrg string `mapstructure:"azuredevops-org"` + AzureDevopsProject string `mapstructure:"azuredevops-project"` AzureDevopsToken string `mapstructure:"azuredevops-token"` AzureDevopsUser string `mapstructure:"azuredevops-user"` AzureDevopsWebhookSecret string `mapstructure:"azuredevops-webhook-secret"` From 2c832387d71448e7ace16cfcbd1e789c8d1f7dcb Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 13 Apr 2019 11:14:44 -0500 Subject: [PATCH 09/64] add azure devops cmd flags --- cmd/server.go | 34 ++++++++++++++++++++++------------ cmd/server_test.go | 7 ++++++- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index c319de1e4..0d97c5829 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -36,11 +36,13 @@ import ( // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. + ADBasicAuthFlag = "azuredevops-basic-auth" + ADBasicPasswordFlag = "azuredevops-basic-password" // inbound requests + ADBasicUserFlag = "azuredevops-basic-user" // inbound requests ADOrgFlag = "azuredevops-org" ADProjectFlag = "azuredevops-project" - ADTokenFlag = "azuredevops-token" - ADUserFlag = "azuredevops-user" - ADWebhookSecretFlag = "azuredevops-webhook-secret" // nolint: gosec + ADTokenFlag = "azuredevops-token" // outbound requests + ADUserFlag = "azuredevops-user" // outbound requests AllowForkPRsFlag = "allow-fork-prs" AllowRepoConfigFlag = "allow-repo-config" AtlantisURLFlag = "atlantis-url" @@ -75,7 +77,7 @@ const ( TFETokenFlag = "tfe-token" // Flag defaults. - DefaultADOrg = "" + DefaultADBasicAuth = true DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" @@ -86,6 +88,20 @@ const ( ) var stringFlags = map[string]stringFlag{ + ADBasicAuthFlag: { + description: "Enable/disable basic auth for validating Azure Devops webhooks (see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure Devops org. " + + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + + "Should be specified via the ATLANTIS_AD_BASIC_AUTH environment variable.", + }, + ADBasicPasswordFlag: { + description: "Azure Devops basic authentication password for inbound webhooks.", + defaultValue: "", + }, + ADBasicUserFlag: { + description: "Azure Devops basic authentication username for inbound webhooks.", + defaultValue: "", + }, ADOrgFlag: { description: "Organization name of your Azure Devops instance.", defaultValue: "", @@ -94,17 +110,11 @@ var stringFlags = map[string]stringFlag{ description: "Project name in your Azure Devops insance.", defaultValue: "", }, - ADUserFlag: { - description: "Azure Devops username of API user.", - }, ADTokenFlag: { description: "Azure Devops token of API user. Can also be specified via the ATLANTIS_DO_TOKEN environment variable.", }, - ADWebhookSecretFlag: { - description: "Secret used to validate Azure Devops webhooks (see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + - " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure Devops org. " + - "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + - "Should be specified via the ATLANTIS_DO_WEBHOOK_SECRET environment variable.", + ADUserFlag: { + description: "Azure Devops username of API user.", }, AtlantisURLFlag: { description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.", diff --git a/cmd/server_test.go b/cmd/server_test.go index ce1890eff..c3af356a4 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -346,8 +346,13 @@ func TestExecute_Defaults(t *testing.T) { cmd.GitlabTokenFlag: "gitlab-token", cmd.BitbucketUserFlag: "bitbucket-user", cmd.BitbucketTokenFlag: "bitbucket-token", - cmd.ADUserFlag: "azuredevops-user", + cmd.ADBasicAuthFlag: "azuredevops-basic-auth", + cmd.ADBasicUser: "azuredevops-basic-user", + cmd.ADBasicPassowrd: "azuredevops-basic-password", + cmd.ADOrgFlag: "azuredevops-org", + cmd.ADProjectFlag: "azuredevops-project", cmd.ADTokenFlag: "azuredevops-token", + cmd.ADUserFlag: "azuredevops-user", cmd.RepoWhitelistFlag: "*", }) err := c.Execute() From 811a2a6ec9bac910e5bf90571e561542798bd76b Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 13 Apr 2019 21:36:21 -0500 Subject: [PATCH 10/64] replace azuredevops secret for basic auth --- server/user_config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/user_config.go b/server/user_config.go index 22b400f9d..44c8d7c1f 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -10,11 +10,12 @@ type UserConfig struct { AllowRepoConfig bool `mapstructure:"allow-repo-config"` AtlantisURL string `mapstructure:"atlantis-url"` Automerge bool `mapstructure:"automerge"` + AzureDevopsBasicPassword string `mapstructure:"azuredevops-basic-password"` + AzureDevopsBasicUser string `mapstructure:"azuredevops-basic-user"` AzureDevopsOrg string `mapstructure:"azuredevops-org"` AzureDevopsProject string `mapstructure:"azuredevops-project"` AzureDevopsToken string `mapstructure:"azuredevops-token"` AzureDevopsUser string `mapstructure:"azuredevops-user"` - AzureDevopsWebhookSecret string `mapstructure:"azuredevops-webhook-secret"` BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` BitbucketToken string `mapstructure:"bitbucket-token"` BitbucketUser string `mapstructure:"bitbucket-user"` From 40593c7f7e0982283ee7f8a8a18ba83635ea3338 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 14 Apr 2019 18:18:06 -0500 Subject: [PATCH 11/64] change go-azuredevops package constraint location --- Gopkg.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gopkg.toml b/Gopkg.toml index db303bb4a..f905e9f97 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -98,7 +98,7 @@ [[constraint]] branch = "master" - name = "github.com/benmatselby/go-azuredevops" + name = "github.com/mcdafydd/go-azuredevops" [[constraint]] name = "github.com/go-test/deep" From b7ff1c23b156a230ddcfafd18efc980113d8ac5f Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 14 Apr 2019 18:19:05 -0500 Subject: [PATCH 12/64] add project parameter to bitbucketcloud NewRepo() --- server/events/vcs/bitbucketcloud/client_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/events/vcs/bitbucketcloud/client_test.go b/server/events/vcs/bitbucketcloud/client_test.go index d9fda3f5a..e4ecb762c 100644 --- a/server/events/vcs/bitbucketcloud/client_test.go +++ b/server/events/vcs/bitbucketcloud/client_test.go @@ -200,7 +200,7 @@ func TestClient_PullIsApproved(t *testing.T) { client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") client.BaseURL = testServer.URL - repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "https://bitbucket.org/owner/repo.git", "user", "token") + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "", "https://bitbucket.org/owner/repo.git", "user", "token") Ok(t, err) approved, err := client.PullIsApproved(repo, models.PullRequest{ Num: 1, From a058a661ebfc865275d9702cb355037734862a46 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Apr 2019 09:05:29 -0500 Subject: [PATCH 13/64] initial commit of azuredevops client --- server/azuredevops_request_validator.go | 71 +++++ server/azuredevops_request_validator_test.go | 113 ++++++++ server/events/vcs/azuredevops_client.go | 274 +++++++++++++++++++ server/events/vcs/azuredevops_client_test.go | 209 ++++++++++++++ 4 files changed, 667 insertions(+) create mode 100644 server/azuredevops_request_validator.go create mode 100644 server/azuredevops_request_validator_test.go create mode 100644 server/events/vcs/azuredevops_client.go create mode 100644 server/events/vcs/azuredevops_client_test.go diff --git a/server/azuredevops_request_validator.go b/server/azuredevops_request_validator.go new file mode 100644 index 000000000..256a9cf9e --- /dev/null +++ b/server/azuredevops_request_validator.go @@ -0,0 +1,71 @@ +// Copyright 2017 HootSuite Media Inc. +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Modified hereafter by contributors to runatlantis/atlantis. + +package server + +import ( + "fmt" + "io/ioutil" + "net/http" + + "github.com/mcdafydd/go-azuredevops/azuredevops" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_azuredevops_request_validator.go AzureDevopsRequestValidator + +// AzureDevopsRequestValidator handles checking if Azure Devops requests +// contain a valid Basic authentication username and password. +type AzureDevopsRequestValidator interface { + // Validate returns the JSON payload of the request. + // If both username and password values have a length greater than zero, + // it checks that the credentials match those configured in Atlantis. + // If either username or password have a length of zero, the payload is + // returned without further checking. + Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) +} + +// DefaultAzureDevopsRequestValidator handles checking if Azure Devops +// requests contain the correct Basic auth username and password. +type DefaultAzureDevopsRequestValidator struct{} + +// Validate returns the JSON payload of the request. +// If secret is not empty, it checks that the request was signed +// by secret and returns an error if it was not. +// If secret is empty, it does not check if the request was signed. +func (d *DefaultAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) { + if len(user) != 0 && len(pass) != 0 { + return d.validateWithBasicAuth(r, user, pass) + } + return d.validateWithoutBasicAuth(r) +} + +func (d *DefaultAzureDevopsRequestValidator) validateWithBasicAuth(r *http.Request, user []byte, pass []byte) ([]byte, error) { + payload, err := azuredevops.ValidatePayload(r, user, pass) + if err != nil { + return nil, err + } + return payload, nil +} + +func (d *DefaultAzureDevopsRequestValidator) validateWithoutBasicAuth(r *http.Request) ([]byte, error) { + switch ct := r.Header.Get("Content-Type"); ct { + case "application/json": + payload, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("could not read body: %s", err) + } + return payload, nil + default: + return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) + } +} diff --git a/server/azuredevops_request_validator_test.go b/server/azuredevops_request_validator_test.go new file mode 100644 index 000000000..0461d7e79 --- /dev/null +++ b/server/azuredevops_request_validator_test.go @@ -0,0 +1,113 @@ +// Copyright 2017 HootSuite Media Inc. +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Modified hereafter by contributors to runatlantis/atlantis. + +package server_test + +import ( + "bytes" + "net/http" + "net/url" + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server" + . "github.com/runatlantis/atlantis/testing" +) + +func TestValidate_WithSecretErr(t *testing.T) { + t.Log("if the request is not valid against the secret there is an error") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + buf := bytes.NewBufferString("") + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") + req.Header.Set("Content-Type", "application/json") + + _, err = g.Validate(req, []byte("secret")) + Assert(t, err != nil, "error should not be nil") + Equals(t, "payload signature check failed", err.Error()) +} + +func TestValidate_WithSecret(t *testing.T) { + t.Log("if the request is valid against the secret the payload is returned") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + buf := bytes.NewBufferString(`{"yo":true}`) + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") + req.Header.Set("Content-Type", "application/json") + + bs, err := g.Validate(req, []byte("0123456789abcdef")) + Ok(t, err) + Equals(t, `{"yo":true}`, string(bs)) +} + +func TestValidate_WithoutSecretInvalidContentType(t *testing.T) { + t.Log("if the request has an invalid content type an error is returned") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + buf := bytes.NewBufferString("") + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("Content-Type", "invalid") + + _, err = g.Validate(req, nil) + Assert(t, err != nil, "error should not be nil") + Equals(t, "webhook request has unsupported Content-Type \"invalid\"", err.Error()) +} + +func TestValidate_WithoutSecretJSON(t *testing.T) { + t.Log("if the request is JSON the body is returned") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + buf := bytes.NewBufferString(`{"yo":true}`) + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("Content-Type", "application/json") + + bs, err := g.Validate(req, nil) + Ok(t, err) + Equals(t, `{"yo":true}`, string(bs)) +} + +func TestValidate_WithoutSecretFormNoPayload(t *testing.T) { + t.Log("if the request is form encoded and does not contain a payload param an error is returned") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + buf := bytes.NewBufferString("") + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + _, err = g.Validate(req, nil) + Assert(t, err != nil, "error should not be nil") + Equals(t, "webhook request did not contain expected 'payload' form value", err.Error()) +} + +func TestValidate_WithoutSecretForm(t *testing.T) { + t.Log("if the request is form encoded and does not contain a payload param an error is returned") + RegisterMockTestingT(t) + g := server.DefaultAzureDevopsRequestValidator{} + form := url.Values{} + form.Set("payload", `{"yo":true}`) + buf := bytes.NewBufferString(form.Encode()) + req, err := http.NewRequest("POST", "http://localhost/event", buf) + Ok(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + bs, err := g.Validate(req, nil) + Ok(t, err) + Equals(t, `{"yo":true}`, string(bs)) +} diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go new file mode 100644 index 000000000..3e4d45de8 --- /dev/null +++ b/server/events/vcs/azuredevops_client.go @@ -0,0 +1,274 @@ +// Copyright 2017 HootSuite Media Inc. +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Modified hereafter by contributors to runatlantis/atlantis. + +package vcs + +import ( + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + version "github.com/hashicorp/go-version" + "github.com/mcdafydd/go-azuredevops/azuredevops" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/vcs/common" + "github.com/runatlantis/atlantis/server/logging" + + "github.com/runatlantis/atlantis/server/events/models" +) + +type AzureDevopsClient struct { + Client *azuredevops.Client + // Version is set to the server version. + Version *version.Version +} + +// azuredevopsClientUnderTest is true if we're running under go test. +var azuredevopsClientUnderTest = false + +// NewAzureDevopsClient returns a valid Azure Devops client. +func NewAzureDevopsClient(hostname string, account string, project string, token string, logger *logging.SimpleLogger) (*AzureDevopsClient, error) { + var httpClient = http.Client{ + Timeout: time.Second * 10, + } + var adClient, err = azuredevops.NewClient(account, project, token, &httpClient) + if err != nil { + return nil, errors.Wrapf(err, "azuredevops.NewClient() %p", adClient) + } + client := &AzureDevopsClient{ + Client: adClient, + } + + // If not using dev.azure.com we need to set the URL to the API. + if hostname != "dev.azure.com" { + // We assume the url will be over HTTPS if the user doesn't specify a scheme. + absoluteURL := hostname + if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { + absoluteURL = "https://" + absoluteURL + } + + url, err := url.Parse(absoluteURL) + if err != nil { + return nil, errors.Wrapf(err, "parsing URL %q", absoluteURL) + } + + // Warn if this hostname isn't resolvable. + ips, err := net.LookupIP(url.Hostname()) + if err != nil { + logger.Warn("unable to resolve %q: %s", url.Hostname(), err) + } else if len(ips) == 0 { + logger.Warn("found no IPs while resolving %q", url.Hostname()) + } + + // Now we're ready to construct the client. + absoluteURL = strings.TrimSuffix(absoluteURL, "/") + } + + return client, nil +} + +// GetModifiedFiles returns the names of files that were modified in the merge request. +// The names include the path to the file from the repo root, ex. parent/child/file.txt. +func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { + var files []string + + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20requests?view=azure-devops-rest-5.1 + apiURL := fmt.Sprintf("_apis/git/pullrequests/%d?api-version=5.1-preview.1", pull.Num) + req, err := g.Client.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + + var commitIdResponse azuredevops.GitPullRequest + _, err = g.Client.Execute(req, &commitIdResponse) + commitId := commitIdResponse.GetLastMergeSourceCommit().GetCommitID() + + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20changes?view=azure-devops-rest-5.1 + apiURL = fmt.Sprintf("_apis/git/repositories/%s/commits/%s/changes?api-version=5.1-preview.1", url.QueryEscape(repo.Name), commitId) + req, err = g.Client.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + + var response azuredevops.GitCommitChanges + _, err = g.Client.Execute(req, &response) + + for _, change := range response.GetChanges() { + item := change.GetItem() + files = append(files, item.GetPath()) + + // If the file was renamed, we'll want to run plan in the directory + // it was moved from as well. + if change.ChangeType == azuredevops.Rename { + files = append(files, change.GetSourceServerItem()) + } + } + + return files, nil +} + +// CreateComment creates a comment on the merge request. +// If comment length is greater than the max comment length we split into +// multiple comments. +func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment string) error { + sepEnd := "\n```\n" + + "\n
    \n\n**Warning**: Output length greater than max comment size. Continued in next comment." + sepStart := "Continued from previous comment.\n
    Show Output\n\n" + + "```diff\n" + + maxCommentLength := 72 + comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart) + adComments := make([]azuredevops.Comment, len(comments)) + for i, c := range comments { + entry := azuredevops.Comment{ + CommentType: azuredevops.CommentTypeText, + Content: &c, + } + adComments[i] = entry + } + // Need to create a thread first and save the thread number from response + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20threads/create#comment_on_the_pull_request + apiURL := fmt.Sprintf("_apis/git/repositories/%s/pullRequests/%d/threads?api-version=5.1-preview.1", url.QueryEscape(repo.Name), pullNum) + req, err := g.Client.NewRequest("POST", apiURL, nil) + if err != nil { + return err + } + var threadResp azuredevops.GitPullRequestCommentThread + _, err = g.Client.Execute(req, &threadResp) + + threadID := threadResp.GetID() + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20thread%20comments/create + apiURL = fmt.Sprintf("_apis/git/repositories/%s/pullRequests/%d/threads/%d/comments?api-version=5.1-preview.1", url.QueryEscape(repo.Name), pullNum, threadID) + threadResp.Comments = &adComments + req, err = g.Client.NewRequest("POST", apiURL, threadResp) + if err != nil { + return err + } + var response azuredevops.GitPullRequestCommentThread + _, err = g.Client.Execute(req, &response) + + return nil +} + +// PullIsApproved returns true if the merge request was approved. +func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { + // previews List() args were g.ctx, repo.Owner, repo.Name, pull.Num, nil + opts := &azuredevops.PullRequestListOptions{} + adPull, _, err := g.Client.PullRequests.Get(pull.Num, opts) + if err != nil { + return false, errors.Wrap(err, "getting pull request") + } + reviews := adPull.GetReviewers() + if err != nil { + return false, errors.Wrap(err, "getting list of pull request reviewers") + } + for _, review := range reviews { + if *review.Vote == azuredevops.VoteApproved { + return true, nil + } + } + // what happens if no reviewers? must have reviewers? handle both states + //return false, nil + return true, nil +} + +// PullIsMergeable returns true if the merge request can be merged. +func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) { + + opts := &azuredevops.PullRequestListOptions{} + adPull, _, err := g.Client.PullRequests.Get(pull.Num, opts) + if err != nil { + return false, errors.Wrap(err, "getting pull request") + } + if adPull.MergeStatus != azuredevops.MergeConflicts && adPull.MergeStatus != azuredevops.MergeRejectedByPolicy { + return false, nil + } + return true, nil +} + +// UpdateStatus updates the build status of a commit. +func (g *AzureDevopsClient) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { + adState := azuredevops.GitError + switch state { + case models.PendingCommitStatus: + adState = azuredevops.GitPending + case models.SuccessCommitStatus: + adState = azuredevops.GitSucceeded + case models.FailedCommitStatus: + adState = azuredevops.GitFailed + } + + genreStr := "Atlantis Bot" + status := &azuredevops.GitStatus{ + State: adState, + Description: &description, + Context: &azuredevops.GitStatusContext{ + Name: &src, + Genre: &genreStr, + }, + TargetURL: &url, + } + _, _, err := g.Client.Git.CreateStatus(repo.Name, pull.HeadCommit, *status) + return err +} + +// MergePull merges the merge request. +func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { + // Users can set their repo to disallow certain types of merging. + // We detect which types aren't allowed and use the type that is. + + // Azure Devops supports squash merge and noFastForward + // in branch policies + // Ignoring these for now + // https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops + + /* + const ( + defaultMergeMethod = "merge" + ) + method := defaultMergeMethod + */ + // Now we're ready to make our API call to merge the pull request. + /* + options := &azuredevops.GitPullRequestCompletionOptions{ + Status: "completed", + */ + // id *IdentityRef, commitMsg string, opts *PullRequestListOptions + + descriptor := "Atlantis Terraform Pull Request Automation" + i := "atlantis" + imageUrl := "https://github.com/runatlantis/atlantis/raw/master/runatlantis.io/.vuepress/public/hero.png" + id := &azuredevops.IdentityRef{ + Descriptor: &descriptor, + ID: &i, + ImageURL: &imageUrl, + } + + commitMsg := "" + mergeResult, _, err := g.Client.PullRequests.Merge( + pull.BaseRepo.Name, + pull.Num, + id, + commitMsg, + ) + if err != nil { + return errors.Wrap(err, "merging pull request") + } + if mergeResult.MergeStatus != azuredevops.MergeSucceeded { + return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMergeFailureMessage()) + } + return nil +} diff --git a/server/events/vcs/azuredevops_client_test.go b/server/events/vcs/azuredevops_client_test.go new file mode 100644 index 000000000..7f30613a9 --- /dev/null +++ b/server/events/vcs/azuredevops_client_test.go @@ -0,0 +1,209 @@ +package vcs + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/mcdafydd/go-azuredevops/azuredevops" + "github.com/runatlantis/atlantis/server/events/models" + . "github.com/runatlantis/atlantis/testing" +) + +// Test that the base url gets set properly. +func TestNewAzureDevopsClient_BaseURL(t *testing.T) { + azuredevopsClientUnderTest = true + defer func() { azuredevopsClientUnderTest = false }() + cases := []struct { + Hostname string + Account string + Project string + ExpBaseURL string + }{ + { + "dev.azure.com", + "testorg", + "testproject", + "https://dev.azure.com/testorg/testproject", + }, + { + "visualstudio.com", + "testorg", + "testproject", + "https://testorg.visualstudio.com/testproject", + }, + } + + for _, c := range cases { + t.Run(c.Hostname, c.Account, c.Project, func(t *testing.T) { + client, err := NewAzureDevopsClient(c.Hostname, c.Account, c.Project, "token", nil) + Ok(t, err) + Equals(t, c.ExpBaseURL, client.Client.BaseURL().String()) + }) + } +} + +func TestAzureDevopsClient_MergePull(t *testing.T) { + cases := []struct { + description string + glResponse string + code int + expErr string + }{ + { + "success", + mergeSuccess, + 200, + "", + }, + { + "405", + `{"message":"405 Method Not Allowed"}`, + 405, + "405 {message: 405 Method Not Allowed}", + }, + { + "406", + `{"message":"406 Branch cannot be merged"}`, + 406, + "406 {message: 406 Branch cannot be merged}", + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + testServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1/merge": + w.WriteHeader(c.code) + w.Write([]byte(c.glResponse)) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + } + })) + + var httpClient = &http.Client{ + Timeout: time.Second * 10, + } + internalClient := azuredevops.NewClient(account, project, "token", httpClient) + Ok(t, internalClient.SetBaseURL(testServer.URL)) + client := &AzureDevopsClient{ + Client: internalClient, + Version: nil, + } + + err := client.MergePull(models.PullRequest{ + Num: 1, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + }, + }) + if c.expErr == "" { + Ok(t, err) + } else { + ErrContains(t, c.expErr, err) + ErrContains(t, "unable to merge merge request, it may not be in a mergeable state", err) + } + }) + } +} + +func TestAzureDevopsClient_UpdateStatus(t *testing.T) { + cases := []struct { + status models.CommitStatus + expState string + }{ + { + models.PendingCommitStatus, + "pending", + }, + { + models.SuccessCommitStatus, + "success", + }, + { + models.FailedCommitStatus, + "failed", + }, + } + for _, c := range cases { + t.Run(c.expState, func(t *testing.T) { + gotRequest := false + testServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": + gotRequest = true + + body, err := ioutil.ReadAll(r.Body) + Ok(t, err) + exp := fmt.Sprintf(`{"state":"%s","context":"src","target_url":"https://google.com","description":"description"}`, c.expState) + Equals(t, exp, string(body)) + defer r.Body.Close() // nolint: errcheck + w.Write([]byte("{}")) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + } + })) + + var httpClient = &http.Client{ + Timeout: time.Second * 10, + } + internalClient := azuredevops.NewClient(account, project, "token", httpClient) + Ok(t, internalClient.SetBaseURL(testServer.URL)) + client := &AzureDevopsClient{ + Client: internalClient, + Version: nil, + } + + repo := models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + } + err := client.UpdateStatus(repo, models.PullRequest{ + Num: 1, + BaseRepo: repo, + HeadCommit: "sha", + }, c.status, "src", "description", "https://google.com") + Ok(t, err) + Assert(t, gotRequest, "expected to get the request") + }) + } +} + +var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://azuredevops.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://azuredevops.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://azuredevops.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}` + +var getChangesResp = `{ + "changeCounts": { + "Add": 456 + }, + "changes": [ + { + "item": { + "gitObjectType": "blob", + "path": "/MyWebSite/MyWebSite/favicon.ico", + "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/favicon.ico?versionType=Commit" + }, + "changeType": "add" + }, + { + "item": { + "gitObjectType": "tree", + "path": "/MyWebSite/MyWebSite/fonts", + "isFolder": true, + "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/fonts?versionType=Commit" + }, + "changeType": "add" + } + ] +}` From 7dd268709a13a65e65338795bdeb6b575ce01a64 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Apr 2019 17:48:17 -0500 Subject: [PATCH 14/64] Azure Devops support fork development cmd/server.go: - Add default empty strings for Azure Devops webhook basic user/pass server/user_config.go: - Rename AzureDevopsBasicUser to AzureDevopsWebhookBasicUser to signify difference from the API call credentials server/server.go: - fix azuredevopsClient var - add Azure Devops to []supportedVCSHosts - add AzureDevopsRequestValidator for basic auth webhook support server/azuredevops_request_validator*.go: - add charset utf-8 to support content-types for webhooks - start adding validator tests server/events_controller.go*: - change azuredevops webhook header to Request-Id - move Validate() and Basic Auth webhook support to go-azuredevops - add pull and comment event functions --- cmd/server.go | 13 +- server/azuredevops_request_validator.go | 6 +- server/azuredevops_request_validator_test.go | 12 +- server/events_controller.go | 120 ++++++++++++------- server/events_controller_e2e_test.go | 1 + server/events_controller_test.go | 4 +- server/server.go | 44 ++++--- server/user_config.go | 58 ++++----- 8 files changed, 151 insertions(+), 107 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index d91c44156..63e07adc9 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -78,6 +78,8 @@ const ( // Flag defaults. DefaultADBasicAuth = true + DefaultADBasicUser = "" + DefaultADBasicPassword = "" DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" @@ -434,9 +436,6 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.DataDir == "" { c.DataDir = DefaultDataDir } - if c.AzureDevopsOrg == "" { - c.AzureDevopsOrg = DefaultADOrg - } if c.GithubHostname == "" { c.GithubHostname = DefaultGHHostname } @@ -582,11 +581,11 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs") } - if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookSecret == "" && !s.SilenceOutput { - s.Logger.Warn("No Azure Devops webhook secret set. This could allow attackers to spoof requests from Azure Devops.") + if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookBasicUser == "" && !s.SilenceOutput { + s.Logger.Warn("No Azure Devops webhook basic user set. This could allow attackers to spoof requests from Azure Devops.") } - if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookSecret == "" && !s.SilenceOutput { - s.Logger.Warn("No Azure Devops webhook secret set. This could allow attackers to spoof requests from Azure Devops.") + if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookBasicPassword == "" && !s.SilenceOutput { + s.Logger.Warn("No Azure Devops webhook basic password set. This could allow attackers to spoof requests from Azure Devops.") } } diff --git a/server/azuredevops_request_validator.go b/server/azuredevops_request_validator.go index 256a9cf9e..20e1ef256 100644 --- a/server/azuredevops_request_validator.go +++ b/server/azuredevops_request_validator.go @@ -58,14 +58,14 @@ func (d *DefaultAzureDevopsRequestValidator) validateWithBasicAuth(r *http.Reque } func (d *DefaultAzureDevopsRequestValidator) validateWithoutBasicAuth(r *http.Request) ([]byte, error) { - switch ct := r.Header.Get("Content-Type"); ct { - case "application/json": + ct := r.Header.Get("Content-Type") + if ct == "application/json" || ct == "application/json; charset=utf-8" { payload, err := ioutil.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("could not read body: %s", err) } return payload, nil - default: + } else { return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) } } diff --git a/server/azuredevops_request_validator_test.go b/server/azuredevops_request_validator_test.go index 0461d7e79..ebe6a536c 100644 --- a/server/azuredevops_request_validator_test.go +++ b/server/azuredevops_request_validator_test.go @@ -34,7 +34,7 @@ func TestValidate_WithSecretErr(t *testing.T) { req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") req.Header.Set("Content-Type", "application/json") - _, err = g.Validate(req, []byte("secret")) + _, err = g.Validate(req, []byte("user"), nil) Assert(t, err != nil, "error should not be nil") Equals(t, "payload signature check failed", err.Error()) } @@ -49,7 +49,7 @@ func TestValidate_WithSecret(t *testing.T) { req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") req.Header.Set("Content-Type", "application/json") - bs, err := g.Validate(req, []byte("0123456789abcdef")) + bs, err := g.Validate(req, []byte("username"), []byte("0123456789abcdef")) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } @@ -77,7 +77,7 @@ func TestValidate_WithoutSecretJSON(t *testing.T) { Ok(t, err) req.Header.Set("Content-Type", "application/json") - bs, err := g.Validate(req, nil) + bs, err := g.Validate(req, nil, nil) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } @@ -91,7 +91,7 @@ func TestValidate_WithoutSecretFormNoPayload(t *testing.T) { Ok(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - _, err = g.Validate(req, nil) + _, err = g.Validate(req, nil, nil) Assert(t, err != nil, "error should not be nil") Equals(t, "webhook request did not contain expected 'payload' form value", err.Error()) } @@ -107,7 +107,9 @@ func TestValidate_WithoutSecretForm(t *testing.T) { Ok(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - bs, err := g.Validate(req, nil) + bs, err := g.Validate(req, nil, nil) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } + +// *** add tests for all supported content types diff --git a/server/events_controller.go b/server/events_controller.go index 8f196d772..982618b60 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -20,6 +20,7 @@ import ( "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" + "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" @@ -31,7 +32,7 @@ import ( const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" -const azuredevopsHeader = "X-AzureDevops-Event" +const azuredevopsHeader = "Request-Id" // bitbucketEventTypeHeader is the same in both cloud and server. const bitbucketEventTypeHeader = "X-Event-Key" @@ -70,10 +71,16 @@ type EventsController struct { // UI that identifies this call as coming from Bitbucket. If empty, no // request validation is done. BitbucketWebhookSecret []byte - // AzureDevopsWebhookSecret is the secret added to this webhook via the Azure - // Devops UI that identifies this call as coming from Bitbucket. If empty, no - // request validation is done. - AzureDevopsWebhookSecret []byte + // AzureDevopsWebhookBasicUser is the Basic authentication username added to this + // webhook via the Azure Devops UI that identifies this call as coming from your + // Azure Devops Team Project. If empty, no request validation is done. + // For more information, see https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops + AzureDevopsWebhookBasicUser []byte + // AzureDevopsWebhookBasicPassword is the Basic authentication password added to this + // webhook via the Azure Devops UI that identifies this call as coming from your + // Azure Devops Team Project. If empty, no request validation is done. + AzureDevopsWebhookBasicPassword []byte + AzureDevopsRequestValidator AzureDevopsRequestValidator } // Post handles POST webhook requests. @@ -113,15 +120,15 @@ func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) { e.Logger.Debug("handling Bitbucket Server post") e.handleBitbucketServerPost(w, r) return - } else if r.Header.Get(azuredevopsHeader) != "" { - if !e.supportsHost(models.AzureDevops) { - e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support AzureDevops") - return - } - e.Logger.Debug("handling AzureDevops post") - e.handleAzureDevopsPost(w, r) + } + } else if r.Header.Get(azuredevopsHeader) != "" { + if !e.supportsHost(models.AzureDevops) { + e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support AzureDevops") return } + e.Logger.Debug("handling AzureDevops post") + e.handleAzureDevopsPost(w, r) + return } e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request") } @@ -208,43 +215,32 @@ func (e *EventsController) handleBitbucketServerPost(w http.ResponseWriter, r *h } } -func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) {} - -/*func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) { - eventType := r.Header.Get(azuredevopsEventTypeHeader) - reqID := r.Header.Get(azuredevopsRequestIDHeader) - sig := r.Header.Get(azuredevopsSignatureHeader) - defer r.Body.Close() // nolint: errcheck - body, err := ioutil.ReadAll(r.Body) +func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) { + // Validate the request against the optional basic auth username and password. + payload, err := e.AzureDevopsRequestValidator.Validate(r, e.AzureDevopsWebhookBasicUser, e.AzureDevopsWebhookBasicPassword) if err != nil { - e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, azuredevopsRequestIDHeader, reqID) + e.respond(w, logging.Warn, http.StatusUnauthorized, err.Error()) return } - if eventType == azuredevops.DiagnosticsPingHeader { - // Specially handle the diagnostics:ping event because Bitbucket Server - // doesn't send the signature with this event for some reason. - e.respond(w, logging.Info, http.StatusOK, "Successfully received %s event %s=%s", eventType, azuredevopsRequestIDHeader, reqID) - return - } - if len(e.AzureDevopsWebhookSecret) > 0 { - if err := azuredevops.ValidateSignature(body, sig, e.AzureDevopsWebhookSecret); err != nil { - e.respond(w, logging.Warn, http.StatusBadRequest, errors.Wrap(err, "request did not pass validation").Error()) - return - } - } - switch eventType { - case azuredevops.PullCreatedHeader, azuredevops.PullMergedHeader, azuredevops.PullDeclinedHeader, azuredevops.PullDeletedHeader: - e.Logger.Debug("handling as pull request state changed event") - e.handleBitbucketServerPullRequestEvent(w, eventType, body, reqID) - return - case azuredevops.PullCommentCreatedHeader: - e.Logger.Debug("handling as comment created event") - e.HandleBitbucketServerCommentEvent(w, body, reqID) - return + e.Logger.Debug("request valid") + + azuredevopsReqID := "Request-Id=" + r.Header.Get("Request-Id") + event, _ := azuredevops.ParseWebHook(payload) + /*event, ok := webhook.(*azuredevops.Event) + if !ok { + e.respond(w, logging.Debug, http.StatusBadRequest, "Error unmarshaling webhook payload %s", azuredevopsReqID) + }*/ + switch event.PayloadType { + case azuredevops.WorkItemEvent: + e.Logger.Debug("handling as comment event") + e.HandleAzureDevopsCommentEvent(w, event, azuredevopsReqID) + case azuredevops.PullRequestEvent: + e.Logger.Debug("handling as pull request event") + e.HandleAzureDevopsPullRequestEvent(w, event, azuredevopsReqID) default: - e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketServerRequestIDHeader, reqID) + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event %s", azuredevopsReqID) } -}*/ +} // HandleGithubCommentEvent handles comment events from GitHub where Atlantis // commands can come from. It's exported to make testing easier. @@ -461,6 +457,42 @@ func (e *EventsController) HandleGitlabMergeRequestEvent(w http.ResponseWriter, e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType) } +// HandleAzureDevopsCommentEvent handles comment events from Azure Devops where Atlantis +// commands can come from. It's exported to make testing easier. +func (e *EventsController) HandleAzureDevopsCommentEvent(w http.ResponseWriter, event *azuredevops.Event, azuredevopsReqID string) { + workItem := event.Resource.(*azuredevops.WorkItem) + fields := *workItem.Fields + if fields["System.State"] != "New" { + e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since action was not created %s", azuredevopsReqID) + return + } + + baseRepo, user, pullNum, err := e.Parser.ParseAzureDevopsWorkItemEvent(workItem) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Failed parsing event: %v %s", err, azuredevopsReqID) + return + } + + // *** Verify this for Azure Devops - comes from Github behavior *** + // We pass in nil for maybeHeadRepo because the head repo data isn't + // available in the AzureDevopsWorkItem event. + comment := fields["System.History"] + e.handleCommentEvent(w, baseRepo, nil, nil, user, pullNum, comment, models.AzureDevops) +} + +// HandleAzureDevopsPullRequestEvent will delete any locks associated with the pull +// request if the event is a pull request closed event. It's exported to make +// testing easier. +func (e *EventsController) HandleAzureDevopsPullRequestEvent(w http.ResponseWriter, pullEvent *azuredevops.Event, azuredevopsReqID string) { + pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseAzureDevopsPullEvent(*pullEvent) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s", err, azuredevopsReqID) + return + } + e.Logger.Info("identified event as type %q", pullEventType.String()) + e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType) +} + // supportsHost returns true if h is in e.SupportedVCSHosts and false otherwise. func (e *EventsController) supportsHost(h models.VCSHostType) bool { for _, supported := range e.SupportedVCSHosts { diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index f7e38d521..ec8d2faa0 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -325,6 +325,7 @@ func TestGitHubWorkflow(t *testing.T) { w := httptest.NewRecorder() When(githubGetter.GetPullRequest(AnyRepo(), AnyInt())).ThenReturn(GitHubPullRequestParsed(headSHA), nil) When(vcsClient.GetModifiedFiles(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(c.ModifiedFiles, nil) + // *** need azuredevops tests here *** // First, send the open pull request event which triggers autoplan. pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA) diff --git a/server/events_controller_test.go b/server/events_controller_test.go index b71be8177..71e6546a1 100644 --- a/server/events_controller_test.go +++ b/server/events_controller_test.go @@ -205,7 +205,7 @@ func TestPost_GitlabCommentNotWhitelisted(t *testing.T) { body, _ := ioutil.ReadAll(w.Result().Body) exp := "Repo not whitelisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) - expRepo, _ := models.NewRepo(models.Gitlab, "gitlabhq/gitlab-test", "https://example.com/gitlabhq/gitlab-test.git", "", "") + expRepo, _ := models.NewRepo(models.Gitlab, "gitlabhq/gitlab-test", "", "https://example.com/gitlabhq/gitlab-test.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment(expRepo, 1, "```\nError: This repo is not whitelisted for Atlantis.\n```") } @@ -263,7 +263,7 @@ func TestPost_GithubCommentNotWhitelisted(t *testing.T) { body, _ := ioutil.ReadAll(w.Result().Body) exp := "Repo not whitelisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) - expRepo, _ := models.NewRepo(models.Github, "baxterthehacker/public-repo", "https://github.com/baxterthehacker/public-repo.git", "", "") + expRepo, _ := models.NewRepo(models.Github, "baxterthehacker/public-repo", "", "https://github.com/baxterthehacker/public-repo.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment(expRepo, 2, "```\nError: This repo is not whitelisted for Atlantis.\n```") } diff --git a/server/server.go b/server/server.go index 709e548df..33f51e3d8 100644 --- a/server/server.go +++ b/server/server.go @@ -32,7 +32,6 @@ import ( "github.com/runatlantis/atlantis/server/events/db" "github.com/runatlantis/atlantis/server/events/yaml/valid" - "github.com/benmatselby/go-azuredevops/azuredevops" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -114,7 +113,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { var gitlabClient *vcs.GitlabClient var bitbucketCloudClient *bitbucketcloud.Client var bitbucketServerClient *bitbucketserver.Client - var azuredevopsClient *azuredevops.Client + var azuredevopsClient *vcs.AzureDevopsClient if userConfig.GithubUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.Github) var err error @@ -153,6 +152,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } } + if userConfig.AzureDevopsUser != "" { + supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops) + var err error + azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevopsOrg, userConfig.AzureDevopsUser, userConfig.AzureDevopsProject, userConfig.AzureDevopsToken, logger) + if err != nil { + return nil, err + } + } var webhooksConfig []webhooks.Config for _, c := range userConfig.Webhooks { @@ -252,6 +259,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { VCSClient: vcsClient, GithubPullGetter: githubClient, GitlabMergeRequestGetter: gitlabClient, + AzureDevopsPullGetter: azuredevopsClient, CommitStatusUpdater: commitStatusUpdater, EventParser: eventParser, MarkdownRenderer: markdownRenderer, @@ -315,21 +323,23 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { DB: boltdb, } eventsController := &EventsController{ - CommandRunner: commandRunner, - PullCleaner: pullClosedExecutor, - Parser: eventParser, - CommentParser: commentParser, - Logger: logger, - GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), - GithubRequestValidator: &DefaultGithubRequestValidator{}, - GitlabRequestParserValidator: &DefaultGitlabRequestParserValidator{}, - GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), - RepoWhitelistChecker: repoWhitelist, - SilenceWhitelistErrors: userConfig.SilenceWhitelistErrors, - SupportedVCSHosts: supportedVCSHosts, - VCSClient: vcsClient, - BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), - AzureDevopsWebhookSecret: []byte(userConfig.AzureDevopsWebhookSecret), + CommandRunner: commandRunner, + PullCleaner: pullClosedExecutor, + Parser: eventParser, + CommentParser: commentParser, + Logger: logger, + GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), + GithubRequestValidator: &DefaultGithubRequestValidator{}, + GitlabRequestParserValidator: &DefaultGitlabRequestParserValidator{}, + GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), + RepoWhitelistChecker: repoWhitelist, + SilenceWhitelistErrors: userConfig.SilenceWhitelistErrors, + SupportedVCSHosts: supportedVCSHosts, + VCSClient: vcsClient, + BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), + AzureDevopsWebhookBasicUser: []byte(userConfig.AzureDevopsWebhookBasicUser), + AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookBasicPassword), + AzureDevopsRequestValidator: &DefaultAzureDevopsRequestValidator{}, } return &Server{ AtlantisVersion: config.AtlantisVersion, diff --git a/server/user_config.go b/server/user_config.go index 44c8d7c1f..b83819fe4 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -6,35 +6,35 @@ import "github.com/runatlantis/atlantis/server/logging" // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type UserConfig struct { - AllowForkPRs bool `mapstructure:"allow-fork-prs"` - AllowRepoConfig bool `mapstructure:"allow-repo-config"` - AtlantisURL string `mapstructure:"atlantis-url"` - Automerge bool `mapstructure:"automerge"` - AzureDevopsBasicPassword string `mapstructure:"azuredevops-basic-password"` - AzureDevopsBasicUser string `mapstructure:"azuredevops-basic-user"` - AzureDevopsOrg string `mapstructure:"azuredevops-org"` - AzureDevopsProject string `mapstructure:"azuredevops-project"` - AzureDevopsToken string `mapstructure:"azuredevops-token"` - AzureDevopsUser string `mapstructure:"azuredevops-user"` - BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` - BitbucketToken string `mapstructure:"bitbucket-token"` - BitbucketUser string `mapstructure:"bitbucket-user"` - BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` - CheckoutStrategy string `mapstructure:"checkout-strategy"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RepoConfig string `mapstructure:"repo-config"` - RepoConfigJSON string `mapstructure:"repo-config-json"` - RepoWhitelist string `mapstructure:"repo-whitelist"` + AllowForkPRs bool `mapstructure:"allow-fork-prs"` + AllowRepoConfig bool `mapstructure:"allow-repo-config"` + AtlantisURL string `mapstructure:"atlantis-url"` + Automerge bool `mapstructure:"automerge"` + AzureDevopsWebhookBasicPassword string `mapstructure:"azuredevops-basic-password"` + AzureDevopsWebhookBasicUser string `mapstructure:"azuredevops-basic-user"` + AzureDevopsOrg string `mapstructure:"azuredevops-org"` + AzureDevopsProject string `mapstructure:"azuredevops-project"` + AzureDevopsToken string `mapstructure:"azuredevops-token"` + AzureDevopsUser string `mapstructure:"azuredevops-user"` + BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` + BitbucketToken string `mapstructure:"bitbucket-token"` + BitbucketUser string `mapstructure:"bitbucket-user"` + BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` + CheckoutStrategy string `mapstructure:"checkout-strategy"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + RepoConfig string `mapstructure:"repo-config"` + RepoConfigJSON string `mapstructure:"repo-config-json"` + RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. RequireApproval bool `mapstructure:"require-approval"` From 4bffa57c52b0070fb3b46a9ed37e589c4d910332 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Apr 2019 17:49:04 -0500 Subject: [PATCH 15/64] Updates to azuredevops_client.go - remove unneeded NewAzureDevopsClient() code - change to using const refs - add GetPullRequest() --- server/events/vcs/azuredevops_client.go | 56 +++++++------------- server/events/vcs/azuredevops_client_test.go | 6 +-- 2 files changed, 23 insertions(+), 39 deletions(-) diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 3e4d45de8..79fdd20af 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -15,10 +15,8 @@ package vcs import ( "fmt" - "net" "net/http" "net/url" - "strings" "time" version "github.com/hashicorp/go-version" @@ -48,35 +46,11 @@ func NewAzureDevopsClient(hostname string, account string, project string, token if err != nil { return nil, errors.Wrapf(err, "azuredevops.NewClient() %p", adClient) } + client := &AzureDevopsClient{ Client: adClient, } - // If not using dev.azure.com we need to set the URL to the API. - if hostname != "dev.azure.com" { - // We assume the url will be over HTTPS if the user doesn't specify a scheme. - absoluteURL := hostname - if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { - absoluteURL = "https://" + absoluteURL - } - - url, err := url.Parse(absoluteURL) - if err != nil { - return nil, errors.Wrapf(err, "parsing URL %q", absoluteURL) - } - - // Warn if this hostname isn't resolvable. - ips, err := net.LookupIP(url.Hostname()) - if err != nil { - logger.Warn("unable to resolve %q: %s", url.Hostname(), err) - } else if len(ips) == 0 { - logger.Warn("found no IPs while resolving %q", url.Hostname()) - } - - // Now we're ready to construct the client. - absoluteURL = strings.TrimSuffix(absoluteURL, "/") - } - return client, nil } @@ -112,7 +86,8 @@ func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullR // If the file was renamed, we'll want to run plan in the directory // it was moved from as well. - if change.ChangeType == azuredevops.Rename { + changeType := azuredevops.Rename.String() + if change.ChangeType == &changeType { files = append(files, change.GetSourceServerItem()) } } @@ -132,9 +107,10 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment maxCommentLength := 72 comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart) adComments := make([]azuredevops.Comment, len(comments)) + ct := azuredevops.CommentTypeText.String() for i, c := range comments { entry := azuredevops.Comment{ - CommentType: azuredevops.CommentTypeText, + CommentType: &ct, Content: &c, } adComments[i] = entry @@ -193,27 +169,35 @@ func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRe if err != nil { return false, errors.Wrap(err, "getting pull request") } - if adPull.MergeStatus != azuredevops.MergeConflicts && adPull.MergeStatus != azuredevops.MergeRejectedByPolicy { + if *adPull.MergeStatus != azuredevops.MergeConflicts.String() && + *adPull.MergeStatus != azuredevops.MergeRejectedByPolicy.String() { return false, nil } return true, nil } +// GetPullRequest returns the pull request. +func (g *AzureDevopsClient) GetPullRequest(repo models.Repo, num int) (*azuredevops.GitPullRequest, error) { + opts := &azuredevops.PullRequestListOptions{} + pull, _, err := g.Client.PullRequests.Get(num, opts) + return pull, err +} + // UpdateStatus updates the build status of a commit. func (g *AzureDevopsClient) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { - adState := azuredevops.GitError + adState := azuredevops.GitError.String() switch state { case models.PendingCommitStatus: - adState = azuredevops.GitPending + adState = azuredevops.GitPending.String() case models.SuccessCommitStatus: - adState = azuredevops.GitSucceeded + adState = azuredevops.GitSucceeded.String() case models.FailedCommitStatus: - adState = azuredevops.GitFailed + adState = azuredevops.GitFailed.String() } genreStr := "Atlantis Bot" status := &azuredevops.GitStatus{ - State: adState, + State: &adState, Description: &description, Context: &azuredevops.GitStatusContext{ Name: &src, @@ -267,7 +251,7 @@ func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { if err != nil { return errors.Wrap(err, "merging pull request") } - if mergeResult.MergeStatus != azuredevops.MergeSucceeded { + if *mergeResult.MergeStatus != azuredevops.MergeSucceeded.String() { return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMergeFailureMessage()) } return nil diff --git a/server/events/vcs/azuredevops_client_test.go b/server/events/vcs/azuredevops_client_test.go index 7f30613a9..daf9a5888 100644 --- a/server/events/vcs/azuredevops_client_test.go +++ b/server/events/vcs/azuredevops_client_test.go @@ -90,7 +90,7 @@ func TestAzureDevopsClient_MergePull(t *testing.T) { var httpClient = &http.Client{ Timeout: time.Second * 10, - } + } internalClient := azuredevops.NewClient(account, project, "token", httpClient) Ok(t, internalClient.SetBaseURL(testServer.URL)) client := &AzureDevopsClient{ @@ -156,8 +156,8 @@ func TestAzureDevopsClient_UpdateStatus(t *testing.T) { })) var httpClient = &http.Client{ - Timeout: time.Second * 10, - } + Timeout: time.Second * 10, + } internalClient := azuredevops.NewClient(account, project, "token", httpClient) Ok(t, internalClient.SetBaseURL(testServer.URL)) client := &AzureDevopsClient{ From 4d11f8089a1ccc6bae300c026c36217eacbb7ceb Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Apr 2019 17:50:52 -0500 Subject: [PATCH 16/64] add Azure Devops pull getter support --- server/events/command_runner.go | 25 +++++++++++++++++++++++++ server/events/command_runner_test.go | 2 ++ 2 files changed, 27 insertions(+) diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 626f1760d..339bc3696 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -18,6 +18,7 @@ import ( "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" + "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/db" "github.com/runatlantis/atlantis/server/events/models" @@ -45,6 +46,12 @@ type GithubPullGetter interface { GetPullRequest(repo models.Repo, pullNum int) (*github.PullRequest, error) } +// AzureDevopsPullGetter makes API calls to get pull requests. +type AzureDevopsPullGetter interface { + // GetPullRequest gets the pull request with id pullNum for the repo. + GetPullRequest(repo models.Repo, pullNum int) (*azuredevops.GitPullRequest, error) +} + //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_gitlab_merge_request_getter.go GitlabMergeRequestGetter // GitlabMergeRequestGetter makes API calls to get merge requests. @@ -57,6 +64,7 @@ type GitlabMergeRequestGetter interface { type DefaultCommandRunner struct { VCSClient vcs.Client GithubPullGetter GithubPullGetter + AzureDevopsPullGetter AzureDevopsPullGetter GitlabMergeRequestGetter GitlabMergeRequestGetter CommitStatusUpdater CommitStatusUpdater EventParser EventParsing @@ -158,6 +166,8 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead err = errors.New("pull request should not be nil–this is a bug") } pull = *maybePull + case models.AzureDevops: + pull, headRepo, err = c.getAzureDevopsData(baseRepo, pullNum) default: err = errors.New("Unknown VCS type–this is a bug") } @@ -344,6 +354,21 @@ func (c *DefaultCommandRunner) getGitlabData(baseRepo models.Repo, pullNum int) return pull, nil } +func (c *DefaultCommandRunner) getAzureDevopsData(baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { + if c.AzureDevopsPullGetter == nil { + return models.PullRequest{}, models.Repo{}, errors.New("Atlantis not configured to support Azure Devops") + } + adPull, err := c.AzureDevopsPullGetter.GetPullRequest(baseRepo, pullNum) + if err != nil { + return models.PullRequest{}, models.Repo{}, errors.Wrap(err, "making pull request API call to Azure Devops") + } + pull, _, headRepo, err := c.EventParser.ParseAzureDevopsPull(adPull) + if err != nil { + return pull, headRepo, errors.Wrap(err, "extracting required fields from comment data") + } + return pull, headRepo, nil +} + func (c *DefaultCommandRunner) buildLogger(repoFullName string, pullNum int) *logging.SimpleLogger { src := fmt.Sprintf("%s#%d", repoFullName, pullNum) return c.Logger.NewLogger(src, true, c.Logger.GetLevel()) diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 69d9af019..5ef48753e 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -50,6 +50,7 @@ func setup(t *testing.T) *vcsmocks.MockClient { vcsClient := vcsmocks.NewMockClient() githubGetter = mocks.NewMockGithubPullGetter() gitlabGetter = mocks.NewMockGitlabMergeRequestGetter() + azuredevopsGetter = mocks.NewMockAzureDevopsPullGetter() logger := logmocks.NewMockSimpleLogging() pullLogger = logging.NewSimpleLogger("runatlantis/atlantis#1", true, logging.Info) projectCommandRunner = mocks.NewMockProjectCommandRunner() @@ -65,6 +66,7 @@ func setup(t *testing.T) *vcsmocks.MockClient { MarkdownRenderer: &events.MarkdownRenderer{}, GithubPullGetter: githubGetter, GitlabMergeRequestGetter: gitlabGetter, + AzureDevopsPullGetter: azuredevopsGetter, Logger: logger, AllowForkPRs: false, AllowForkPRsFlag: "allow-fork-prs-flag", From 5cbcf25de09a557660f71569920abc5e5be93e59 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Apr 2019 17:52:08 -0500 Subject: [PATCH 17/64] Initial support for azure devops events - Pull request events - Work item comment events --- server/events/event_parser.go | 196 ++++++++++++++++++++++++ server/events/event_parser_test.go | 236 +++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index d17aa25dd..a3ee1f933 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -16,11 +16,14 @@ package events import ( "encoding/json" "fmt" + "net/url" "path" + "strconv" "strings" "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" + "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" @@ -234,6 +237,44 @@ type EventParsing interface { // GetBitbucketServerPullEventType returns the type of the pull request // event given the Bitbucket Server header. GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType + + // ParseAzureDevopsWorkItemEvent parses Azure Devops work item comment events. + // There is no service hook event for comments made directly on a pull request, only + // for comments made on a work item. In order to see the content of a comment in + // the webhook payload, the comment must be on a work item. Since a work item + // could have multiple pull requests, we have to do a bit of extra work to ensure + // the pull request of interest and an atlantis comment are related. + // + // For more information about driving development via Azure Devops work items: + // https://docs.microsoft.com/en-us/azure/devops/boards/work-items/workflow-and-state-categories?view=azure-devops#auto-completion-of-work-items-with-pull-requests + // + // baseRepo is the repo that the pull request will be merged into. + // user is the pull request author. + // pullNum is the number of the pull request that triggered the webhook. + ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkItem) ( + baseRepo models.Repo, user models.User, pullNum int, err error) + + // ParseAzureDevopsPull parses the response from the Azure Devops API endpoint (not + // from a webhook) that returns a pull request. + // pull is the parsed pull request. + // baseRepo is the repo the pull request will be merged into. + // headRepo is the repo the pull request branch is from. + ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) ( + pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) + + // ParseAzureDevopsPullEvent parses Azure Devops pull request events. + // pull is the parsed pull request. + // pullEventType is the type of event, for example opened/closed. + // baseRepo is the repo the pull request will be merged into. + // headRepo is the repo the pull request branch is from. + // user is the pull request author. + ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) ( + pull models.PullRequest, pullEventType models.PullRequestEventType, + baseRepo models.Repo, headRepo models.Repo, user models.User, err error) + + // ParseAzureDevopsRepo parses the response from the Azure Devops API endpoint that + // returns a repo into the Atlantis model. + ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) } // EventParser parses VCS events. @@ -247,6 +288,8 @@ type EventParser struct { BitbucketServerURL string AzureDevopsUser string AzureDevopsToken string + AzureDevopsOrg string + AzureDevopsProject string } // GetBitbucketCloudPullEventType returns the type of the pull request @@ -677,3 +720,156 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData) return } + +// ParseAzureDevopsPullEvent parses Azure Devops pull request events. +// See EventParsing for return value docs. +func (e *EventParser) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { + /*if pullEvent == nil { + err = errors.New("pullEvent is null") + return + }*/ + pullResource := pullEvent.Resource.(*azuredevops.GitPullRequest) + pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource) + if err != nil { + return + } + createdBy := pullResource.GetCreatedBy() + if createdBy == nil { + err = errors.New("CreatedBy is null") + return + } + senderUsername := createdBy.GetUniqueName() + if senderUsername == "" { + err = errors.New("CreatedBy.UniqueName is null") + return + } + switch pullEvent.EventType { + case "git.pullrequest.created": + pullEventType = models.OpenedPullEvent + case "git.pullrequest.updated": + pullEventType = models.UpdatedPullEvent + case "git.pullrequest.merged": + pullEventType = models.ClosedPullEvent + default: + pullEventType = models.OtherPullEvent + } + user = models.User{Username: senderUsername} + return +} + +// ParseAzureDevopsPull parses the response from the Azure Devops API endpoint (not +// from a webhook) that returns a pull request. +// See EventParsing for return value docs. +func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) { + commit := pull.LastMergeSourceCommit.GetCommitID() + if commit == "" { + err = errors.New("lastMergeSourceCommit.commitID is null") + return + } + url := pull.GetURL() + if url == "" { + err = errors.New("url is null") + return + } + headBranch := pull.GetSourceRefName() + if headBranch == "" { + err = errors.New("sourceRefName (branch name) is null") + return + } + baseBranch := pull.GetTargetRefName() + if baseBranch == "" { + err = errors.New("targetRefName (branch name) is null") + return + } + num := pull.GetPullRequestID() + if num == 0 { + err = errors.New("pullRequestId is null") + return + } + createdBy := pull.GetCreatedBy() + authorUsername := createdBy.GetUniqueName() + baseRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) + if err != nil { + return + } + + pullState := models.ClosedPullState + if *pull.Status == azuredevops.PullActive.String() { + pullState = models.OpenPullState + } + + pullModel = models.PullRequest{ + Author: authorUsername, + HeadBranch: headBranch, + HeadCommit: commit, + URL: url, + Num: num, + State: pullState, + BaseRepo: baseRepo, + BaseBranch: baseBranch, + } + return +} + +// ParseAzureDevopsWorkItemEvent parses Azure Devops work item comment events. +// Azure Devops does not have a webhook that fires upon commenting on a pull request, +// only for commenting on a work item. +// It is possible to get a workitem.commented event without a reference linking it +// to a repository if the user hasn't linked it to a pull request (relations list field +// in the JSON payload). *** Test outcome of linking multiple pull requests to a work item. +// Example comment in JSON payload: +// "System.History": "
    third comment discussion - see where this shows up in the +// webhook.  the webhook should also indicate two existing links to this work item. +//   one branch and one active pull request!
    " +// See EventParsing for return value docs. +func (e *EventParser) ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkItem) (baseRepo models.Repo, user models.User, pullNum int, err error) { + // Check for pull request links to the work item that generated the comment + // service event. If a pull request link exists, extract its number + // Example link URI: vstfs:///Git/PullRequestId/d03edfe3-755b-4e8d-8f88-5aa5b51aba61%2f5d4fa372-78d4-4d85-aff6-7dcbc45d44d2%2f7 + for _, relation := range comment.Relations { + if uri := relation.GetURL(); strings.Contains(uri, "vstfs:///Git/PullRequestId/") { + var parsed *url.URL + parsed, err = url.Parse(uri) + if err != nil { + return + } + pullNumStr := strings.Split(parsed.Path, "/")[5] + pullNum, err = strconv.Atoi(pullNumStr) + if err != nil { + return + } + } + } + + // Retrieve the linked pull request to get baseRepo and user + fields := comment.GetFields() + project := fields["System.TeamProject"] + client, err := azuredevops.NewClient(e.AzureDevopsUser, project, e.AzureDevopsToken, nil) + opts := azuredevops.PullRequestListOptions{} + pr, _, err := client.PullRequests.Get(pullNum, &opts) + if err != nil { + return + } + createdBy := pr.GetCreatedBy() + user = models.User{Username: createdBy.GetUniqueName()} + baseRepo, err = e.ParseAzureDevopsRepo(pr.GetRepository()) + if err != nil { + return + } + + return baseRepo, user, pullNum, err +} + +// ParseAzureDevopsRepo parses the response from the Azure Devops API endpoint that +// returns a repo into the Atlantis model. +// See EventParsing for return value docs. +func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) { + teamProject := adRepo.GetProject() + projectName := new(string) + if teamProject != nil { + *projectName = teamProject.GetName() + } else { + *projectName = "" + } + return models.NewRepo(models.AzureDevops, adRepo.GetName(), *projectName, adRepo.GetRemoteURL(), e.AzureDevopsUser, e.AzureDevopsToken) +} diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index 8d39bd047..db16f4b7f 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" + "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/mohae/deepcopy" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" @@ -40,6 +41,8 @@ var parser = events.EventParser{ BitbucketServerURL: "http://mycorp.com:7490", AzureDevopsUser: "azuredevops-user", AzureDevopsToken: "azuredevops-token", + AzureDevopsOrg: "azuredevops-org", + AzureDevopsProject: "azuredevops-project", } func TestParseGithubRepo(t *testing.T) { @@ -1044,3 +1047,236 @@ func TestGetBitbucketServerEventType(t *testing.T) { }) } } + +func TestParseAzureDevopsRepo(t *testing.T) { + r, err := parser.ParseAzureDevopsRepo(&Repo) + Ok(t, err) + Equals(t, models.Repo{ + Owner: "owner", + FullName: "owner/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", + SanitizedCloneURL: Repo.GetCloneURL(), + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "dev.azure.com", + Type: models.AzureDevops, + }, + }, r) +} + +func TestParseAzureDevopsWorkItemCommentEvent(t *testing.T) { + comment := azuredevops.WorkItemCommentEvent{ + Repo: &Repo, + Issue: &azuredevops.Issue{ + Number: azuredevops.Int(1), + User: &azuredevops.User{Login: azuredevops.String("issue_user")}, + HTMLURL: azuredevops.String("https://azuredevops.com/runatlantis/atlantis/issues/1"), + }, + Comment: &azuredevops.WorkItemComment{ + User: &azuredevops.User{Login: azuredevops.String("comment_user")}, + }, + } + + testComment := deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) + testComment.Comment = nil + _, _, _, err := parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) + ErrEquals(t, "comment.user.login is null", err) + + testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) + testComment.Comment.User = nil + _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) + ErrEquals(t, "comment.user.login is null", err) + + testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) + testComment.Comment.User.Login = nil + _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) + ErrEquals(t, "comment.user.login is null", err) + + testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) + testComment.Issue = nil + _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) + ErrEquals(t, "issue.number is null", err) + + // this should be successful + repo, user, pullNum, err := parser.ParseAzureDevopsWorkItemCommentEvent(&comment) + Ok(t, err) + Equals(t, models.Repo{ + Owner: *comment.Repo.Owner.Login, + FullName: *comment.Repo.FullName, + CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", + SanitizedCloneURL: *comment.Repo.CloneURL, + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "azuredevops.com", + Type: models.AzureDevops, + }, + }, repo) + Equals(t, models.User{ + Username: *comment.Comment.User.Login, + }, user) + Equals(t, *comment.Issue.Number, pullNum) +} + +func TestParseAzureDevopsPullEvent(t *testing.T) { + _, _, _, _, _, err := parser.ParseAzureDevopsPullEvent(&azuredevops.PullRequestEvent{}) + ErrEquals(t, "pull_request is null", err) + + testEvent := deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) + testEvent.PullRequest.HTMLURL = nil + _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) + ErrEquals(t, "html_url is null", err) + + testEvent = deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) + testEvent.Sender = nil + _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) + ErrEquals(t, "sender is null", err) + + testEvent = deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) + testEvent.Sender.Login = nil + _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) + ErrEquals(t, "sender.login is null", err) + + actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(&PullEvent) + Ok(t, err) + expBaseRepo := models.Repo{ + Owner: "owner", + FullName: "owner/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", + SanitizedCloneURL: Repo.GetCloneURL(), + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "azuredevops.com", + Type: models.AzureDevops, + }, + } + Equals(t, expBaseRepo, actBaseRepo) + Equals(t, expBaseRepo, actHeadRepo) + Equals(t, models.PullRequest{ + URL: Pull.GetHTMLURL(), + Author: Pull.User.GetLogin(), + HeadBranch: Pull.Head.GetRef(), + BaseBranch: Pull.Base.GetRef(), + HeadCommit: Pull.Head.GetSHA(), + Num: Pull.GetNumber(), + State: models.OpenPullState, + BaseRepo: expBaseRepo, + }, actPull) + Equals(t, models.OpenedPullEvent, evType) + Equals(t, models.User{Username: "user"}, actUser) +} + +func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { + cases := []struct { + action string + exp models.PullRequestEventType + }{ + { + action: "synchronize", + exp: models.UpdatedPullEvent, + }, + { + action: "unassigned", + exp: models.OtherPullEvent, + }, + { + action: "review_requested", + exp: models.OtherPullEvent, + }, + { + action: "review_request_removed", + exp: models.OtherPullEvent, + }, + { + action: "labeled", + exp: models.OtherPullEvent, + }, + { + action: "unlabeled", + exp: models.OtherPullEvent, + }, + { + action: "opened", + exp: models.OpenedPullEvent, + }, + { + action: "edited", + exp: models.OtherPullEvent, + }, + { + action: "closed", + exp: models.ClosedPullEvent, + }, + { + action: "reopened", + exp: models.OtherPullEvent, + }, + } + + for _, c := range cases { + t.Run(c.action, func(t *testing.T) { + event := deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) + event.Action = &c.action + _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(&event) + Ok(t, err) + Equals(t, c.exp, actType) + }) + } +} + +func TestParseAzureDevopsPull(t *testing.T) { + testPull := deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.Head.SHA = nil + _, _, _, err := parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "head.sha is null", err) + + testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.HTMLURL = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "html_url is null", err) + + testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.Head.Ref = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "head.ref is null", err) + + testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.Base.Ref = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "base.ref is null", err) + + testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.User.Login = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "user.login is null", err) + + testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) + testPull.Number = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "number is null", err) + + pullRes, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&Pull) + Ok(t, err) + expBaseRepo := models.Repo{ + Owner: "owner", + FullName: "owner/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", + SanitizedCloneURL: Repo.GetCloneURL(), + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "azuredevops.com", + Type: models.AzureDevops, + }, + } + Equals(t, models.PullRequest{ + URL: Pull.GetHTMLURL(), + Author: Pull.User.GetLogin(), + HeadBranch: Pull.Head.GetRef(), + BaseBranch: Pull.Base.GetRef(), + HeadCommit: Pull.Head.GetSHA(), + Num: Pull.GetNumber(), + State: models.OpenPullState, + BaseRepo: expBaseRepo, + }, pullRes) + Equals(t, expBaseRepo, actBaseRepo) + Equals(t, expBaseRepo, actHeadRepo) +} From 87e88e34df722f3fe6f8252dcd1c456030a9d208 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Apr 2019 20:09:28 -0500 Subject: [PATCH 18/64] Fixes repo fullName for Azure Devops --- server/events/event_parser.go | 26 +++++++++++++++++++++----- server/events/models/models.go | 20 ++++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index a3ee1f933..0780b1c60 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -862,14 +862,30 @@ func (e *EventParser) ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkIte // ParseAzureDevopsRepo parses the response from the Azure Devops API endpoint that // returns a repo into the Atlantis model. +// If the event payload doesn't contain a parent repository reference, extract the owner +// name from the URL. // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) { teamProject := adRepo.GetProject() - projectName := new(string) - if teamProject != nil { - *projectName = teamProject.GetName() + parent := adRepo.GetParentRepository() + owner := "" + if parent != nil { + owner = parent.GetName() } else { - *projectName = "" + uri, err := url.Parse(adRepo.GetRemoteURL()) + if err != nil { + return models.Repo{}, err + } + // ex: https://runatlantis.visualstudio.com/project/_git/repo + if strings.Contains(uri.Host, "visualstudio.com") { + owner = strings.Split(uri.Host, ".")[0] + // ex: https://dev.azure.com/runatlantis/project/_git/repo + } else if strings.Contains(uri.Host, "dev.azure.com") { + owner = strings.Split(uri.Path, "/")[1] + } else { + owner = "" + } } - return models.NewRepo(models.AzureDevops, adRepo.GetName(), *projectName, adRepo.GetRemoteURL(), e.AzureDevopsUser, e.AzureDevopsToken) + fullName := fmt.Sprintf("%s/%s/%s", owner, teamProject.GetName(), adRepo.GetName()) + return models.NewRepo(models.AzureDevops, fullName, teamProject.GetName(), adRepo.GetRemoteURL(), e.AzureDevopsUser, e.AzureDevopsToken) } diff --git a/server/events/models/models.go b/server/events/models/models.go index c58eb4062..8892e43ee 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -33,11 +33,13 @@ import ( // Repo is a VCS repository. type Repo struct { // FullName is the owner and repo name separated - // by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", "Bitbucket Server/atlantis". + // by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", + // "Bitbucket Server/atlantis", "azuredevops/project/atlantis". FullName string - // Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup". - // This may contain /'s in the case of GitLab subgroups. - // This may contain spaces in the case of Bitbucket Server. + // Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup" + // or azuredevops/project. This may contain /'s in the case of GitLab + // subgroups or Azure Devops Team Projects. This may contain spaces in + // the case of Bitbucket Server. Owner string // Project is juts the project name, only required for Azure Devops. This will never // have /'s in it. @@ -93,7 +95,8 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, clone // We skip this check for Bitbucket Server because its format is different // and because the caller in that case actually constructs the clone url // from the repo name and so there's no point checking if they match. - if vcsHostType != BitbucketServer { + // Azure Devops also does not require .git at the end of clone urls. + if vcsHostType != BitbucketServer && vcsHostType != AzureDevops { expClonePath := fmt.Sprintf("/%s.git", repoFullName) if expClonePath != cloneURLParsed.Path { return Repo{}, fmt.Errorf("expected clone url to have path %q but had %q", expClonePath, cloneURLParsed.Path) @@ -116,9 +119,9 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, clone if owner == "" || repo == "" { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q or repo %q was empty", repoFullName, owner, repo) } - // Only GitLab repos can have /'s in their owners. This is for GitLab - // subgroups. - if strings.Contains(owner, "/") && vcsHostType != Gitlab { + // Only GitLab and Azure Devops repos can have /'s in their owners. + // This is for GitLab subgroups and Azure Devops Team Projects. + if strings.Contains(owner, "/") && vcsHostType != Gitlab && vcsHostType != AzureDevops { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q should not contain any /'s", repoFullName, owner) } if strings.Contains(repo, "/") { @@ -360,6 +363,7 @@ type ProjectCommandContext struct { // for owner or repo. // Ex. runatlantis/atlantis => (runatlantis, atlantis) // gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) +// azuredevops/project/atlantis => (azuredevops/project, atlantis) func SplitRepoFullName(repoFullName string) (owner string, repo string) { lastSlashIdx := strings.LastIndex(repoFullName, "/") if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { From 4eb9a010bbda6cc54fac4f2cc354c3447639173b Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Apr 2019 11:23:25 -0500 Subject: [PATCH 19/64] Azure Devops support - Add missing headRepo set to AzureDevops event parser - Strip 'refs/heads' from branch values sent to models.PullRequest - Fix calls to NewClient() to use org, not username - Add notes about use of /'s in repo FullName - Force use of 'dev.azure.com' for VCSHost.Hostname value - NewAzureDevopsClient(): Rename 'account' to 'username' to match docs - NewAzureDevopsClient(): Rename unused 'hostname' to 'org' --- server/events/event_parser.go | 12 +++++++----- server/events/vcs/azuredevops_client.go | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 0780b1c60..404854a23 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -792,6 +792,10 @@ func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pu if err != nil { return } + headRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) + if err != nil { + return + } pullState := models.ClosedPullState if *pull.Status == azuredevops.PullActive.String() { @@ -800,13 +804,13 @@ func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pu pullModel = models.PullRequest{ Author: authorUsername, - HeadBranch: headBranch, + HeadBranch: path.Base(headBranch), HeadCommit: commit, URL: url, Num: num, State: pullState, BaseRepo: baseRepo, - BaseBranch: baseBranch, + BaseBranch: path.Base(baseBranch), } return } @@ -842,9 +846,7 @@ func (e *EventParser) ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkIte } // Retrieve the linked pull request to get baseRepo and user - fields := comment.GetFields() - project := fields["System.TeamProject"] - client, err := azuredevops.NewClient(e.AzureDevopsUser, project, e.AzureDevopsToken, nil) + client, err := azuredevops.NewClient(e.AzureDevopsOrg, e.AzureDevopsProject, e.AzureDevopsToken, nil) opts := azuredevops.PullRequestListOptions{} pr, _, err := client.PullRequests.Get(pullNum, &opts) if err != nil { diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 79fdd20af..523f29c51 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -38,11 +38,11 @@ type AzureDevopsClient struct { var azuredevopsClientUnderTest = false // NewAzureDevopsClient returns a valid Azure Devops client. -func NewAzureDevopsClient(hostname string, account string, project string, token string, logger *logging.SimpleLogger) (*AzureDevopsClient, error) { +func NewAzureDevopsClient(org string, username string, project string, token string, logger *logging.SimpleLogger) (*AzureDevopsClient, error) { var httpClient = http.Client{ Timeout: time.Second * 10, } - var adClient, err = azuredevops.NewClient(account, project, token, &httpClient) + var adClient, err = azuredevops.NewClient(org, project, token, &httpClient) if err != nil { return nil, errors.Wrapf(err, "azuredevops.NewClient() %p", adClient) } From 8c27c12f572839212dd11f311c00d22007f293b9 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Apr 2019 11:35:43 -0500 Subject: [PATCH 20/64] update readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 961b3d451..15f75eda8 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ Terraform Pull Request Automation

    +## Azure Devops Support (alpha) + +This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops/tree/webhook-support) library. It will build and is somewhat functional now, but beware that work is still ongoing to fix panics, add more tests, fix work item comment support, and update documentation. + ## Resources * How to get started: [www.runatlantis.io/guide](https://www.runatlantis.io/guide) * Full documentation: [www.runatlantis.io/docs](https://www.runatlantis.io/docs) From 10abea0875f1ee453e59c3b12be7287fd104c24f Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Apr 2019 15:57:39 -0500 Subject: [PATCH 21/64] fix CreateStatus() if TargetURL is empty --- server/events/vcs/azuredevops_client.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 523f29c51..be1f7e4dc 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -196,16 +196,18 @@ func (g *AzureDevopsClient) UpdateStatus(repo models.Repo, pull models.PullReque } genreStr := "Atlantis Bot" - status := &azuredevops.GitStatus{ + status := azuredevops.GitStatus{ State: &adState, Description: &description, Context: &azuredevops.GitStatusContext{ Name: &src, Genre: &genreStr, }, - TargetURL: &url, } - _, _, err := g.Client.Git.CreateStatus(repo.Name, pull.HeadCommit, *status) + if url != "" { + status.TargetURL = &url + } + _, _, err := g.Client.Git.CreateStatus(repo.Name, pull.HeadCommit, status) return err } From cbb28ec1f1ef319a76d01465f4ca45ce2d45e00b Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Mon, 29 Apr 2019 00:06:48 -0500 Subject: [PATCH 22/64] Azure Devops support - Add bluemonday to strip HTML tags from comments - Rename all WorkItemEvent refs to WorkItemCommentedEvent - Rename pullEvent to event to match types - Minor comment clean-ups - Support multiple pull request links in a work item --- Gopkg.toml | 4 ++ server/events/event_parser.go | 80 ++++++++++++++++++----------------- server/events_controller.go | 37 ++++++++++------ 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/Gopkg.toml b/Gopkg.toml index f905e9f97..09d51f87a 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -100,6 +100,10 @@ branch = "master" name = "github.com/mcdafydd/go-azuredevops" +[[constraint]] + branch = "master" + name = "github.com/microcosm-cc/bluemonday" + [[constraint]] name = "github.com/go-test/deep" version = "1.0.1" diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 404854a23..d610e7ff9 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -238,7 +238,7 @@ type EventParsing interface { // event given the Bitbucket Server header. GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType - // ParseAzureDevopsWorkItemEvent parses Azure Devops work item comment events. + // ParseAzureDevopsWorkItemCommentedEvent parses Azure Devops work item comment events. // There is no service hook event for comments made directly on a pull request, only // for comments made on a work item. In order to see the content of a comment in // the webhook payload, the comment must be on a work item. Since a work item @@ -251,8 +251,8 @@ type EventParsing interface { // baseRepo is the repo that the pull request will be merged into. // user is the pull request author. // pullNum is the number of the pull request that triggered the webhook. - ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkItem) ( - baseRepo models.Repo, user models.User, pullNum int, err error) + // *** Add tests and handle linking multiple pull requests to a work item. + ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem) (pullRefs []PullRef, err error) // ParseAzureDevopsPull parses the response from the Azure Devops API endpoint (not // from a webhook) that returns a pull request. @@ -723,12 +723,11 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu // ParseAzureDevopsPullEvent parses Azure Devops pull request events. // See EventParsing for return value docs. -func (e *EventParser) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { - /*if pullEvent == nil { - err = errors.New("pullEvent is null") +func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { + pullResource, ok := event.Resource.(*azuredevops.GitPullRequest) + if !ok { return - }*/ - pullResource := pullEvent.Resource.(*azuredevops.GitPullRequest) + } pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource) if err != nil { return @@ -743,7 +742,7 @@ func (e *EventParser) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (pu err = errors.New("CreatedBy.UniqueName is null") return } - switch pullEvent.EventType { + switch event.EventType { case "git.pullrequest.created": pullEventType = models.OpenedPullEvent case "git.pullrequest.updated": @@ -815,22 +814,19 @@ func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pu return } -// ParseAzureDevopsWorkItemEvent parses Azure Devops work item comment events. -// Azure Devops does not have a webhook that fires upon commenting on a pull request, -// only for commenting on a work item. -// It is possible to get a workitem.commented event without a reference linking it -// to a repository if the user hasn't linked it to a pull request (relations list field -// in the JSON payload). *** Test outcome of linking multiple pull requests to a work item. -// Example comment in JSON payload: -// "System.History": "
    third comment discussion - see where this shows up in the -// webhook.  the webhook should also indicate two existing links to this work item. -//   one branch and one active pull request!
    " +// PullRef Data necessary to process a single pull request. +type PullRef struct { + BaseRepo models.Repo + User models.User + PullNum int +} + +// ParseAzureDevopsWorkItemCommentedEvent parses Azure Devops work item comment events. +// Multiple pull requests can be linked to a single work item. // See EventParsing for return value docs. -func (e *EventParser) ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkItem) (baseRepo models.Repo, user models.User, pullNum int, err error) { - // Check for pull request links to the work item that generated the comment - // service event. If a pull request link exists, extract its number - // Example link URI: vstfs:///Git/PullRequestId/d03edfe3-755b-4e8d-8f88-5aa5b51aba61%2f5d4fa372-78d4-4d85-aff6-7dcbc45d44d2%2f7 +func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem) (pullRefs []PullRef, err error) { for _, relation := range comment.Relations { + ref := &PullRef{} if uri := relation.GetURL(); strings.Contains(uri, "vstfs:///Git/PullRequestId/") { var parsed *url.URL parsed, err = url.Parse(uri) @@ -838,28 +834,34 @@ func (e *EventParser) ParseAzureDevopsWorkItemEvent(comment *azuredevops.WorkIte return } pullNumStr := strings.Split(parsed.Path, "/")[5] - pullNum, err = strconv.Atoi(pullNumStr) + ref.PullNum, err = strconv.Atoi(pullNumStr) if err != nil { return } + + // Retrieve the linked pull request to get baseRepo and user + client := new(azuredevops.Client) + client, err = azuredevops.NewClient(e.AzureDevopsOrg, e.AzureDevopsProject, e.AzureDevopsToken, nil) + if err != nil { + return + } + pr := new(azuredevops.GitPullRequest) + opts := azuredevops.PullRequestListOptions{} + pr, _, err = client.PullRequests.Get(ref.PullNum, &opts) + if err != nil { + return + } + createdBy := pr.GetCreatedBy() + ref.User = models.User{Username: createdBy.GetUniqueName()} + ref.BaseRepo, err = e.ParseAzureDevopsRepo(pr.GetRepository()) + if err != nil { + return + } + pullRefs = append(pullRefs, *ref) } } - // Retrieve the linked pull request to get baseRepo and user - client, err := azuredevops.NewClient(e.AzureDevopsOrg, e.AzureDevopsProject, e.AzureDevopsToken, nil) - opts := azuredevops.PullRequestListOptions{} - pr, _, err := client.PullRequests.Get(pullNum, &opts) - if err != nil { - return - } - createdBy := pr.GetCreatedBy() - user = models.User{Username: createdBy.GetUniqueName()} - baseRepo, err = e.ParseAzureDevopsRepo(pr.GetRepository()) - if err != nil { - return - } - - return baseRepo, user, pullNum, err + return pullRefs, err } // ParseAzureDevopsRepo parses the response from the Azure Devops API endpoint that diff --git a/server/events_controller.go b/server/events_controller.go index 982618b60..5ff2d2ea9 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -21,6 +21,7 @@ import ( "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" "github.com/mcdafydd/go-azuredevops/azuredevops" + "github.com/microcosm-cc/bluemonday" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" @@ -231,7 +232,7 @@ func (e *EventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http. e.respond(w, logging.Debug, http.StatusBadRequest, "Error unmarshaling webhook payload %s", azuredevopsReqID) }*/ switch event.PayloadType { - case azuredevops.WorkItemEvent: + case azuredevops.WorkItemCommentedEvent: e.Logger.Debug("handling as comment event") e.HandleAzureDevopsCommentEvent(w, event, azuredevopsReqID) case azuredevops.PullRequestEvent: @@ -459,32 +460,42 @@ func (e *EventsController) HandleGitlabMergeRequestEvent(w http.ResponseWriter, // HandleAzureDevopsCommentEvent handles comment events from Azure Devops where Atlantis // commands can come from. It's exported to make testing easier. +// Sometimes we may want data from the parent azuredevops.Event struct, so we handle type checking here. func (e *EventsController) HandleAzureDevopsCommentEvent(w http.ResponseWriter, event *azuredevops.Event, azuredevopsReqID string) { - workItem := event.Resource.(*azuredevops.WorkItem) - fields := *workItem.Fields - if fields["System.State"] != "New" { - e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since action was not created %s", azuredevopsReqID) + comment := new(string) + workItem, ok := event.Resource.(*azuredevops.WorkItem) + if !ok || event.PayloadType != azuredevops.WorkItemCommentedEvent { + e.respond(w, logging.Debug, http.StatusBadRequest, "Event.Resource is nil or received bad event type %v; Request-Id = %s", event.Resource, azuredevopsReqID) return } + *comment, ok = (*workItem.Fields)["System.History"].(string) + if !ok { + e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since comment is not a string; Request-Id = %s", azuredevopsReqID) + return + } + strippedComment := bluemonday.StrictPolicy().SanitizeBytes([]byte(*comment)) + + if len(workItem.Relations) == 0 { + e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since no pull request is linked to work item; Request-Id = %s", azuredevopsReqID) + return + } + pullRefs, err := e.Parser.ParseAzureDevopsWorkItemCommentedEvent(workItem) - baseRepo, user, pullNum, err := e.Parser.ParseAzureDevopsWorkItemEvent(workItem) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Failed parsing event: %v %s", err, azuredevopsReqID) return } - // *** Verify this for Azure Devops - comes from Github behavior *** - // We pass in nil for maybeHeadRepo because the head repo data isn't - // available in the AzureDevopsWorkItem event. - comment := fields["System.History"] - e.handleCommentEvent(w, baseRepo, nil, nil, user, pullNum, comment, models.AzureDevops) + for _, ref := range pullRefs { + e.handleCommentEvent(w, ref.BaseRepo, nil, nil, ref.User, ref.PullNum, string(strippedComment), models.AzureDevops) + } } // HandleAzureDevopsPullRequestEvent will delete any locks associated with the pull // request if the event is a pull request closed event. It's exported to make // testing easier. -func (e *EventsController) HandleAzureDevopsPullRequestEvent(w http.ResponseWriter, pullEvent *azuredevops.Event, azuredevopsReqID string) { - pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseAzureDevopsPullEvent(*pullEvent) +func (e *EventsController) HandleAzureDevopsPullRequestEvent(w http.ResponseWriter, event *azuredevops.Event, azuredevopsReqID string) { + pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseAzureDevopsPullEvent(*event) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s", err, azuredevopsReqID) return From 36a849d2b5bfadeeef785ca169c1cec07214774c Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Mon, 29 Apr 2019 09:55:37 -0500 Subject: [PATCH 23/64] Fixes steps that use cloneURL for Azure Devops --- server/events/event_parser.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index d610e7ff9..e20563a13 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -890,6 +890,13 @@ func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (m owner = "" } } - fullName := fmt.Sprintf("%s/%s/%s", owner, teamProject.GetName(), adRepo.GetName()) - return models.NewRepo(models.AzureDevops, fullName, teamProject.GetName(), adRepo.GetRemoteURL(), e.AzureDevopsUser, e.AzureDevopsToken) + + // Construct our own clone URL so we always get the new dev.azure.com + // hostname for now. + // https://docs.microsoft.com/en-us/azure/devops/release-notes/2018/sep-10-azure-devops-launch#switch-existing-organizations-to-use-the-new-domain-name-url + project := teamProject.GetName() + repo := adRepo.GetName() + cloneURL := fmt.Sprintf("https://dev.azure.com/%s/%s/_git/%s", owner, project, repo) + fullName := fmt.Sprintf("%s/%s/%s", owner, project, repo) + return models.NewRepo(models.AzureDevops, fullName, teamProject.GetName(), cloneURL, e.AzureDevopsUser, e.AzureDevopsToken) } From bf1ccc47db812fc22bc7aac53e33f33ff589d342 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Mon, 29 Apr 2019 20:19:02 -0500 Subject: [PATCH 24/64] Azure Devops support - Fixes logic error in PullIsMergeable() - Replace GetRemoteURL() with GetWebURL() - returns what we want --- server/events/event_parser.go | 2 +- server/events/vcs/azuredevops_client.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index e20563a13..22e0b2af9 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -876,7 +876,7 @@ func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (m if parent != nil { owner = parent.GetName() } else { - uri, err := url.Parse(adRepo.GetRemoteURL()) + uri, err := url.Parse(adRepo.GetWebURL()) if err != nil { return models.Repo{}, err } diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index be1f7e4dc..3cff8efa8 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -171,9 +171,9 @@ func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRe } if *adPull.MergeStatus != azuredevops.MergeConflicts.String() && *adPull.MergeStatus != azuredevops.MergeRejectedByPolicy.String() { - return false, nil + return true, nil } - return true, nil + return false, nil } // GetPullRequest returns the pull request. From 7f1fd23f0ff049ed2495b64274ec2b45a4ef4c5e Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 17 May 2019 09:31:01 -0500 Subject: [PATCH 25/64] Azure Devops fork updates - Remove project from NewRepo() calls - Add new function SplitAzureDevopsRepoFullName --- server/events/models/models.go | 62 +++++++++++++++---- server/events/models/models_test.go | 96 ++++++++++++++++++++++++++--- server/events_controller_test.go | 4 +- 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/server/events/models/models.go b/server/events/models/models.go index 8892e43ee..5277e4f29 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -20,6 +20,7 @@ import ( "fmt" "net/url" paths "path" + "regexp" "strings" "time" @@ -33,11 +34,11 @@ import ( // Repo is a VCS repository. type Repo struct { // FullName is the owner and repo name separated - // by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", + // by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", // "Bitbucket Server/atlantis", "azuredevops/project/atlantis". FullName string // Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup" - // or azuredevops/project. This may contain /'s in the case of GitLab + // or azuredevops/project. This may contain /'s in the case of GitLab // subgroups or Azure Devops Team Projects. This may contain spaces in // the case of Bitbucket Server. Owner string @@ -67,7 +68,7 @@ func (r Repo) ID() string { // cloneURL can be with or without .git at the end // ex. https://github.com/runatlantis/atlantis.git OR // https://github.com/runatlantis/atlantis -func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) { +func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) { if repoFullName == "" { return Repo{}, errors.New("repoFullName can't be empty") } @@ -76,8 +77,14 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, clone } if vcsHostType == AzureDevops { - if project == "" { - return Repo{}, errors.New("AzureDevops project name can't be empty") + // https://docs.microsoft.com/en-us/azure/devops/organizations/settings/naming-restrictions?view=azure-devops + matched, err := regexp.MatchString("[A-z0-9][A-z0-9._]+/[A-z0-9][A-z0-9._]+/[A-z0-9][A-z0-9._]+", repoFullName) + if err != nil { + e := fmt.Sprintf("NewRepo error vcsHostType = AzureDevops: %s", err) + return Repo{}, errors.New(e) + } + if !matched { + return Repo{}, errors.New("AzureDevops repoFullName must be of the format owner/project/repo") } } @@ -114,14 +121,19 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, project string, clone authedCloneURL := strings.Replace(cloneURL, "https://", "https://"+auth, -1) authedCloneURL = strings.Replace(authedCloneURL, "http://", "http://"+auth, -1) - // Get the owner and repo names from the full name. - owner, repo := SplitRepoFullName(repoFullName) + // Get the owner, project, and repo names from the full name. + var owner, project, repo string + if vcsHostType == AzureDevops { + owner, project, repo = SplitAzureDevopsRepoFullName(repoFullName) + } else { + owner, repo = SplitRepoFullName(repoFullName) + } if owner == "" || repo == "" { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q or repo %q was empty", repoFullName, owner, repo) } - // Only GitLab and Azure Devops repos can have /'s in their owners. + // Only GitLab repos can have /'s in their owners. // This is for GitLab subgroups and Azure Devops Team Projects. - if strings.Contains(owner, "/") && vcsHostType != Gitlab && vcsHostType != AzureDevops { + if strings.Contains(owner, "/") && vcsHostType != Gitlab { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q should not contain any /'s", repoFullName, owner) } if strings.Contains(repo, "/") { @@ -358,20 +370,44 @@ type ProjectCommandContext struct { Workspace string } -// SplitRepoFullName splits a repo full name up into its owner and repo name -// segments. If the repoFullName is malformed, may return empty strings -// for owner or repo. +// SplitRepoFullName splits a repo full name up into its owner and repo +// name segments. If the repoFullName is malformed, may return empty +// strings for owner or repo. // Ex. runatlantis/atlantis => (runatlantis, atlantis) // gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) -// azuredevops/project/atlantis => (azuredevops/project, atlantis) +// azuredevops/project/atlantis => (azuredevops, project, atlantis) func SplitRepoFullName(repoFullName string) (owner string, repo string) { lastSlashIdx := strings.LastIndex(repoFullName, "/") if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { return "", "" } + return repoFullName[:lastSlashIdx], repoFullName[lastSlashIdx+1:] } +// SplitAzureDevopsRepoFullName splits a repo full name up into its owner, +// repo and project name segments. If the repoFullName is malformed, may +// return empty strings for owner, repo, or project. Azure Devops uses +// repoFullName format owner/project/repo. +// +// Ex. runatlantis/atlantis => (runatlantis, atlantis) +// gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) +// azuredevops/project/atlantis => (azuredevops, project, atlantis) +func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project string, repo string) { + firstSlashIdx := strings.Index(repoFullName, "/") + lastSlashIdx := strings.LastIndex(repoFullName, "/") + slashCount := strings.Count(repoFullName, "/") + if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { + return "", "", "" + } + if firstSlashIdx != lastSlashIdx && slashCount == 2 { + return repoFullName[:firstSlashIdx], + repoFullName[firstSlashIdx+1 : lastSlashIdx], + repoFullName[lastSlashIdx+1:] + } + return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] +} + // ProjectResult is the result of executing a plan/apply for a specific project. type ProjectResult struct { Command CommandName diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index 49667c159..68724a4e3 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -23,34 +23,34 @@ import ( ) func TestNewRepo_EmptyRepoFullName(t *testing.T) { - _, err := models.NewRepo(models.Github, "", "", "https://github.com/notowner/repo.git", "u", "p") + _, err := models.NewRepo(models.Github, "", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, "repoFullName can't be empty", err) } func TestNewRepo_EmptyCloneURL(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", "", "", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", "", "u", "p") ErrEquals(t, "cloneURL can't be empty", err) } func TestNewRepo_InvalidCloneURL(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", "", ":", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", ":", "u", "p") ErrEquals(t, "invalid clone url: parse :.git: missing protocol scheme", err) } func TestNewRepo_CloneURLWrongRepo(t *testing.T) { - _, err := models.NewRepo(models.Github, "owner/repo", "", "https://github.com/notowner/repo.git", "u", "p") + _, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, `expected clone url to have path "/owner/repo.git" but had "/notowner/repo.git"`, err) } func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) { - _, err := models.NewRepo(models.AzureDevops, "owner/repo", "", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") + _, err := models.NewRepo(models.AzureDevops, "owner/repo", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") ErrEquals(t, "AzureDevops project name can't be empty", err) } // For bitbucket server we don't validate the clone URL because the callers // are actually constructing it. func TestNewRepo_CloneURLBitbucketServer(t *testing.T) { - repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ FullName: "owner/repo", @@ -98,7 +98,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { for _, c := range cases { t.Run(c.repoFullName, func(t *testing.T) { cloneURL := fmt.Sprintf("https://github.com/%s.git", c.repoFullName) - _, err := models.NewRepo(models.Github, c.repoFullName, "", cloneURL, "u", "p") + _, err := models.NewRepo(models.Github, c.repoFullName, cloneURL, "u", "p") ErrEquals(t, c.expErr, err) }) } @@ -106,7 +106,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { // If the clone url doesn't end with .git, and VCS is not Azure Devops, it is appended func TestNewRepo_MissingDotGit(t *testing.T) { - repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "", "https://bitbucket.org/owner/repo", "u", "p") + repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo.git") Equals(t, repo.SanitizedCloneURL, "https://bitbucket.org/owner/repo.git") @@ -114,7 +114,7 @@ func TestNewRepo_MissingDotGit(t *testing.T) { func TestNewRepo_HTTPAuth(t *testing.T) { // When the url has http the auth should be added. - repo, err := models.NewRepo(models.Github, "owner/repo", "", "http://github.com/owner/repo.git", "u", "p") + repo, err := models.NewRepo(models.Github, "owner/repo", "http://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ @@ -131,7 +131,7 @@ func TestNewRepo_HTTPAuth(t *testing.T) { func TestNewRepo_HTTPSAuth(t *testing.T) { // When the url has https the auth should be added. - repo, err := models.NewRepo(models.Github, "owner/repo", "", "https://github.com/owner/repo.git", "u", "p") + repo, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ @@ -266,6 +266,82 @@ func TestSplitRepoFullName(t *testing.T) { } } +// These test cases should cover the same behavior as TestSplitRepoFullName +// and only produce different output in the AzureDevops case of +// owner/project/repo. +func TestAzureDevopsSplitRepoFullName(t *testing.T) { + cases := []struct { + input string + expOwner string + expRepo string + expProject string + }{ + { + "owner/repo", + "owner", + "repo", + "", + }, + { + "group/subgroup/owner/repo", + "group/subgroup/owner", + "repo", + "", + }, + { + "group/subgroup/owner/project/repo", + "group/subgroup/owner/project", + "repo", + "", + }, + { + "", + "", + "", + "", + }, + { + "/", + "", + "", + "", + }, + { + "owner/", + "", + "", + "", + }, + { + "/repo", + "", + "repo", + "", + }, + { + "group/subgroup/", + "", + "", + "", + }, + { + "owner/project/repo", + "owner", + "repo", + "project", + }, + } + + for _, c := range cases { + t.Run(c.input, func(t *testing.T) { + owner, project, repo := models.SplitAzureDevopsRepoFullName(c.input) + Equals(t, c.expOwner, owner) + Equals(t, c.expProject, project) + Equals(t, c.expRepo, repo) + }) + } +} + func TestProjectResult_IsSuccessful(t *testing.T) { cases := map[string]struct { pr models.ProjectResult diff --git a/server/events_controller_test.go b/server/events_controller_test.go index 71e6546a1..b71be8177 100644 --- a/server/events_controller_test.go +++ b/server/events_controller_test.go @@ -205,7 +205,7 @@ func TestPost_GitlabCommentNotWhitelisted(t *testing.T) { body, _ := ioutil.ReadAll(w.Result().Body) exp := "Repo not whitelisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) - expRepo, _ := models.NewRepo(models.Gitlab, "gitlabhq/gitlab-test", "", "https://example.com/gitlabhq/gitlab-test.git", "", "") + expRepo, _ := models.NewRepo(models.Gitlab, "gitlabhq/gitlab-test", "https://example.com/gitlabhq/gitlab-test.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment(expRepo, 1, "```\nError: This repo is not whitelisted for Atlantis.\n```") } @@ -263,7 +263,7 @@ func TestPost_GithubCommentNotWhitelisted(t *testing.T) { body, _ := ioutil.ReadAll(w.Result().Body) exp := "Repo not whitelisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) - expRepo, _ := models.NewRepo(models.Github, "baxterthehacker/public-repo", "", "https://github.com/baxterthehacker/public-repo.git", "", "") + expRepo, _ := models.NewRepo(models.Github, "baxterthehacker/public-repo", "https://github.com/baxterthehacker/public-repo.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment(expRepo, 2, "```\nError: This repo is not whitelisted for Atlantis.\n```") } From 5d7d3c0ea0fc595e3e17c859685c76f3ab771468 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 19 May 2019 20:36:35 -0500 Subject: [PATCH 26/64] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 15f75eda8..c52065557 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Azure Devops Support (alpha) -This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops/tree/webhook-support) library. It will build and is somewhat functional now, but beware that work is still ongoing to fix panics, add more tests, fix work item comment support, and update documentation. +This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops/tree/webhook-support) library. As of tag `v0.7.1` of go-azuredeops, the basic suport for comments, statuses, running `atlantis help|plan|apply` seems to work. There is still much to do in terms of writing tests and documentation and operational testing. ## Resources * How to get started: [www.runatlantis.io/guide](https://www.runatlantis.io/guide) From 12f95c5b7970533b8ace93679b18e499a9a4b65e Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 19 May 2019 20:54:04 -0500 Subject: [PATCH 27/64] Azure Devops support - Add blackfriday for markdown-to-html comment translation Azure Devops doesn't support markdown in work item comments - Restore NewRepo() to original form - removing project parameter - Add models.SplitAzureDevopsRepoFullName() to handle project name All Azure Devops URIs need owner, project, and repo. This function allows us to avoid adding project to NewRepo() - Update calls to azuredevops.NewClient() to match library requirements - Add context to azuredevops client to match library requirements - Basic working CreateComment() and MergePull() support (not widely tested) --- Gopkg.toml | 4 + README.md | 2 +- README_azuredevops.md | 15 ++ server/events/command_context.go | 6 + server/events/event_parser.go | 28 ++- server/events/vcs/azuredevops_client.go | 229 ++++++++++++------ server/events/vcs/azuredevops_client_test.go | 19 +- .../events/vcs/bitbucketcloud/client_test.go | 2 +- server/locks_controller.go | 14 +- server/server.go | 2 +- server/web_templates.go | 1 + 11 files changed, 228 insertions(+), 94 deletions(-) create mode 100644 README_azuredevops.md diff --git a/Gopkg.toml b/Gopkg.toml index 09d51f87a..2b12ad411 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -104,6 +104,10 @@ branch = "master" name = "github.com/microcosm-cc/bluemonday" +[[constraint]] + branch = "master" + name = "gopkg.in/russross/blackfriday.v2" + [[constraint]] name = "github.com/go-test/deep" version = "1.0.1" diff --git a/README.md b/README.md index c52065557..e81853394 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Azure Devops Support (alpha) -This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops/tree/webhook-support) library. As of tag `v0.7.1` of go-azuredeops, the basic suport for comments, statuses, running `atlantis help|plan|apply` seems to work. There is still much to do in terms of writing tests and documentation and operational testing. +This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops) library. Please see `README_azuredevops.md` for more information. ## Resources * How to get started: [www.runatlantis.io/guide](https://www.runatlantis.io/guide) diff --git a/README_azuredevops.md b/README_azuredevops.md new file mode 100644 index 000000000..0f711d2ad --- /dev/null +++ b/README_azuredevops.md @@ -0,0 +1,15 @@ +# Atlantis + +

    + Atlantis Logo

    + Terraform Pull Request Automation +

    + +## Azure Devops Support (alpha) + +As of tag `v0.7.1` of [go-azuredevops](https://github.com/mcdafydd/go-azuredevops), basic suport for comments, statuses, running `atlantis help|plan|apply` appears to be functional. There is still much to do in terms of writing tests and documentation and operational testing. + +Known issues: + +* Merging pull requests doesn't seem to be removing the locks + diff --git a/server/events/command_context.go b/server/events/command_context.go index 5a2bcfe0b..4c181e594 100644 --- a/server/events/command_context.go +++ b/server/events/command_context.go @@ -37,4 +37,10 @@ type CommandContext struct { // set our own build statuses which can affect mergeability if users have // required the Atlantis status to be successful prior to merging. PullMergeable bool + // AzureDevopsWorkItemID Azure Devops Atlantis commands come from a work item + // comment with a link to one or more pull requests. Pull requests may + // also be linked to more than one work item, so we need to know which work item + // triggered this pull request command. + // https://docs.microsoft.com/en-us/azure/devops/boards/queries/link-work-items-support-traceability?view=azure-devops&tabs=new-web-form + AzureDevopsWorkItemID int } diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 22e0b2af9..d16365c52 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -14,12 +14,14 @@ package events import ( + "context" "encoding/json" "fmt" "net/url" "path" "strconv" "strings" + "time" "github.com/google/go-github/github" gitlab "github.com/lkysow/go-gitlab" @@ -343,7 +345,6 @@ func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.Co headRepo, err = models.NewRepo( models.BitbucketCloud, *event.PullRequest.Source.Repository.FullName, - "", *event.PullRequest.Source.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) @@ -353,7 +354,6 @@ func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.Co baseRepo, err = models.NewRepo( models.BitbucketCloud, *event.Repository.FullName, - "", *event.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) @@ -518,7 +518,7 @@ func (e *EventParser) ParseGithubPull(pull *github.PullRequest) (pullModel model // returns a repo into the Atlantis model. // See EventParsing for return value docs. func (e *EventParser) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) { - return models.NewRepo(models.Github, ghRepo.GetFullName(), "", ghRepo.GetCloneURL(), e.GithubUser, e.GithubToken) + return models.NewRepo(models.Github, ghRepo.GetFullName(), ghRepo.GetCloneURL(), e.GithubUser, e.GithubToken) } // ParseGitlabMergeRequestEvent parses GitLab merge request events. @@ -532,11 +532,11 @@ func (e *EventParser) ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) (pul // GitLab also has a "merged" state, but we map that to Closed so we don't // need to check for it. - baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, "", event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken) + baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } - headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, "", event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken) + headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } @@ -577,7 +577,7 @@ func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeComm // Parse the base repo first. repoFullName := event.Project.PathWithNamespace cloneURL := event.Project.GitHTTPURL - baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, "", cloneURL, e.GitlabUser, e.GitlabToken) + baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) if err != nil { return } @@ -588,7 +588,7 @@ func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeComm // Now parse the head repo. headRepoFullName := event.MergeRequest.Source.PathWithNamespace headCloneURL := event.MergeRequest.Source.GitHTTPURL - headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, "", headCloneURL, e.GitlabUser, e.GitlabToken) + headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, headCloneURL, e.GitlabUser, e.GitlabToken) return } @@ -666,7 +666,6 @@ func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver. headRepo, err = models.NewRepo( models.BitbucketServer, headRepoFullname, - "", headRepoCloneURL, e.BitbucketUser, e.BitbucketToken) @@ -680,7 +679,6 @@ func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver. baseRepo, err = models.NewRepo( models.BitbucketServer, baseRepoFullname, - "", baseRepoCloneURL, e.BitbucketUser, e.BitbucketToken) @@ -840,14 +838,20 @@ func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevop } // Retrieve the linked pull request to get baseRepo and user + tp := azuredevops.BasicAuthTransport{ + Username: "", + Password: e.AzureDevopsToken, + } + httpClient := tp.Client() + httpClient.Timeout = 10 * time.Second client := new(azuredevops.Client) - client, err = azuredevops.NewClient(e.AzureDevopsOrg, e.AzureDevopsProject, e.AzureDevopsToken, nil) + client, err = azuredevops.NewClient(httpClient) if err != nil { return } pr := new(azuredevops.GitPullRequest) opts := azuredevops.PullRequestListOptions{} - pr, _, err = client.PullRequests.Get(ref.PullNum, &opts) + pr, _, err = client.PullRequests.Get(context.Background(), e.AzureDevopsOrg, e.AzureDevopsProject, ref.PullNum, &opts) if err != nil { return } @@ -898,5 +902,5 @@ func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (m repo := adRepo.GetName() cloneURL := fmt.Sprintf("https://dev.azure.com/%s/%s/_git/%s", owner, project, repo) fullName := fmt.Sprintf("%s/%s/%s", owner, project, repo) - return models.NewRepo(models.AzureDevops, fullName, teamProject.GetName(), cloneURL, e.AzureDevopsUser, e.AzureDevopsToken) + return models.NewRepo(models.AzureDevops, fullName, cloneURL, e.AzureDevopsUser, e.AzureDevopsToken) } diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 3cff8efa8..aef4b93a9 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -14,41 +14,48 @@ package vcs import ( + "context" "fmt" - "net/http" - "net/url" + "strconv" + "strings" "time" version "github.com/hashicorp/go-version" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/vcs/common" - "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/events/models" + + "gopkg.in/russross/blackfriday.v2" ) type AzureDevopsClient struct { Client *azuredevops.Client // Version is set to the server version. Version *version.Version + ctx context.Context } // azuredevopsClientUnderTest is true if we're running under go test. var azuredevopsClientUnderTest = false // NewAzureDevopsClient returns a valid Azure Devops client. -func NewAzureDevopsClient(org string, username string, project string, token string, logger *logging.SimpleLogger) (*AzureDevopsClient, error) { - var httpClient = http.Client{ - Timeout: time.Second * 10, +func NewAzureDevopsClient(org string, username string, project string, token string) (*AzureDevopsClient, error) { + tp := azuredevops.BasicAuthTransport{ + Username: "", + Password: strings.TrimSpace(token), } - var adClient, err = azuredevops.NewClient(org, project, token, &httpClient) + httpClient := tp.Client() + httpClient.Timeout = time.Second * 10 + var adClient, err = azuredevops.NewClient(httpClient) if err != nil { return nil, errors.Wrapf(err, "azuredevops.NewClient() %p", adClient) } client := &AzureDevopsClient{ Client: adClient, + ctx: context.Background(), } return client, nil @@ -58,29 +65,19 @@ func NewAzureDevopsClient(org string, username string, project string, token str // The names include the path to the file from the repo root, ex. parent/child/file.txt. func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { var files []string + commitIDResponse := new(azuredevops.GitPullRequest) - // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20requests?view=azure-devops-rest-5.1 - apiURL := fmt.Sprintf("_apis/git/pullrequests/%d?api-version=5.1-preview.1", pull.Num) - req, err := g.Client.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, err + opts := azuredevops.PullRequestGetOptions{ + IncludeWorkItemRefs: true, } + commitIDResponse, _, _ = g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) - var commitIdResponse azuredevops.GitPullRequest - _, err = g.Client.Execute(req, &commitIdResponse) - commitId := commitIdResponse.GetLastMergeSourceCommit().GetCommitID() + commitID := commitIDResponse.GetLastMergeSourceCommit().GetCommitID() - // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20changes?view=azure-devops-rest-5.1 - apiURL = fmt.Sprintf("_apis/git/repositories/%s/commits/%s/changes?api-version=5.1-preview.1", url.QueryEscape(repo.Name), commitId) - req, err = g.Client.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, err - } + r := new(azuredevops.GitCommitChanges) + r, _, _ = g.Client.Git.GetChanges(g.ctx, repo.Owner, repo.Project, repo.Name, commitID) - var response azuredevops.GitCommitChanges - _, err = g.Client.Execute(req, &response) - - for _, change := range response.GetChanges() { + for _, change := range r.Changes { item := change.GetItem() files = append(files, item.GetPath()) @@ -95,59 +92,110 @@ func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullR return files, nil } -// CreateComment creates a comment on the merge request. +// CreateComment creates a comment on a work item linked to pullNum. +// Comments made on pull requests cannot trigger webhooks as of April, 2019. +// Since it is possible to link multiple work items to a single pull request, we: +// 1. Get the pull request +// 2. Get all linked workItems +// a. If only one, comment on it +// b. If more than one, loop through each item and +// i. Get comments for that work item +// ii. Comment on the first workItem with a valid atlantis command in a comment +// iii. Return, ignoring any other items +// iv. If no work items contain comments with a valid atlantis command, return err +// c. If anything else, return warning error // If comment length is greater than the max comment length we split into // multiple comments. +// Azure Devops doesn't support markdown in Work Item comments, but it will +// convert text to HTML. We use the blackfriday library to convert Atlantis +// comment markdown before submission. func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment string) error { sepEnd := "\n```\n
    " + "\n
    \n\n**Warning**: Output length greater than max comment size. Continued in next comment." sepStart := "Continued from previous comment.\n
    Show Output\n\n" + "```diff\n" - maxCommentLength := 72 + // maxCommentLength is the maximum number of chars allowed in a single comment + // This length was copied from the Github client - haven't found documentation + // or tested limit in Azure Devops. + const maxCommentLength = 65536 + comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart) - adComments := make([]azuredevops.Comment, len(comments)) - ct := azuredevops.CommentTypeText.String() - for i, c := range comments { - entry := azuredevops.Comment{ - CommentType: &ct, - Content: &c, + opts := azuredevops.PullRequestGetOptions{ + IncludeWorkItemRefs: true, + } + pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pullNum, &opts) + if err != nil { + return err + } + if len(pull.WorkItemRefs) == 1 { + workItemID, err := strconv.Atoi(*pull.WorkItemRefs[0].ID) + if err != nil { + return err } - adComments[i] = entry + for _, c := range comments { + input := blackfriday.Run([]byte(c)) + s := string(input) + workItemComment := azuredevops.WorkItemComment{ + Text: &s, + } + _, _, err := g.Client.WorkItems.CreateComment(g.ctx, repo.Owner, repo.Project, workItemID, &workItemComment) + if err != nil { + return err + } + } + return nil + } else if len(pull.WorkItemRefs) > 1 { + // Until we figure out a nice way to leverage CommentParser.Parse() + // here, we're going to log an error and continue if the user has a + // pull request linked to more than one work item. An example + // implementation is commented below. + return errors.New("pull request linked to more than one work item - ignoring") + /* Example handling of PR linked to multiple work items + for _, ref := range pull.WorkItemRefs { + workItemID, err := strconv.Atoi(*ref.ID) + if err != nil { + return err + } + opts := azuredevops.WorkItemCommentListOptions{} + r, _, err := g.Client.WorkItems.ListComments(g.ctx, repo.Owner, repo.Project, workItemID, &opts) + if err != nil { + return err + } + for _, comment := range r.Comments { + fmt.Println(comment) + parseResult := commentParser.Parse(comment, vcsHost) + if !parseResult.Ignore { + // This is likely the correct Work Item + input := blackfriday.Run([]byte(comment.GetText())) + s := string(input) + workItemComment := azuredevops.WorkItemComment{ + Text: &s, + } + _, _, err = g.Client.WorkItems.CreateComment(g.ctx, repo.Owner, repo.Project, workItemID, &workItemComment) + if err != nil { + return err + } + return nil + } + } + } + */ } - // Need to create a thread first and save the thread number from response - // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20threads/create#comment_on_the_pull_request - apiURL := fmt.Sprintf("_apis/git/repositories/%s/pullRequests/%d/threads?api-version=5.1-preview.1", url.QueryEscape(repo.Name), pullNum) - req, err := g.Client.NewRequest("POST", apiURL, nil) - if err != nil { - return err - } - var threadResp azuredevops.GitPullRequestCommentThread - _, err = g.Client.Execute(req, &threadResp) - - threadID := threadResp.GetID() - // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20thread%20comments/create - apiURL = fmt.Sprintf("_apis/git/repositories/%s/pullRequests/%d/threads/%d/comments?api-version=5.1-preview.1", url.QueryEscape(repo.Name), pullNum, threadID) - threadResp.Comments = &adComments - req, err = g.Client.NewRequest("POST", apiURL, threadResp) - if err != nil { - return err - } - var response azuredevops.GitPullRequestCommentThread - _, err = g.Client.Execute(req, &response) - - return nil + return err } // PullIsApproved returns true if the merge request was approved. func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { // previews List() args were g.ctx, repo.Owner, repo.Name, pull.Num, nil - opts := &azuredevops.PullRequestListOptions{} - adPull, _, err := g.Client.PullRequests.Get(pull.Num, opts) + opts := azuredevops.PullRequestGetOptions{ + IncludeWorkItemRefs: true, + } + adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) if err != nil { return false, errors.Wrap(err, "getting pull request") } - reviews := adPull.GetReviewers() + reviews := adPull.Reviewers if err != nil { return false, errors.Wrap(err, "getting list of pull request reviewers") } @@ -164,8 +212,10 @@ func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullReq // PullIsMergeable returns true if the merge request can be merged. func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) { - opts := &azuredevops.PullRequestListOptions{} - adPull, _, err := g.Client.PullRequests.Get(pull.Num, opts) + opts := azuredevops.PullRequestGetOptions{ + IncludeWorkItemRefs: true, + } + adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) if err != nil { return false, errors.Wrap(err, "getting pull request") } @@ -178,8 +228,10 @@ func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRe // GetPullRequest returns the pull request. func (g *AzureDevopsClient) GetPullRequest(repo models.Repo, num int) (*azuredevops.GitPullRequest, error) { - opts := &azuredevops.PullRequestListOptions{} - pull, _, err := g.Client.PullRequests.Get(num, opts) + opts := azuredevops.PullRequestGetOptions{ + IncludeWorkItemRefs: true, + } + pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, num, &opts) return pull, err } @@ -207,7 +259,7 @@ func (g *AzureDevopsClient) UpdateStatus(repo models.Repo, pull models.PullReque if url != "" { status.TargetURL = &url } - _, _, err := g.Client.Git.CreateStatus(repo.Name, pull.HeadCommit, status) + _, _, err := g.Client.Git.CreateStatus(g.ctx, repo.Owner, repo.Project, repo.Name, pull.HeadCommit, status) return err } @@ -232,23 +284,60 @@ func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { options := &azuredevops.GitPullRequestCompletionOptions{ Status: "completed", */ - // id *IdentityRef, commitMsg string, opts *PullRequestListOptions + // id *IdentityRef, commitMsg string, opts *PullRequestGetOptions descriptor := "Atlantis Terraform Pull Request Automation" i := "atlantis" - imageUrl := "https://github.com/runatlantis/atlantis/raw/master/runatlantis.io/.vuepress/public/hero.png" - id := &azuredevops.IdentityRef{ + imageURL := "https://github.com/runatlantis/atlantis/raw/master/runatlantis.io/.vuepress/public/hero.png" + id := azuredevops.IdentityRef{ Descriptor: &descriptor, ID: &i, - ImageURL: &imageUrl, + ImageURL: &imageURL, + } + // Set default pull request completion options + mcm := azuredevops.NoFastForward.String() + twi := new(bool) + *twi = true + completionOpts := azuredevops.GitPullRequestCompletionOptions{ + BypassPolicy: new(bool), + BypassReason: azuredevops.String(""), + DeleteSourceBranch: new(bool), + MergeCommitMessage: azuredevops.String(common.AutomergeCommitMsg), + MergeStrategy: &mcm, + SquashMerge: new(bool), + TransitionWorkItems: twi, + TriggeredByAutoComplete: new(bool), } - commitMsg := "" + /* + const ( + defaultMergeMethod = "merge" + rebaseMergeMethod = "rebase" + squashMergeMethod = "squash" + ) + method := defaultMergeMethod + if !repo.GetAllowMergeCommit() { + if repo.GetAllowRebaseMerge() { + method = rebaseMergeMethod + } else if repo.GetAllowSquashMerge() { + method = squashMergeMethod + } + } + */ + // Construct request body from supplied parameters + mergePull := new(azuredevops.GitPullRequest) + mergePull.AutoCompleteSetBy = &id + mergePull.CompletionOptions = &completionOpts + mergeResult, _, err := g.Client.PullRequests.Merge( + g.ctx, + pull.BaseRepo.Owner, + pull.BaseRepo.Project, pull.BaseRepo.Name, pull.Num, + mergePull, + completionOpts, id, - commitMsg, ) if err != nil { return errors.Wrap(err, "merging pull request") diff --git a/server/events/vcs/azuredevops_client_test.go b/server/events/vcs/azuredevops_client_test.go index daf9a5888..d3d834f51 100644 --- a/server/events/vcs/azuredevops_client_test.go +++ b/server/events/vcs/azuredevops_client_test.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -88,10 +89,13 @@ func TestAzureDevopsClient_MergePull(t *testing.T) { } })) - var httpClient = &http.Client{ - Timeout: time.Second * 10, + tp := azuredevops.BasicAuthTransport{ + Username: "", + Password: strings.TrimSpace("token"), } - internalClient := azuredevops.NewClient(account, project, "token", httpClient) + httpClient := tp.Client() + httpClient.Timeout = time.Second * 10 + internalClient, _ := azuredevops.NewClient(httpClient) Ok(t, internalClient.SetBaseURL(testServer.URL)) client := &AzureDevopsClient{ Client: internalClient, @@ -155,10 +159,13 @@ func TestAzureDevopsClient_UpdateStatus(t *testing.T) { } })) - var httpClient = &http.Client{ - Timeout: time.Second * 10, + tp := azuredevops.BasicAuthTransport{ + Username: "", + Password: strings.TrimSpace("token"), } - internalClient := azuredevops.NewClient(account, project, "token", httpClient) + httpClient := tp.Client() + httpClient.Timeout = time.Second * 10 + internalClient, _ := azuredevops.NewClient(httpClient) Ok(t, internalClient.SetBaseURL(testServer.URL)) client := &AzureDevopsClient{ Client: internalClient, diff --git a/server/events/vcs/bitbucketcloud/client_test.go b/server/events/vcs/bitbucketcloud/client_test.go index e4ecb762c..d9fda3f5a 100644 --- a/server/events/vcs/bitbucketcloud/client_test.go +++ b/server/events/vcs/bitbucketcloud/client_test.go @@ -200,7 +200,7 @@ func TestClient_PullIsApproved(t *testing.T) { client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") client.BaseURL = testServer.URL - repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "", "https://bitbucket.org/owner/repo.git", "user", "token") + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "https://bitbucket.org/owner/repo.git", "user", "token") Ok(t, err) approved, err := client.PullIsApproved(repo, models.PullRequest{ Num: 1, diff --git a/server/locks_controller.go b/server/locks_controller.go index 4f3f08444..28f7054c8 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -51,18 +51,26 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { return } - owner, repo := models.SplitRepoFullName(lock.Project.RepoFullName) viewData := LockDetailData{ LockKeyEncoded: id, LockKey: idUnencoded, - RepoOwner: owner, - RepoName: repo, PullRequestLink: lock.Pull.URL, LockedBy: lock.Pull.Author, Workspace: lock.Workspace, AtlantisVersion: l.AtlantisVersion, CleanedBasePath: l.AtlantisURL.Path, } + var owner, project, repo string + if lock.Pull.BaseRepo.VCSHost.Type == models.AzureDevops { + owner, project, repo = models.SplitAzureDevopsRepoFullName(lock.Project.RepoFullName) + } else { + owner, repo = models.SplitRepoFullName(lock.Project.RepoFullName) + project = "" + } + viewData.RepoOwner = owner + viewData.RepoProject = project + viewData.RepoName = repo + err = l.LockDetailTemplate.Execute(w, viewData) if err != nil { l.Logger.Err(err.Error()) diff --git a/server/server.go b/server/server.go index 33f51e3d8..3a4ac0722 100644 --- a/server/server.go +++ b/server/server.go @@ -155,7 +155,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if userConfig.AzureDevopsUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops) var err error - azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevopsOrg, userConfig.AzureDevopsUser, userConfig.AzureDevopsProject, userConfig.AzureDevopsToken, logger) + azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevopsOrg, userConfig.AzureDevopsUser, userConfig.AzureDevopsProject, userConfig.AzureDevopsToken) if err != nil { return nil, err } diff --git a/server/web_templates.go b/server/web_templates.go index 17ac82d1b..2623fc11f 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -113,6 +113,7 @@ type LockDetailData struct { LockKeyEncoded string LockKey string RepoOwner string + RepoProject string RepoName string PullRequestLink string LockedBy string From 299ed4d6beed9c0b78ba5f4be96dcca0416730c3 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 25 May 2019 18:57:23 -0500 Subject: [PATCH 28/64] Minor README update --- README_azuredevops.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README_azuredevops.md b/README_azuredevops.md index 0f711d2ad..f7c477401 100644 --- a/README_azuredevops.md +++ b/README_azuredevops.md @@ -13,3 +13,13 @@ Known issues: * Merging pull requests doesn't seem to be removing the locks +## Problems building in a fork + +Since the parent project is using dep and the vendor folder, I'm currently developing this inside the following folder on my workstation: + +`$GOPATH/src/github.com/runatlantis/atlantis/` + +This deals with the problem of breaking local package imports by trying to build inside `$GOPATH/src/github.com/mcdafydd/atlantis`. With Go modules, apparently `go mod replace` can help to address this issue more cleanly, but for now this works. I don't want to do a global search replace and then change it back before submitting any pull requests. + +I'm happy to hear any suggestions about better ways to handle this! + From 03d105093922a49446fa9d7b6a29c16516383406 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 12 Jul 2019 23:23:05 -0500 Subject: [PATCH 29/64] Fix minor typo, reorder flags --- cmd/server_test.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/server_test.go b/cmd/server_test.go index d64166d69..423706e04 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -340,20 +340,20 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { func TestExecute_Defaults(t *testing.T) { t.Log("Should set the defaults for all unspecified flags.") c := setup(map[string]interface{}{ - cmd.GHUserFlag: "user", - cmd.GHTokenFlag: "token", - cmd.GitlabUserFlag: "gitlab-user", - cmd.GitlabTokenFlag: "gitlab-token", - cmd.BitbucketUserFlag: "bitbucket-user", - cmd.BitbucketTokenFlag: "bitbucket-token", - cmd.ADBasicAuthFlag: "azuredevops-basic-auth", - cmd.ADBasicUser: "azuredevops-basic-user", - cmd.ADBasicPassowrd: "azuredevops-basic-password", - cmd.ADOrgFlag: "azuredevops-org", - cmd.ADProjectFlag: "azuredevops-project", - cmd.ADTokenFlag: "azuredevops-token", - cmd.ADUserFlag: "azuredevops-user", - cmd.RepoWhitelistFlag: "*", + cmd.GHUserFlag: "user", + cmd.GHTokenFlag: "token", + cmd.GitlabUserFlag: "gitlab-user", + cmd.GitlabTokenFlag: "gitlab-token", + cmd.BitbucketUserFlag: "bitbucket-user", + cmd.BitbucketTokenFlag: "bitbucket-token", + cmd.ADBasicAuthFlag: "azuredevops-basic-auth", + cmd.ADBasicUserFlag: "azuredevops-basic-user", + cmd.ADBasicPasswordFlag: "azuredevops-basic-password", + cmd.ADOrgFlag: "azuredevops-org", + cmd.ADProjectFlag: "azuredevops-project", + cmd.ADTokenFlag: "azuredevops-token", + cmd.ADUserFlag: "azuredevops-user", + cmd.RepoWhitelistFlag: "*", }) err := c.Execute() Ok(t, err) From f8d9e30a8e9e7875d765956999d9308bca775e56 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Tue, 16 Jul 2019 21:55:56 -0500 Subject: [PATCH 30/64] Update Azure Devops tests and get them to pass --- cmd/server_test.go | 2 +- server/azuredevops_request_validator_test.go | 57 +--- server/events/command_runner.go | 2 + server/events/command_runner_test.go | 1 + server/events/event_parser_test.go | 308 +++++++++++-------- server/events/vcs/azuredevops_client_test.go | 2 +- server/events/vcs/fixtures/fixtures.go | 143 ++++++++- server/events_controller.go | 2 +- 8 files changed, 332 insertions(+), 185 deletions(-) diff --git a/cmd/server_test.go b/cmd/server_test.go index 6f6b915ab..713073d04 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -184,7 +184,7 @@ func TestExecute_ValidateSSLConfig(t *testing.T) { } func TestExecute_ValidateVCSConfig(t *testing.T) { - expErr := "--gh-user/--gh-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" + expErr := "--gh-user/--gh-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token/--azuredevops-org/--azuredevops-project must be set" cases := []struct { description string flags map[string]interface{} diff --git a/server/azuredevops_request_validator_test.go b/server/azuredevops_request_validator_test.go index ebe6a536c..5e4d8b04e 100644 --- a/server/azuredevops_request_validator_test.go +++ b/server/azuredevops_request_validator_test.go @@ -16,7 +16,6 @@ package server_test import ( "bytes" "net/http" - "net/url" "testing" . "github.com/petergtz/pegomock" @@ -24,37 +23,37 @@ import ( . "github.com/runatlantis/atlantis/testing" ) -func TestValidate_WithSecretErr(t *testing.T) { - t.Log("if the request is not valid against the secret there is an error") +func TestAzureDevopsValidate_WithBasicAuthErr(t *testing.T) { + t.Log("if the request does not have a valid basic auth user and password there is an error") RegisterMockTestingT(t) g := server.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) - req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // user:pass req.Header.Set("Content-Type", "application/json") - _, err = g.Validate(req, []byte("user"), nil) + _, err = g.Validate(req, []byte("user"), []byte("wrongpass")) Assert(t, err != nil, "error should not be nil") - Equals(t, "payload signature check failed", err.Error()) + Equals(t, "ValidatePayload authentication failed", err.Error()) } -func TestValidate_WithSecret(t *testing.T) { - t.Log("if the request is valid against the secret the payload is returned") +func TestAzureDevopsValidate_WithBasicAuth(t *testing.T) { + t.Log("if the request has a valid basic auth user and password the payload is returned") RegisterMockTestingT(t) g := server.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString(`{"yo":true}`) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) - req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // user:pass req.Header.Set("Content-Type", "application/json") - bs, err := g.Validate(req, []byte("username"), []byte("0123456789abcdef")) + bs, err := g.Validate(req, []byte("user"), []byte("pass")) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } -func TestValidate_WithoutSecretInvalidContentType(t *testing.T) { +func TestAzureDevopsValidate_WithoutSecretInvalidContentType(t *testing.T) { t.Log("if the request has an invalid content type an error is returned") RegisterMockTestingT(t) g := server.DefaultAzureDevopsRequestValidator{} @@ -63,12 +62,12 @@ func TestValidate_WithoutSecretInvalidContentType(t *testing.T) { Ok(t, err) req.Header.Set("Content-Type", "invalid") - _, err = g.Validate(req, nil) + _, err = g.Validate(req, nil, nil) Assert(t, err != nil, "error should not be nil") Equals(t, "webhook request has unsupported Content-Type \"invalid\"", err.Error()) } -func TestValidate_WithoutSecretJSON(t *testing.T) { +func TestAzureDevopsValidate_WithoutSecretJSON(t *testing.T) { t.Log("if the request is JSON the body is returned") RegisterMockTestingT(t) g := server.DefaultAzureDevopsRequestValidator{} @@ -81,35 +80,3 @@ func TestValidate_WithoutSecretJSON(t *testing.T) { Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } - -func TestValidate_WithoutSecretFormNoPayload(t *testing.T) { - t.Log("if the request is form encoded and does not contain a payload param an error is returned") - RegisterMockTestingT(t) - g := server.DefaultAzureDevopsRequestValidator{} - buf := bytes.NewBufferString("") - req, err := http.NewRequest("POST", "http://localhost/event", buf) - Ok(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - _, err = g.Validate(req, nil, nil) - Assert(t, err != nil, "error should not be nil") - Equals(t, "webhook request did not contain expected 'payload' form value", err.Error()) -} - -func TestValidate_WithoutSecretForm(t *testing.T) { - t.Log("if the request is form encoded and does not contain a payload param an error is returned") - RegisterMockTestingT(t) - g := server.DefaultAzureDevopsRequestValidator{} - form := url.Values{} - form.Set("payload", `{"yo":true}`) - buf := bytes.NewBufferString(form.Encode()) - req, err := http.NewRequest("POST", "http://localhost/event", buf) - Ok(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - bs, err := g.Validate(req, nil, nil) - Ok(t, err) - Equals(t, `{"yo":true}`, string(bs)) -} - -// *** add tests for all supported content types diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 3d41b0266..45ff7db5a 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -46,6 +46,8 @@ type GithubPullGetter interface { GetPullRequest(repo models.Repo, pullNum int) (*github.PullRequest, error) } +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_azuredevops_pull_getter.go AzureDevopsPullGetter + // AzureDevopsPullGetter makes API calls to get pull requests. type AzureDevopsPullGetter interface { // GetPullRequest gets the pull request with id pullNum for the repo. diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index b9fc2b61c..9eb96a80a 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -36,6 +36,7 @@ import ( var projectCommandBuilder *mocks.MockProjectCommandBuilder var projectCommandRunner *mocks.MockProjectCommandRunner var eventParsing *mocks.MockEventParsing +var azuredevopsGetter *mocks.MockAzureDevopsPullGetter var githubGetter *mocks.MockGithubPullGetter var gitlabGetter *mocks.MockGitlabMergeRequestGetter var ch events.DefaultCommandRunner diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index 8061871c4..f0f4f0085 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -17,6 +17,9 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" + "net/http/httptest" + "path" "path/filepath" "strings" "testing" @@ -41,8 +44,8 @@ var parser = events.EventParser{ BitbucketServerURL: "http://mycorp.com:7490", AzureDevopsUser: "azuredevops-user", AzureDevopsToken: "azuredevops-token", - AzureDevopsOrg: "azuredevops-org", - AzureDevopsProject: "azuredevops-project", + AzureDevopsOrg: "owner", + AzureDevopsProject: "project", } func TestParseGithubRepo(t *testing.T) { @@ -1078,120 +1081,171 @@ func TestGetBitbucketServerEventType(t *testing.T) { } func TestParseAzureDevopsRepo(t *testing.T) { - r, err := parser.ParseAzureDevopsRepo(&Repo) + // this should be successful + repo := ADRepo + repo.ParentRepository = nil + r, err := parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner", - FullName: "owner/repo", - CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", - SanitizedCloneURL: Repo.GetCloneURL(), + Project: "project", + FullName: "owner/project/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", + SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, }, r) + + // this should be successful + repo = ADRepo + repo.WebURL = nil + r, err = parser.ParseAzureDevopsRepo(&repo) + Ok(t, err) + Equals(t, models.Repo{ + Owner: "owner", + Project: "project", + FullName: "owner/project/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", + SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "dev.azure.com", + Type: models.AzureDevops, + }, + }, r) + } -func TestParseAzureDevopsWorkItemCommentEvent(t *testing.T) { - comment := azuredevops.WorkItemCommentEvent{ - Repo: &Repo, - Issue: &azuredevops.Issue{ - Number: azuredevops.Int(1), - User: &azuredevops.User{Login: azuredevops.String("issue_user")}, - HTMLURL: azuredevops.String("https://azuredevops.com/runatlantis/atlantis/issues/1"), - }, - Comment: &azuredevops.WorkItemComment{ - User: &azuredevops.User{Login: azuredevops.String("comment_user")}, +func TestParseAzureDevopsWorkItemCommentedEvent(t *testing.T) { + event := azuredevops.Event{ + EventType: "workitem.commented", + Resource: azuredevops.WorkItem{ + ID: azuredevops.Int(1), + Relations: []*azuredevops.WorkItemRelation{ + { + Rel: azuredevops.String("System.LinkTypes.Hierarchy-Reverse"), + URL: azuredevops.String("vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22"), + Attributes: &map[string]interface{}{ + "IsLocked": false, + "Comment": "Decomposition of work", + }, + }, + }, }, } - testComment := deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) - testComment.Comment = nil - _, _, _, err := parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) - ErrEquals(t, "comment.user.login is null", err) + testEvent := deepcopy.Copy(event).(azuredevops.Event) + resource := testEvent.Resource.(azuredevops.WorkItem) + resource.Relations = nil + _, err := parser.ParseAzureDevopsWorkItemCommentedEvent(&resource, nil) + ErrEquals(t, "no valid pull request relations in event payload", err) - testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) - testComment.Comment.User = nil - _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) - ErrEquals(t, "comment.user.login is null", err) + testEvent = deepcopy.Copy(event).(azuredevops.Event) + resource = testEvent.Resource.(azuredevops.WorkItem) + resource.Relations[0] = nil + _, err = parser.ParseAzureDevopsWorkItemCommentedEvent(&resource, nil) + ErrEquals(t, "no valid pull request relations in event payload", err) - testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) - testComment.Comment.User.Login = nil - _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) - ErrEquals(t, "comment.user.login is null", err) - - testComment = deepcopy.Copy(comment).(azuredevops.WorkItemCommentEvent) - testComment.Issue = nil - _, _, _, err = parser.ParseAzureDevopsWorkItemCommentEvent(&testComment) - ErrEquals(t, "issue.number is null", err) + testEvent = deepcopy.Copy(event).(azuredevops.Event) + resource = testEvent.Resource.(azuredevops.WorkItem) + resource.Relations[0].URL = azuredevops.String("") + _, err = parser.ParseAzureDevopsWorkItemCommentedEvent(&resource, nil) + ErrEquals(t, "no valid pull request relations in event payload", err) // this should be successful - repo, user, pullNum, err := parser.ParseAzureDevopsWorkItemCommentEvent(&comment) + // This parse function requires a mock HTTP server to return the ADPull fixture + // after parsing the workitem.commented webhook event + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/owner/project/_apis/git/pullrequests/22", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, ADPullJSON) + }) + testEvent = deepcopy.Copy(event).(azuredevops.Event) + resource = testEvent.Resource.(azuredevops.WorkItem) + pullRefs, err := parser.ParseAzureDevopsWorkItemCommentedEvent(&resource, &server.URL) Ok(t, err) - Equals(t, models.Repo{ - Owner: *comment.Repo.Owner.Login, - FullName: *comment.Repo.FullName, - CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", - SanitizedCloneURL: *comment.Repo.CloneURL, - Name: "repo", - VCSHost: models.VCSHost{ - Hostname: "azuredevops.com", - Type: models.AzureDevops, - }, - }, repo) - Equals(t, models.User{ - Username: *comment.Comment.User.Login, - }, user) - Equals(t, *comment.Issue.Number, pullNum) + for _, ref := range pullRefs { + Ok(t, err) + Equals(t, models.Repo{ + Owner: "owner", + Project: "project", + FullName: "owner/project/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", + SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", + Name: "repo", + VCSHost: models.VCSHost{ + Hostname: "dev.azure.com", + Type: models.AzureDevops, + }, + }, ref.BaseRepo) + Equals(t, models.User{ + Username: "fabrikamfiber16@hotmail.com", + }, ref.User) + Equals(t, 22, ref.PullNum) + } } func TestParseAzureDevopsPullEvent(t *testing.T) { - _, _, _, _, _, err := parser.ParseAzureDevopsPullEvent(&azuredevops.PullRequestEvent{}) - ErrEquals(t, "pull_request is null", err) + _, _, _, _, _, err := parser.ParseAzureDevopsPullEvent(ADPullEvent) + Ok(t, err) - testEvent := deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) - testEvent.PullRequest.HTMLURL = nil - _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) - ErrEquals(t, "html_url is null", err) + testPull := deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.LastMergeSourceCommit.CommitID = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) - testEvent = deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) - testEvent.Sender = nil - _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) - ErrEquals(t, "sender is null", err) + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.URL = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "url is null", err) + testEvent := deepcopy.Copy(ADPullEvent).(azuredevops.Event) + resource := deepcopy.Copy(testEvent.Resource).(azuredevops.GitPullRequest) + resource.CreatedBy = nil + testEvent.Resource = resource + _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) + ErrEquals(t, "CreatedBy is null", err) - testEvent = deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) - testEvent.Sender.Login = nil - _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(&testEvent) - ErrEquals(t, "sender.login is null", err) + testEvent = deepcopy.Copy(ADPullEvent).(azuredevops.Event) + resource = deepcopy.Copy(testEvent.Resource).(azuredevops.GitPullRequest) + resource.CreatedBy.UniqueName = azuredevops.String("") + testEvent.Resource = resource + _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) + ErrEquals(t, "CreatedBy.UniqueName is null", err) - actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(&PullEvent) + actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(ADPullEvent) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner", - FullName: "owner/repo", - CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", - SanitizedCloneURL: Repo.GetCloneURL(), + Project: "project", + FullName: "owner/project/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", + SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ - Hostname: "azuredevops.com", + Hostname: "dev.azure.com", Type: models.AzureDevops, }, } Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) Equals(t, models.PullRequest{ - URL: Pull.GetHTMLURL(), - Author: Pull.User.GetLogin(), - HeadBranch: Pull.Head.GetRef(), - BaseBranch: Pull.Base.GetRef(), - HeadCommit: Pull.Head.GetSHA(), - Num: Pull.GetNumber(), + URL: ADPull.GetURL(), + Author: ADPull.CreatedBy.GetUniqueName(), + HeadBranch: path.Base(ADPull.GetSourceRefName()), + BaseBranch: path.Base(ADPull.GetTargetRefName()), + HeadCommit: ADPull.LastMergeSourceCommit.GetCommitID(), + Num: ADPull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, models.OpenedPullEvent, evType) - Equals(t, models.User{Username: "user"}, actUser) + Equals(t, models.User{Username: "user@example.com"}, actUser) } func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { @@ -1200,52 +1254,28 @@ func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { exp models.PullRequestEventType }{ { - action: "synchronize", + action: "git.pullrequest.updated", exp: models.UpdatedPullEvent, }, { - action: "unassigned", - exp: models.OtherPullEvent, - }, - { - action: "review_requested", - exp: models.OtherPullEvent, - }, - { - action: "review_request_removed", - exp: models.OtherPullEvent, - }, - { - action: "labeled", - exp: models.OtherPullEvent, - }, - { - action: "unlabeled", - exp: models.OtherPullEvent, - }, - { - action: "opened", + action: "git.pullrequest.created", exp: models.OpenedPullEvent, }, { - action: "edited", - exp: models.OtherPullEvent, - }, - { - action: "closed", + action: "git.pullrequest.merged", exp: models.ClosedPullEvent, }, { - action: "reopened", + action: "anything_else", exp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.action, func(t *testing.T) { - event := deepcopy.Copy(PullEvent).(azuredevops.PullRequestEvent) - event.Action = &c.action - _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(&event) + event := deepcopy.Copy(ADPullEvent).(azuredevops.Event) + event.EventType = c.action + _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(event) Ok(t, err) Equals(t, c.exp, actType) }) @@ -1253,59 +1283,65 @@ func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { } func TestParseAzureDevopsPull(t *testing.T) { - testPull := deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.Head.SHA = nil + testPull := deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.LastMergeSourceCommit.CommitID = nil _, _, _, err := parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "head.sha is null", err) + ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) - testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.HTMLURL = nil + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.URL = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "html_url is null", err) + ErrEquals(t, "url is null", err) - testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.Head.Ref = nil + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.SourceRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "head.ref is null", err) + ErrEquals(t, "sourceRefName (branch name) is null", err) - testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.Base.Ref = nil + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.TargetRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "base.ref is null", err) + ErrEquals(t, "targetRefName (branch name) is null", err) - testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.User.Login = nil + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.CreatedBy = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "user.login is null", err) + ErrEquals(t, "CreatedBy is null", err) - testPull = deepcopy.Copy(Pull).(azuredevops.PullRequest) - testPull.Number = nil + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.CreatedBy.UniqueName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) - ErrEquals(t, "number is null", err) + ErrEquals(t, "CreatedBy.UniqueName is null", err) - pullRes, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&Pull) + testPull = deepcopy.Copy(ADPull).(azuredevops.GitPullRequest) + testPull.PullRequestID = nil + _, _, _, err = parser.ParseAzureDevopsPull(&testPull) + ErrEquals(t, "pullRequestId is null", err) + + actPull, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&ADPull) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner", - FullName: "owner/repo", - CloneURL: "https://azuredevops-user:azuredevops-token@azuredevops.com/owner/repo.git", - SanitizedCloneURL: Repo.GetCloneURL(), + Project: "project", + FullName: "owner/project/repo", + CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", + SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ - Hostname: "azuredevops.com", + Hostname: "dev.azure.com", Type: models.AzureDevops, }, } Equals(t, models.PullRequest{ - URL: Pull.GetHTMLURL(), - Author: Pull.User.GetLogin(), - HeadBranch: Pull.Head.GetRef(), - BaseBranch: Pull.Base.GetRef(), - HeadCommit: Pull.Head.GetSHA(), - Num: Pull.GetNumber(), + URL: ADPull.GetURL(), + Author: ADPull.CreatedBy.GetUniqueName(), + HeadBranch: path.Base(ADPull.GetSourceRefName()), + BaseBranch: path.Base(ADPull.GetTargetRefName()), + HeadCommit: ADPull.LastMergeSourceCommit.GetCommitID(), + Num: ADPull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, - }, pullRes) + }, actPull) Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) } diff --git a/server/events/vcs/azuredevops_client_test.go b/server/events/vcs/azuredevops_client_test.go index d3d834f51..542fdef60 100644 --- a/server/events/vcs/azuredevops_client_test.go +++ b/server/events/vcs/azuredevops_client_test.go @@ -188,7 +188,7 @@ func TestAzureDevopsClient_UpdateStatus(t *testing.T) { } } -var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://azuredevops.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://azuredevops.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://azuredevops.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}` +var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://github.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://github.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://github.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}` var getChangesResp = `{ "changeCounts": { diff --git a/server/events/vcs/fixtures/fixtures.go b/server/events/vcs/fixtures/fixtures.go index 18b31981e..d38051770 100644 --- a/server/events/vcs/fixtures/fixtures.go +++ b/server/events/vcs/fixtures/fixtures.go @@ -13,7 +13,10 @@ package fixtures -import "github.com/google/go-github/github" +import ( + "github.com/google/go-github/github" + "github.com/mcdafydd/go-azuredevops/azuredevops" +) var PullEvent = github.PullRequestEvent{ Sender: &github.User{ @@ -49,3 +52,141 @@ var Repo = github.Repository{ Name: github.String("repo"), CloneURL: github.String("https://github.com/owner/repo.git"), } + +var ADPullEvent = azuredevops.Event{ + EventType: "git.pullrequest.created", + Resource: ADPull, +} + +var ADPull = azuredevops.GitPullRequest{ + CreatedBy: &azuredevops.IdentityRef{ + ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), + DisplayName: azuredevops.String("User"), + UniqueName: azuredevops.String("user@example.com"), + }, + LastMergeSourceCommit: &azuredevops.GitCommitRef{ + CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), + URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), + }, + PullRequestID: azuredevops.Int(1), + Repository: &ADRepo, + SourceRefName: azuredevops.String("refs/heads/owner/sourceBranch"), + Status: azuredevops.String("active"), + TargetRefName: azuredevops.String("refs/heads/targetBranch"), + URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), +} + +var ADRepo = azuredevops.GitRepository{ + DefaultBranch: azuredevops.String("refs/heads/master"), + Name: azuredevops.String("repo"), + ParentRepository: &azuredevops.GitRepositoryRef{ + Name: azuredevops.String("owner"), + }, + Project: &azuredevops.TeamProjectReference{ + ID: azuredevops.String("a21f5f20-4a12-aaf4-ab12-9a0927cbbb90"), + Name: azuredevops.String("project"), + State: azuredevops.String("unchanged"), + }, + WebURL: azuredevops.String("https://dev.azure.com/owner/project/_git/repo"), +} + +var ADPullJSON = `{ + "repository": { + "id": "3411ebc1-d5aa-464f-9615-0b527bc66719", + "name": "repo", + "url": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", + "webUrl": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", + "project": { + "id": "a7573007-bbb3-4341-b726-0c4148a07853", + "name": "project", + "description": "test project created on Halloween 2016", + "url": "https://dev.azure.com/owner/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853", + "state": "wellFormed", + "revision": 7 + }, + "remoteUrl": "https://dev.azure.com/owner/project/_git/repo" + }, + "pullRequestId": 22, + "codeReviewId": 22, + "status": "active", + "createdBy": { + "id": "d6245f20-2af8-44f4-9451-8107cb2767db", + "displayName": "Normal Paulk", + "uniqueName": "fabrikamfiber16@hotmail.com", + "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", + "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" + }, + "creationDate": "2016-11-01T16:30:31.6655471Z", + "title": "A new feature", + "description": "Adding a new feature", + "sourceRefName": "refs/heads/npaulk/my_work", + "targetRefName": "refs/heads/new_feature", + "mergeStatus": "succeeded", + "mergeId": "f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82", + "lastMergeSourceCommit": { + "commitId": "b60280bc6e62e2f880f1b63c1e24987664d3bda3", + "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" + }, + "lastMergeTargetCommit": { + "commitId": "f47bbc106853afe3c1b07a81754bce5f4b8dbf62", + "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" + }, + "lastMergeCommit": { + "commitId": "39f52d24533cc712fc845ed9fd1b6c06b3942588", + "author": { + "name": "Normal Paulk", + "email": "fabrikamfiber16@hotmail.com", + "date": "2016-11-01T16:30:32Z" + }, + "committer": { + "name": "Normal Paulk", + "email": "fabrikamfiber16@hotmail.com", + "date": "2016-11-01T16:30:32Z" + }, + "comment": "Merge pull request 22 from npaulk/my_work into new_feature", + "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588" + }, + "reviewers": [ + { + "reviewerUrl": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db", + "vote": 0, + "id": "d6245f20-2af8-44f4-9451-8107cb2767db", + "displayName": "Normal Paulk", + "uniqueName": "fabrikamfiber16@hotmail.com", + "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", + "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" + } + ], + "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22", + "_links": { + "self": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22" + }, + "repository": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719" + }, + "workItems": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems" + }, + "sourceBranch": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" + }, + "targetBranch": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" + }, + "sourceCommit": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" + }, + "targetCommit": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" + }, + "createdBy": { + "href": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db" + }, + "iterations": { + "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations" + } + }, + "supportsIterations": true, + "artifactId": "vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22" +}` diff --git a/server/events_controller.go b/server/events_controller.go index d44a9901c..232372ed2 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -481,7 +481,7 @@ func (e *EventsController) HandleAzureDevopsCommentEvent(w http.ResponseWriter, e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since no pull request is linked to work item; Request-Id = %s", azuredevopsReqID) return } - pullRefs, err := e.Parser.ParseAzureDevopsWorkItemCommentedEvent(workItem) + pullRefs, err := e.Parser.ParseAzureDevopsWorkItemCommentedEvent(workItem, nil) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Failed parsing event: %v %s", err, azuredevopsReqID) From 29b74d25f146842bc020866e036f0c141cd67cea Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Tue, 16 Jul 2019 21:57:41 -0500 Subject: [PATCH 31/64] Update Azure Devops Event Parser - Always set explicit return values - Add baseURL func parameter to facilitate testing --- server/events/event_parser.go | 68 ++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 2e3d47e4d..ec48b7c65 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -254,7 +254,7 @@ type EventParsing interface { // user is the pull request author. // pullNum is the number of the pull request that triggered the webhook. // *** Add tests and handle linking multiple pull requests to a work item. - ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem) (pullRefs []PullRef, err error) + ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem, baseURL *string) (pullRefs []PullRef, err error) // ParseAzureDevopsPull parses the response from the Azure Devops API endpoint (not // from a webhook) that returns a pull request. @@ -722,23 +722,24 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu // ParseAzureDevopsPullEvent parses Azure Devops pull request events. // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { - pullResource, ok := event.Resource.(*azuredevops.GitPullRequest) + obj, ok := event.Resource.(azuredevops.GitPullRequest) if !ok { - return + return pull, pullEventType, baseRepo, headRepo, user, err } + pullResource := &obj pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource) if err != nil { - return + return pull, pullEventType, baseRepo, headRepo, user, err } createdBy := pullResource.GetCreatedBy() if createdBy == nil { err = errors.New("CreatedBy is null") - return + return pull, pullEventType, baseRepo, headRepo, user, err } senderUsername := createdBy.GetUniqueName() if senderUsername == "" { err = errors.New("CreatedBy.UniqueName is null") - return + return pull, pullEventType, baseRepo, headRepo, user, err } switch event.EventType { case "git.pullrequest.created": @@ -751,7 +752,7 @@ func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull m pullEventType = models.OtherPullEvent } user = models.User{Username: senderUsername} - return + return pull, pullEventType, baseRepo, headRepo, user, err } // ParseAzureDevopsPull parses the response from the Azure Devops API endpoint (not @@ -761,39 +762,46 @@ func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pu commit := pull.LastMergeSourceCommit.GetCommitID() if commit == "" { err = errors.New("lastMergeSourceCommit.commitID is null") - return + return pullModel, baseRepo, headRepo, err } url := pull.GetURL() if url == "" { err = errors.New("url is null") - return + return pullModel, baseRepo, headRepo, err } headBranch := pull.GetSourceRefName() if headBranch == "" { err = errors.New("sourceRefName (branch name) is null") - return + return pullModel, baseRepo, headRepo, err } baseBranch := pull.GetTargetRefName() if baseBranch == "" { err = errors.New("targetRefName (branch name) is null") - return + return pullModel, baseRepo, headRepo, err } num := pull.GetPullRequestID() if num == 0 { err = errors.New("pullRequestId is null") - return + return pullModel, baseRepo, headRepo, err } createdBy := pull.GetCreatedBy() + if createdBy == nil { + err = errors.New("CreatedBy is null") + return pullModel, baseRepo, headRepo, err + } authorUsername := createdBy.GetUniqueName() + if authorUsername == "" { + err = errors.New("CreatedBy.UniqueName is null") + return pullModel, baseRepo, headRepo, err + } baseRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) if err != nil { - return + return pullModel, baseRepo, headRepo, err } headRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) if err != nil { - return + return pullModel, baseRepo, headRepo, err } - pullState := models.ClosedPullState if *pull.Status == azuredevops.PullActive.String() { pullState = models.OpenPullState @@ -822,19 +830,26 @@ type PullRef struct { // ParseAzureDevopsWorkItemCommentedEvent parses Azure Devops work item comment events. // Multiple pull requests can be linked to a single work item. // See EventParsing for return value docs. -func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem) (pullRefs []PullRef, err error) { +// pullNumStr is retrieved from a URI that resembles: +// vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22 +// where the pull request number is 22, following the %2f near tne end +// Must set a default error value that will be returned if there are no valid +// pull request relations in the event +func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem, baseURL *string) (pullRefs []PullRef, err error) { + defaultErrorStr := "no valid pull request relations in event payload" + err = errors.New(defaultErrorStr) for _, relation := range comment.Relations { ref := &PullRef{} if uri := relation.GetURL(); strings.Contains(uri, "vstfs:///Git/PullRequestId/") { var parsed *url.URL parsed, err = url.Parse(uri) if err != nil { - return + return pullRefs, err } pullNumStr := strings.Split(parsed.Path, "/")[5] ref.PullNum, err = strconv.Atoi(pullNumStr) if err != nil { - return + return pullRefs, err } // Retrieve the linked pull request to get baseRepo and user @@ -847,21 +862,32 @@ func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevop client := new(azuredevops.Client) client, err = azuredevops.NewClient(httpClient) if err != nil { - return + return pullRefs, err + } + if baseURL != nil { + if !strings.HasSuffix(*baseURL, "/") { + *baseURL = *baseURL + "/" + } + parsed, err = url.Parse(*baseURL) + if err != nil { + return pullRefs, err + } + client.BaseURL = *parsed } pr := new(azuredevops.GitPullRequest) opts := azuredevops.PullRequestListOptions{} pr, _, err = client.PullRequests.Get(context.Background(), e.AzureDevopsOrg, e.AzureDevopsProject, ref.PullNum, &opts) if err != nil { - return + return pullRefs, err } createdBy := pr.GetCreatedBy() ref.User = models.User{Username: createdBy.GetUniqueName()} ref.BaseRepo, err = e.ParseAzureDevopsRepo(pr.GetRepository()) if err != nil { - return + return pullRefs, err } pullRefs = append(pullRefs, *ref) + err = nil } } From 424a6b005c666748322ec69088d4186bcc5a15c3 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Tue, 16 Jul 2019 22:07:40 -0500 Subject: [PATCH 32/64] Commit Azure Devops mocks --- server/events/matchers/models_pullrequest.go | 1 - server/events/matchers/models_repo.go | 1 - .../matchers/ptr_to_logging_simplelogger.go | 1 - server/events/mock_workingdir_test.go | 5 +- .../mocks/matchers/azuredevops_event.go | 20 ++ .../ptr_to_azuredevops_gitpullrequest.go | 20 ++ .../ptr_to_azuredevops_gitrepository.go | 20 ++ .../matchers/ptr_to_azuredevops_workitem.go | 20 ++ server/events/mocks/matchers/ptr_to_string.go | 20 ++ .../mocks/matchers/slice_of_events_pullref.go | 20 ++ .../mocks/mock_azuredevops_pull_getter.go | 114 ++++++++++ server/events/mocks/mock_event_parsing.go | 214 ++++++++++++++++++ .../mock_azuredevops_request_validator.go | 117 ++++++++++ 13 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 server/events/mocks/matchers/azuredevops_event.go create mode 100644 server/events/mocks/matchers/ptr_to_azuredevops_gitpullrequest.go create mode 100644 server/events/mocks/matchers/ptr_to_azuredevops_gitrepository.go create mode 100644 server/events/mocks/matchers/ptr_to_azuredevops_workitem.go create mode 100644 server/events/mocks/matchers/ptr_to_string.go create mode 100644 server/events/mocks/matchers/slice_of_events_pullref.go create mode 100644 server/events/mocks/mock_azuredevops_pull_getter.go create mode 100644 server/mocks/mock_azuredevops_request_validator.go diff --git a/server/events/matchers/models_pullrequest.go b/server/events/matchers/models_pullrequest.go index 37e478013..dd1fb0d4e 100644 --- a/server/events/matchers/models_pullrequest.go +++ b/server/events/matchers/models_pullrequest.go @@ -3,7 +3,6 @@ package matchers import ( "reflect" - "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" ) diff --git a/server/events/matchers/models_repo.go b/server/events/matchers/models_repo.go index e985fd3a9..418f13cfc 100644 --- a/server/events/matchers/models_repo.go +++ b/server/events/matchers/models_repo.go @@ -3,7 +3,6 @@ package matchers import ( "reflect" - "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" ) diff --git a/server/events/matchers/ptr_to_logging_simplelogger.go b/server/events/matchers/ptr_to_logging_simplelogger.go index 095fa65a7..04c72791b 100644 --- a/server/events/matchers/ptr_to_logging_simplelogger.go +++ b/server/events/matchers/ptr_to_logging_simplelogger.go @@ -3,7 +3,6 @@ package matchers import ( "reflect" - "github.com/petergtz/pegomock" logging "github.com/runatlantis/atlantis/server/logging" ) diff --git a/server/events/mock_workingdir_test.go b/server/events/mock_workingdir_test.go index 58d91d8d8..c0b745f72 100644 --- a/server/events/mock_workingdir_test.go +++ b/server/events/mock_workingdir_test.go @@ -4,12 +4,11 @@ package events import ( - "reflect" - "time" - pegomock "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" + "reflect" + "time" ) type MockWorkingDir struct { diff --git a/server/events/mocks/matchers/azuredevops_event.go b/server/events/mocks/matchers/azuredevops_event.go new file mode 100644 index 000000000..e8bb43d1c --- /dev/null +++ b/server/events/mocks/matchers/azuredevops_event.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" +) + +func AnyAzuredevopsEvent() azuredevops.Event { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(azuredevops.Event))(nil)).Elem())) + var nullValue azuredevops.Event + return nullValue +} + +func EqAzuredevopsEvent(value azuredevops.Event) azuredevops.Event { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue azuredevops.Event + return nullValue +} diff --git a/server/events/mocks/matchers/ptr_to_azuredevops_gitpullrequest.go b/server/events/mocks/matchers/ptr_to_azuredevops_gitpullrequest.go new file mode 100644 index 000000000..8c5088273 --- /dev/null +++ b/server/events/mocks/matchers/ptr_to_azuredevops_gitpullrequest.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" +) + +func AnyPtrToAzuredevopsGitPullRequest() *azuredevops.GitPullRequest { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*azuredevops.GitPullRequest))(nil)).Elem())) + var nullValue *azuredevops.GitPullRequest + return nullValue +} + +func EqPtrToAzuredevopsGitPullRequest(value *azuredevops.GitPullRequest) *azuredevops.GitPullRequest { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *azuredevops.GitPullRequest + return nullValue +} diff --git a/server/events/mocks/matchers/ptr_to_azuredevops_gitrepository.go b/server/events/mocks/matchers/ptr_to_azuredevops_gitrepository.go new file mode 100644 index 000000000..f9499615e --- /dev/null +++ b/server/events/mocks/matchers/ptr_to_azuredevops_gitrepository.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" +) + +func AnyPtrToAzuredevopsGitRepository() *azuredevops.GitRepository { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*azuredevops.GitRepository))(nil)).Elem())) + var nullValue *azuredevops.GitRepository + return nullValue +} + +func EqPtrToAzuredevopsGitRepository(value *azuredevops.GitRepository) *azuredevops.GitRepository { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *azuredevops.GitRepository + return nullValue +} diff --git a/server/events/mocks/matchers/ptr_to_azuredevops_workitem.go b/server/events/mocks/matchers/ptr_to_azuredevops_workitem.go new file mode 100644 index 000000000..1e4cc8529 --- /dev/null +++ b/server/events/mocks/matchers/ptr_to_azuredevops_workitem.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" +) + +func AnyPtrToAzuredevopsWorkItem() *azuredevops.WorkItem { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*azuredevops.WorkItem))(nil)).Elem())) + var nullValue *azuredevops.WorkItem + return nullValue +} + +func EqPtrToAzuredevopsWorkItem(value *azuredevops.WorkItem) *azuredevops.WorkItem { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *azuredevops.WorkItem + return nullValue +} diff --git a/server/events/mocks/matchers/ptr_to_string.go b/server/events/mocks/matchers/ptr_to_string.go new file mode 100644 index 000000000..d803cdd36 --- /dev/null +++ b/server/events/mocks/matchers/ptr_to_string.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + +) + +func AnyPtrToString() *string { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*string))(nil)).Elem())) + var nullValue *string + return nullValue +} + +func EqPtrToString(value *string) *string { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *string + return nullValue +} diff --git a/server/events/mocks/matchers/slice_of_events_pullref.go b/server/events/mocks/matchers/slice_of_events_pullref.go new file mode 100644 index 000000000..758e7569e --- /dev/null +++ b/server/events/mocks/matchers/slice_of_events_pullref.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" +) + +func AnySliceOfEventsPullRef() []events.PullRef { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*([]events.PullRef))(nil)).Elem())) + var nullValue []events.PullRef + return nullValue +} + +func EqSliceOfEventsPullRef(value []events.PullRef) []events.PullRef { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue []events.PullRef + return nullValue +} diff --git a/server/events/mocks/mock_azuredevops_pull_getter.go b/server/events/mocks/mock_azuredevops_pull_getter.go new file mode 100644 index 000000000..683403c7a --- /dev/null +++ b/server/events/mocks/mock_azuredevops_pull_getter.go @@ -0,0 +1,114 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: AzureDevopsPullGetter) + +package mocks + +import ( + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockAzureDevopsPullGetter struct { + fail func(message string, callerSkip ...int) +} + +func NewMockAzureDevopsPullGetter(options ...pegomock.Option) *MockAzureDevopsPullGetter { + mock := &MockAzureDevopsPullGetter{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockAzureDevopsPullGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockAzureDevopsPullGetter) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockAzureDevopsPullGetter) GetPullRequest(repo models.Repo, pullNum int) (*azuredevops.GitPullRequest, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockAzureDevopsPullGetter().") + } + params := []pegomock.Param{repo, pullNum} + result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullRequest", params, []reflect.Type{reflect.TypeOf((**azuredevops.GitPullRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 *azuredevops.GitPullRequest + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(*azuredevops.GitPullRequest) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockAzureDevopsPullGetter) VerifyWasCalledOnce() *VerifierMockAzureDevopsPullGetter { + return &VerifierMockAzureDevopsPullGetter{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockAzureDevopsPullGetter) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockAzureDevopsPullGetter { + return &VerifierMockAzureDevopsPullGetter{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockAzureDevopsPullGetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAzureDevopsPullGetter { + return &VerifierMockAzureDevopsPullGetter{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockAzureDevopsPullGetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockAzureDevopsPullGetter { + return &VerifierMockAzureDevopsPullGetter{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockAzureDevopsPullGetter struct { + mock *MockAzureDevopsPullGetter + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockAzureDevopsPullGetter) GetPullRequest(repo models.Repo, pullNum int) *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification { + params := []pegomock.Param{repo, pullNum} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullRequest", params, verifier.timeout) + return &MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification struct { + mock *MockAzureDevopsPullGetter + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification) GetCapturedArguments() (models.Repo, int) { + repo, pullNum := c.GetAllCapturedArguments() + return repo[len(repo)-1], pullNum[len(pullNum)-1] +} + +func (c *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []int) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.Repo, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(models.Repo) + } + _param1 = make([]int, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.(int) + } + } + return +} diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go index 2f46ec8e6..a8bb34112 100644 --- a/server/events/mocks/mock_event_parsing.go +++ b/server/events/mocks/mock_event_parsing.go @@ -6,7 +6,9 @@ package mocks import ( github "github.com/google/go-github/github" go_gitlab "github.com/lkysow/go-gitlab" + azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" pegomock "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" @@ -374,6 +376,106 @@ func (mock *MockEventParsing) GetBitbucketServerPullEventType(eventTypeHeader st return ret0 } +func (mock *MockEventParsing) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem, baseURL *string) ([]events.PullRef, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{comment, baseURL} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsWorkItemCommentedEvent", params, []reflect.Type{reflect.TypeOf((*[]events.PullRef)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 []events.PullRef + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]events.PullRef) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockEventParsing) ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) (models.PullRequest, models.Repo, models.Repo, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{adPull} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsPull", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.PullRequest + var ret1 models.Repo + var ret2 models.Repo + var ret3 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequest) + } + if result[1] != nil { + ret1 = result[1].(models.Repo) + } + if result[2] != nil { + ret2 = result[2].(models.Repo) + } + if result[3] != nil { + ret3 = result[3].(error) + } + } + return ret0, ret1, ret2, ret3 +} + +func (mock *MockEventParsing) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{pullEvent} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsPullEvent", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.PullRequest + var ret1 models.PullRequestEventType + var ret2 models.Repo + var ret3 models.Repo + var ret4 models.User + var ret5 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequest) + } + if result[1] != nil { + ret1 = result[1].(models.PullRequestEventType) + } + if result[2] != nil { + ret2 = result[2].(models.Repo) + } + if result[3] != nil { + ret3 = result[3].(models.Repo) + } + if result[4] != nil { + ret4 = result[4].(models.User) + } + if result[5] != nil { + ret5 = result[5].(error) + } + } + return ret0, ret1, ret2, ret3, ret4, ret5 +} + +func (mock *MockEventParsing) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{adRepo} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsRepo", params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.Repo + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.Repo) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + func (mock *MockEventParsing) VerifyWasCalledOnce() *VerifierMockEventParsing { return &VerifierMockEventParsing{ mock: mock, @@ -765,3 +867,115 @@ func (c *MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification) G } return } + +func (verifier *VerifierMockEventParsing) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevops.WorkItem, baseURL *string) *MockEventParsing_ParseAzureDevopsWorkItemCommentedEvent_OngoingVerification { + params := []pegomock.Param{comment, baseURL} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsWorkItemCommentedEvent", params, verifier.timeout) + return &MockEventParsing_ParseAzureDevopsWorkItemCommentedEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseAzureDevopsWorkItemCommentedEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseAzureDevopsWorkItemCommentedEvent_OngoingVerification) GetCapturedArguments() (*azuredevops.WorkItem, *string) { + comment, baseURL := c.GetAllCapturedArguments() + return comment[len(comment)-1], baseURL[len(baseURL)-1] +} + +func (c *MockEventParsing_ParseAzureDevopsWorkItemCommentedEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []*azuredevops.WorkItem, _param1 []*string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*azuredevops.WorkItem, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*azuredevops.WorkItem) + } + _param1 = make([]*string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.(*string) + } + } + return +} + +func (verifier *VerifierMockEventParsing) ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) *MockEventParsing_ParseAzureDevopsPull_OngoingVerification { + params := []pegomock.Param{adPull} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsPull", params, verifier.timeout) + return &MockEventParsing_ParseAzureDevopsPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseAzureDevopsPull_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseAzureDevopsPull_OngoingVerification) GetCapturedArguments() *azuredevops.GitPullRequest { + adPull := c.GetAllCapturedArguments() + return adPull[len(adPull)-1] +} + +func (c *MockEventParsing_ParseAzureDevopsPull_OngoingVerification) GetAllCapturedArguments() (_param0 []*azuredevops.GitPullRequest) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*azuredevops.GitPullRequest, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*azuredevops.GitPullRequest) + } + } + return +} + +func (verifier *VerifierMockEventParsing) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification { + params := []pegomock.Param{pullEvent} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsPullEvent", params, verifier.timeout) + return &MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification) GetCapturedArguments() azuredevops.Event { + pullEvent := c.GetAllCapturedArguments() + return pullEvent[len(pullEvent)-1] +} + +func (c *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []azuredevops.Event) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]azuredevops.Event, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(azuredevops.Event) + } + } + return +} + +func (verifier *VerifierMockEventParsing) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification { + params := []pegomock.Param{adRepo} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsRepo", params, verifier.timeout) + return &MockEventParsing_ParseAzureDevopsRepo_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseAzureDevopsRepo_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification) GetCapturedArguments() *azuredevops.GitRepository { + adRepo := c.GetAllCapturedArguments() + return adRepo[len(adRepo)-1] +} + +func (c *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification) GetAllCapturedArguments() (_param0 []*azuredevops.GitRepository) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*azuredevops.GitRepository, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*azuredevops.GitRepository) + } + } + return +} diff --git a/server/mocks/mock_azuredevops_request_validator.go b/server/mocks/mock_azuredevops_request_validator.go new file mode 100644 index 000000000..75e8db670 --- /dev/null +++ b/server/mocks/mock_azuredevops_request_validator.go @@ -0,0 +1,117 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server (interfaces: AzureDevopsRequestValidator) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + http "net/http" + "reflect" + "time" +) + +type MockAzureDevopsRequestValidator struct { + fail func(message string, callerSkip ...int) +} + +func NewMockAzureDevopsRequestValidator(options ...pegomock.Option) *MockAzureDevopsRequestValidator { + mock := &MockAzureDevopsRequestValidator{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockAzureDevopsRequestValidator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockAzureDevopsRequestValidator) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockAzureDevopsRequestValidator().") + } + params := []pegomock.Param{r, user, pass} + result := pegomock.GetGenericMockFrom(mock).Invoke("Validate", params, []reflect.Type{reflect.TypeOf((*[]byte)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 []byte + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]byte) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledOnce() *VerifierMockAzureDevopsRequestValidator { + return &VerifierMockAzureDevopsRequestValidator{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockAzureDevopsRequestValidator) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockAzureDevopsRequestValidator { + return &VerifierMockAzureDevopsRequestValidator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAzureDevopsRequestValidator { + return &VerifierMockAzureDevopsRequestValidator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockAzureDevopsRequestValidator { + return &VerifierMockAzureDevopsRequestValidator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockAzureDevopsRequestValidator struct { + mock *MockAzureDevopsRequestValidator + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) *MockAzureDevopsRequestValidator_Validate_OngoingVerification { + params := []pegomock.Param{r, user, pass} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Validate", params, verifier.timeout) + return &MockAzureDevopsRequestValidator_Validate_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockAzureDevopsRequestValidator_Validate_OngoingVerification struct { + mock *MockAzureDevopsRequestValidator + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockAzureDevopsRequestValidator_Validate_OngoingVerification) GetCapturedArguments() (*http.Request, []byte, []byte) { + r, user, pass := c.GetAllCapturedArguments() + return r[len(r)-1], user[len(user)-1], pass[len(pass)-1] +} + +func (c *MockAzureDevopsRequestValidator_Validate_OngoingVerification) GetAllCapturedArguments() (_param0 []*http.Request, _param1 [][]byte, _param2 [][]byte) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*http.Request, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*http.Request) + } + _param1 = make([][]byte, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.([]byte) + } + _param2 = make([][]byte, len(params[2])) + for u, param := range params[2] { + _param2[u] = param.([]byte) + } + } + return +} From 9f51aadf9574a4484884a5a324532b331990a723 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 20 Jul 2019 11:47:48 -0500 Subject: [PATCH 33/64] First draft of Azure Devops documentation updates --- runatlantis.io/docs/access-credentials.md | 5 +- runatlantis.io/docs/apply-requirements.md | 11 ++++ runatlantis.io/docs/configuring-webhooks.md | 35 ++++++++++- runatlantis.io/docs/deployment.md | 66 +++++++++++++++++++-- runatlantis.io/docs/installation-guide.md | 2 +- runatlantis.io/docs/requirements.md | 1 + runatlantis.io/docs/security.md | 7 +++ runatlantis.io/docs/server-configuration.md | 51 ++++++++++++++++ 8 files changed, 168 insertions(+), 10 deletions(-) diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md index 9dc08d0fb..9dd279195 100644 --- a/runatlantis.io/docs/access-credentials.md +++ b/runatlantis.io/docs/access-credentials.md @@ -20,6 +20,7 @@ generate an access token. Read on for the instructions for your specific Git hos * [GitLab](#gitlab) * [Bitbucket Cloud (bitbucket.org)](#bitbucket-cloud-bitbucket-org) * [Bitbucket Server (aka Stash)](#bitbucket-server-aka-stash) +* [Azure Devops](#azure-devops) ### GitHub - Create a Personal Access Token by following: [https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token) @@ -49,8 +50,8 @@ Your Atlantis user must also have "Write permissions" (for repos in an organizat - Give the token **Read** Project permissions and **Write** Pull request permissions - Click **Create** and record the access token -### Create an Azure Devops (dev.azure.com) Personal Access Token -- create a Personal access token by following [https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops) +### Azure Devops +- Create a Personal access token by following [https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops) - Label the password "atlantis" - Under scope "Code", select **Read & Write** and **Status** so that Atlantis can read your pull requests and write comments to them - record the access token diff --git a/runatlantis.io/docs/apply-requirements.md b/runatlantis.io/docs/apply-requirements.md index ce6df1d11..182f49ae3 100644 --- a/runatlantis.io/docs/apply-requirements.md +++ b/runatlantis.io/docs/apply-requirements.md @@ -49,6 +49,7 @@ Each VCS provider has different rules around who can approve: * **Bitbucket Cloud (bitbucket.org)** – A user can approve their own pull request but Atlantis does not count that as an approval and requires an approval from at least one user that is not the author of the pull request +* **Azure Devops** – **All builtin groups include the "Contribute to pull requests"** permission and can approve a pull request :::tip Tip If you want to require **certain people** to approve the pull request, look at the @@ -120,6 +121,15 @@ merge. We don't check anything else because Bitbucket's API doesn't support it. If you need a specific check, please [open an issue](https://github.com/runatlantis/atlantis/issues/new). +#### Azure Devops +In Azure Devops, all pull requests are mergeable unless there is a conflict. You can set a pull request to "Complete" right away, or set "Auto-Complete", which will merge after all branch policies are met. See [Review code with pull requests](https://docs.microsoft.com/en-us/azure/devops/repos/git/pull-requests?view=azure-devops). + +[Branch policies](https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops) can: +* Require a minimum number of reviewers +* Allow users to approve their own changes +* Allow completion even if some reviewers vote "Waiting" or "Reject" +* Reset code reviewer votes when there are new changes + ## Setting Apply Requirements As mentioned above, you can set apply requirements via flags, in `repos.yaml`, or in `atlantis.yaml` if `repos.yaml` allows the override. @@ -186,3 +196,4 @@ request can run the actual `atlantis apply` command. * For more information on GitHub pull request reviews and approvals see: [https://help.github.com/articles/about-pull-request-reviews/](https://help.github.com/articles/about-pull-request-reviews/) * For more information on GitLab merge request reviews and approvals (only supported on GitLab Enterprise) see: [https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html). * For more information on Bitbucket pull request reviews and approvals see: [https://confluence.atlassian.com/bitbucket/pull-requests-and-code-review-223220593.html](https://confluence.atlassian.com/bitbucket/pull-requests-and-code-review-223220593.html) +* For more information on Azure Devops pull request reviews and approvals see: [https://docs.microsoft.com/en-us/azure/devops/repos/git/pull-requests-overview?view=azure-devops](https://docs.microsoft.com/en-us/azure/devops/repos/git/pull-requests-overview?view=azure-devops) diff --git a/runatlantis.io/docs/configuring-webhooks.md b/runatlantis.io/docs/configuring-webhooks.md index 51aba9dd1..ed981ccf1 100644 --- a/runatlantis.io/docs/configuring-webhooks.md +++ b/runatlantis.io/docs/configuring-webhooks.md @@ -85,7 +85,40 @@ If you're using GitLab, navigate to your project's home page in GitLab - Click **Save**Bitbucket Webhook - See [Next Steps](#next-steps) +## Azure Devops +Webhooks are installed at the [team project](https://docs.microsoft.com/en-us/azure/devops/organizations/projects/about-projects?view=azure-devops) level, but may be restricted to only fire based on events pertaining to [specific repos](https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops) within the team project. + +- Navigate anywhere within a team project, ie: `https://dev.azure.com/orgName/projectName/_git/repoName` +- Select **Project settings** in the lower-left corner +- Select **Service hooks** +- Click the green plus icon to add a new webhook +- Scroll to the bottom of the list and select **Web Hooks** +- Click **Next** +- Under "Trigger on this type of event", select **Pull request created** +- Click **Next** +- Set **URL** to `http://$URL/events` where `$URL` is where Atlantis is hosted. Note that SSL, or `https://$URL/events`, is required if you set a Basic username and password for the webhook). **Be sure to add `/events`** +- It is strongly recommended to set a Basic Username and Password for all webhooks +- Leave all three drop-down menus for `...to send` set to **All** +- Resource version should be set to **1.0** +- **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. +- Click **Finish** + +Repeat the process above until you have webhook subscriptions for the following four event types that will trigger on all repositories Atlantis will manage: + +- Pull request created +- Pull request merge attempted +- Pull request updated +- Work item commented on + +In Azure Devops, the text of a comment made on a pull request is not included in Pull Request webhook event payloads. Instead, to use Azure Devops with Atlantis, you must use an Azure Boards Work Item, such as a User Story, Bug, or Task, and [link it to a pull request](https://docs.microsoft.com/en-us/azure/devops/boards/backlogs/connect-work-items-to-git-dev-ops?view=azure-devops). Atlantis commands contained in Work Item comments will be sent in the webhook payloads to your Atlantis `/events` endpoint. + +- See [Next Steps](#next-steps) + +## GitLab +If you're using GitLab, navigate to your project's home page in GitLab ## Next Steps * To verify that Atlantis is receiving your webhooks, create a test pull request - to your repo. You should see the request show up in the Atlantis logs at an `INFO` level. + to your repo. +* If you're using Azure Devops, create a new User Story, and from there create a branch, and then a pull request, both linked to the work item +* You should see the request show up in the Atlantis logs at an `INFO` level. * You'll now need to configure Atlantis to add your [Provider Credentials](provider-credentials.html) diff --git a/runatlantis.io/docs/deployment.md b/runatlantis.io/docs/deployment.md index 9eb85ae4c..68bdc9b4a 100644 --- a/runatlantis.io/docs/deployment.md +++ b/runatlantis.io/docs/deployment.md @@ -17,7 +17,7 @@ Atlantis [Docker image](https://hub.docker.com/r/runatlantis/atlantis/). ### Routing Atlantis and your Git host need to be able to route and communicate with one another. Your Git host needs to be able to send webhooks to Atlantis and Atlantis needs to be able to make API calls to your Git host. If you're using -a public Git host like GitHub.com, GitLab.com or Bitbucket.org then you'll need to +a public Git host like GitHub.com, GitLab.com, Bitbucket.org, or Dev.azure.com then you'll need to expose Atlantis to the internet. If you're using a private Git host like GitHub Enterprise, GitLab Enterprise or @@ -100,13 +100,16 @@ up upgrading Atlantis by accident! for your Terraform repos. See [Repo Whitelist](server-configuration.html#repo-whitelist) for more details. 3. If you're using GitHub: 1. Replace `` with the username of your Atlantis GitHub user without the `@`. - 2. Delete all the `ATLANTIS_GITLAB_*` and `ATLANTIS_BITBUCKET_*` environment variables. + 2. Delete all the `ATLANTIS_GITLAB_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_ADS_*` environment variables. 4. If you're using GitLab: 1. Replace `` with the username of your Atlantis GitLab user without the `@`. - 2. Delete all the `ATLANTIS_GH_*` and `ATLANTIS_BITBUCKET_*` environment variables. + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 5. If you're using Bitbucket: 1. Replace `` with the username of your Atlantis Bitbucket user without the `@`. - 2. Delete all the `ATLANTIS_GH_*` and `ATLANTIS_GITLAB_*` environment variables. + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. +6. If you're using Azure Devops: + 1. Replace `` with the username of your Atlantis Azure Devops user without the `@`. + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_BITBUCKET_*` environment variables. #### StatefulSet Manifest
    @@ -181,6 +184,26 @@ spec: key: token ### End Bitbucket Config ### + ### Azure Devops Config ### + - name: ATLANTIS_AZUREDEVOPS_USER + value: # 6i. If you're using Azure Devops replace with the username of your Atlantis Azure Devops user without the `@`. + - name: ATLANTIS_AZUREDEVOPS_TOKEN + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: token + - name: ATLANTIS_AZUREDEVOPS_BASIC_USER + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: basic-username + - name: ATLANTIS_AZUREDEVOPS_BASIC_PASS + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: basic-password + ### End Azure Devops Config ### + - name: ATLANTIS_DATA_DIR value: /atlantis - name: ATLANTIS_PORT @@ -309,6 +332,26 @@ spec: key: token ### End Bitbucket Config ### + ### Azure Devops Config ### + - name: ATLANTIS_AZUREDEVOPS_USER + value: # 6i. If you're using Azure Devops replace with the username of your Atlantis Azure Devops user without the `@`. + - name: ATLANTIS_AZUREDEVOPS_TOKEN + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: token + - name: ATLANTIS_AZUREDEVOPS_BASIC_USER + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: basic-username + - name: ATLANTIS_AZUREDEVOPS_BASIC_PASS + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: basic-password + ### End Azure Devops Config ### + - name: ATLANTIS_PORT value: "4141" # Kubernetes sets an ATLANTIS_PORT variable so we need to override. ports: @@ -484,14 +527,25 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` +##### Azure Devops +```bash +atlantis server \ +--atlantis-url="$URL" \ +--azuredevops-user="$USERNAME" \ +--azuredevops-token="$TOKEN" \ +--azuredevops-basic-username="$ATLANTIS_AZUREDEVOPS_BASIC_USER" \ +--azuredevops-basic-password="$ATLANTIS_AZUREDEVOPS_BASIC_PASS" \ +--repo-whitelist="$REPO_WHITELIST" +``` + Where - `$URL` is the URL that Atlantis can be reached at -- `$USERNAME` is the GitHub/GitLab/Bitbucket username you generated the token for +- `$USERNAME` is the GitHub/GitLab/Bitbucket/AzureDevops username you generated the token for - `$TOKEN` is the access token you created. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file (see [Configuration](/docs/server-configuration.html#environment-variables)) or as an environment variable: `ATLANTIS_GH_TOKEN` or `ATLANTIS_GITLAB_TOKEN` - or `ATLANTIS_BITBUCKET_TOKEN` + or `ATLANTIS_BITBUCKET_TOKEN` or `ATLANTIS_AZUREDEVOPS_TOKEN` - `$SECRET` is the random key you used for the webhook secret. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file diff --git a/runatlantis.io/docs/installation-guide.md b/runatlantis.io/docs/installation-guide.md index b6a93dc33..b37aff589 100644 --- a/runatlantis.io/docs/installation-guide.md +++ b/runatlantis.io/docs/installation-guide.md @@ -3,7 +3,7 @@ This guide is for installing a **production-ready** instance of Atlantis onto yo infrastructure: 1. First, ensure your Terraform setup meets the Atlantis **requirements** * See [Requirements](requirements.html) -1. Create **access credentials** for your Git host (GitHub, GitLab, Bitbucket) +1. Create **access credentials** for your Git host (GitHub, GitLab, Bitbucket, Azure Devops) * See [Generating Git Host Access Credentials](access-credentials.html) 1. Create a **webhook secret** so Atlantis can validate webhooks * See [Creating a Webhook Secret](webhook-secrets.html) diff --git a/runatlantis.io/docs/requirements.md b/runatlantis.io/docs/requirements.md index 08197666a..93cae0022 100644 --- a/runatlantis.io/docs/requirements.md +++ b/runatlantis.io/docs/requirements.md @@ -11,6 +11,7 @@ Atlantis integrates with the following Git hosts: * GitLab (public, private or enterprise) * Bitbucket Cloud aka bitbucket.org (public or private) * Bitbucket Server aka Stash +* Azure Devops ## Terraform State Atlantis supports all backend types **except for local state**. We don't support local state diff --git a/runatlantis.io/docs/security.md b/runatlantis.io/docs/security.md index 5acc6bd83..6bfc123b5 100644 --- a/runatlantis.io/docs/security.md +++ b/runatlantis.io/docs/security.md @@ -52,6 +52,13 @@ Atlantis should be run with Webhook secrets set via the `$ATLANTIS_GH_WEBHOOK_SE Even with the `--repo-whitelist` flag set, without a webhook secret, attackers could make requests to Atlantis posing as a repository that is whitelisted. Webhook secrets ensure that the webhook requests are actually coming from your VCS provider (GitHub or GitLab). +:::tip Tip +If you are using Azure Devops, instead of webhook secrets add a [basic username and password](#azure devops basic authentication) +::: + +### Azure Devops Basic Authentication +Azure Devops supports sending a basic authentication header in all webhook events. This requires using an HTTPS URL for your webhook location. + ### SSL/HTTPS If you're using webhook secrets but your traffic is over HTTP then the webhook secrets could be stolen. Enable SSL/HTTPS using the `--ssl-cert-file` and `--ssl-key-file` diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 1a5131d2f..7f14e08f6 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -98,6 +98,57 @@ Values are chosen in this order: Automatically merge pull requests after all plans have been successfully applied. Defaults to `false`. See [Automerging](automerging.html) for more details. +* ### `--azuredevops-basic-auth` + ```bash + atlantis server --azuredevops-basic-auth + ``` + Enable/disable basic auth for validating Azure Devops webhooks (see + https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops). + SECURITY WARNING: If not specified, Atlantis won't be able to validate that the + incoming webhook call came from your Azure Devops org. This means that an + attacker could spoof calls to Atlantis and cause it to perform malicious + actions. Should be specified via the ATLANTIS_AZUREDEVOPS_BASIC_AUTH environment + variable. + +* ### `--azuredevops-basic-password` + ```bash + atlantis server --azuredevops-basic-password="password123" + ``` + Azure Devops basic authentication password for inbound webhooks. Can also be specified via the ATLANTIS_AZUREDEVOPS_BASIC_PASSWORD + environment variable. + +* ### `--azuredevops-basic-user` + ```bash + atlantis server --azuredevops-basic-user="username@example.com" + ``` + Azure Devops basic authentication username for inbound webhooks. Can also be specified via the ATLANTIS_AZUREDEVOPS_BASIC_USER + environment variable. + +* ### `--azuredevops-org` + ```bash + atlantis server --azuredevops-org="myorgname" + ``` + Organization name of your Azure Devops instance. + +* ### `--azuredevops-project` + ```bash + atlantis server --azuredevops-project="myproject" + ``` + Project name in your Azure Devops insance. + +* ### `--azuredevops-token` + ```bash + atlantis server --azuredevops-token="username@example.com" + ``` + Azure Devops token of API user. Can also be specified via the ATLANTIS_AZUREDEVOPS_TOKEN + environment variable. + +* ### `--azuredevops-user` + ```bash + atlantis server --azuredevops-user="username@example.com" + ``` + Azure Devops username of API user. + * ### `--bitbucket-base-url` ```bash atlantis server --bitbucket-base-url="http://bitbucket.corp:7990/basepath" From c9c8f01c675dd4fb64c969c2342a368f9a4958d6 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 10:29:20 -0500 Subject: [PATCH 34/64] Fix panic from nil error --- server/events/event_parser.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index ec48b7c65..da27f8f96 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -724,6 +724,7 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { obj, ok := event.Resource.(azuredevops.GitPullRequest) if !ok { + err = errors.New("Unable to type assert event.Resource") return pull, pullEventType, baseRepo, headRepo, user, err } pullResource := &obj From 52a794b73ba12545bb190114fde804ff99c21c98 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 13:30:17 -0500 Subject: [PATCH 35/64] Update Azure Devops webhook documentation --- runatlantis.io/docs/configuring-webhooks.md | 1 - 1 file changed, 1 deletion(-) diff --git a/runatlantis.io/docs/configuring-webhooks.md b/runatlantis.io/docs/configuring-webhooks.md index ed981ccf1..0f6fd17ba 100644 --- a/runatlantis.io/docs/configuring-webhooks.md +++ b/runatlantis.io/docs/configuring-webhooks.md @@ -106,7 +106,6 @@ Webhooks are installed at the [team project](https://docs.microsoft.com/en-us/az Repeat the process above until you have webhook subscriptions for the following four event types that will trigger on all repositories Atlantis will manage: - Pull request created -- Pull request merge attempted - Pull request updated - Work item commented on From b6dc489a6db0caa637370a70c93325d6764cc493 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 14:18:56 -0500 Subject: [PATCH 36/64] Update Azure Devops documentation --- README_azuredevops.md | 21 +++++---------------- runatlantis.io/docs/access-credentials.md | 7 +++++-- runatlantis.io/docs/configuring-webhooks.md | 2 +- runatlantis.io/docs/webhook-secrets.md | 4 ++++ 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/README_azuredevops.md b/README_azuredevops.md index f7c477401..6ccf5e0e3 100644 --- a/README_azuredevops.md +++ b/README_azuredevops.md @@ -5,21 +5,10 @@ Terraform Pull Request Automation

    -## Azure Devops Support (alpha) +## Azure Devops Support (early beta) -As of tag `v0.7.1` of [go-azuredevops](https://github.com/mcdafydd/go-azuredevops), basic suport for comments, statuses, running `atlantis help|plan|apply` appears to be functional. There is still much to do in terms of writing tests and documentation and operational testing. - -Known issues: - -* Merging pull requests doesn't seem to be removing the locks - -## Problems building in a fork - -Since the parent project is using dep and the vendor folder, I'm currently developing this inside the following folder on my workstation: - -`$GOPATH/src/github.com/runatlantis/atlantis/` - -This deals with the problem of breaking local package imports by trying to build inside `$GOPATH/src/github.com/mcdafydd/atlantis`. With Go modules, apparently `go mod replace` can help to address this issue more cleanly, but for now this works. I don't want to do a global search replace and then change it back before submitting any pull requests. - -I'm happy to hear any suggestions about better ways to handle this! +Azure Devops support requires the following dependencies: +* https://github.com/microcosm-cc/bluemonday +* https://github.com/russross/blackfriday +* https://github.com/mcdafydd/go-azuredevops diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md index 9dd279195..abe02174c 100644 --- a/runatlantis.io/docs/access-credentials.md +++ b/runatlantis.io/docs/access-credentials.md @@ -53,8 +53,11 @@ Your Atlantis user must also have "Write permissions" (for repos in an organizat ### Azure Devops - Create a Personal access token by following [https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops) - Label the password "atlantis" -- Under scope "Code", select **Read & Write** and **Status** so that Atlantis can read your pull requests and write comments to them -- record the access token +- The minimum scopes required for this token are: + - Code (Read) + - Code (Status) + - Work Items (Read & write) +- Record the access token ## Next Steps Once you've got your user and access token, you're ready to create a webhook secret. See [Creating a Webhook Secret](webhook-secrets.html). diff --git a/runatlantis.io/docs/configuring-webhooks.md b/runatlantis.io/docs/configuring-webhooks.md index 0f6fd17ba..0096840a7 100644 --- a/runatlantis.io/docs/configuring-webhooks.md +++ b/runatlantis.io/docs/configuring-webhooks.md @@ -100,7 +100,7 @@ Webhooks are installed at the [team project](https://docs.microsoft.com/en-us/az - It is strongly recommended to set a Basic Username and Password for all webhooks - Leave all three drop-down menus for `...to send` set to **All** - Resource version should be set to **1.0** -- **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. +- **NOTE** If you're adding a webhook to multiple team projects or repositories (using filters), each repository will need to use the **same** basic username and password. - Click **Finish** Repeat the process above until you have webhook subscriptions for the following four event types that will trigger on all repositories Atlantis will manage: diff --git a/runatlantis.io/docs/webhook-secrets.md b/runatlantis.io/docs/webhook-secrets.md index 83696b8a3..1e17abc82 100644 --- a/runatlantis.io/docs/webhook-secrets.md +++ b/runatlantis.io/docs/webhook-secrets.md @@ -12,6 +12,10 @@ Webhook secrets are actually optional. However they're highly recommended for security. ::: +::: tip NOTE +Azure Devops uses Basic authentication for webhooks rather than webhook secrets. +::: + ::: warning Bitbucket.org **does not** support webhook secrets. To mitigate, use repo whitelists and IP whitelists. See [Security](security.html#bitbucket-cloud-bitbucket-org) for more information. From e8cfe5af289739f599474ab8720a874a3337394d Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 14:22:14 -0500 Subject: [PATCH 37/64] Fix Azure Devops Pull Events - Event type assertion should be pointer - Remove merge attempted webhook event type - Move closed pull request to updated event type --- server/events/event_parser.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/events/event_parser.go b/server/events/event_parser.go index da27f8f96..b5441ed35 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -722,12 +722,12 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu // ParseAzureDevopsPullEvent parses Azure Devops pull request events. // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { - obj, ok := event.Resource.(azuredevops.GitPullRequest) + pullResource, ok := event.Resource.(*azuredevops.GitPullRequest) if !ok { - err = errors.New("Unable to type assert event.Resource") + errMsg := fmt.Sprintf("Failed to type assert event.Resource.") + err = errors.New(errMsg) return pull, pullEventType, baseRepo, headRepo, user, err } - pullResource := &obj pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource) if err != nil { return pull, pullEventType, baseRepo, headRepo, user, err @@ -747,8 +747,9 @@ func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull m pullEventType = models.OpenedPullEvent case "git.pullrequest.updated": pullEventType = models.UpdatedPullEvent - case "git.pullrequest.merged": - pullEventType = models.ClosedPullEvent + if pull.State == models.ClosedPullState { + pullEventType = models.ClosedPullEvent + } default: pullEventType = models.OtherPullEvent } From 75527a352d778ef0c7541720510d88dd9644ed9c Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 14:34:34 -0500 Subject: [PATCH 38/64] Remove Azure Devops fork info in READMEs --- README.md | 4 ---- README_azuredevops.md | 14 -------------- 2 files changed, 18 deletions(-) delete mode 100644 README_azuredevops.md diff --git a/README.md b/README.md index e81853394..961b3d451 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,6 @@ Terraform Pull Request Automation

    -## Azure Devops Support (alpha) - -This fork contains Azure Devops support using a fork of the [go-azuredevops](https://github.com/mcdafydd/go-azuredevops) library. Please see `README_azuredevops.md` for more information. - ## Resources * How to get started: [www.runatlantis.io/guide](https://www.runatlantis.io/guide) * Full documentation: [www.runatlantis.io/docs](https://www.runatlantis.io/docs) diff --git a/README_azuredevops.md b/README_azuredevops.md deleted file mode 100644 index 6ccf5e0e3..000000000 --- a/README_azuredevops.md +++ /dev/null @@ -1,14 +0,0 @@ -# Atlantis - -

    - Atlantis Logo

    - Terraform Pull Request Automation -

    - -## Azure Devops Support (early beta) - -Azure Devops support requires the following dependencies: - -* https://github.com/microcosm-cc/bluemonday -* https://github.com/russross/blackfriday -* https://github.com/mcdafydd/go-azuredevops From ff611af29feb07a95d549a7744df73c91ad78bc9 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Fri, 26 Jul 2019 14:56:03 -0500 Subject: [PATCH 39/64] Minor style updates --- server/events/command_runner.go | 2 +- server/events/event_parser.go | 2 +- server/events/models/models.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 45ff7db5a..f2bc7c626 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -367,7 +367,7 @@ func (c *DefaultCommandRunner) getGitlabData(baseRepo models.Repo, pullNum int) func (c *DefaultCommandRunner) getAzureDevopsData(baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.AzureDevopsPullGetter == nil { - return models.PullRequest{}, models.Repo{}, errors.New("Atlantis not configured to support Azure Devops") + return models.PullRequest{}, models.Repo{}, errors.New("atlantis not configured to support Azure Devops") } adPull, err := c.AzureDevopsPullGetter.GetPullRequest(baseRepo, pullNum) if err != nil { diff --git a/server/events/event_parser.go b/server/events/event_parser.go index b5441ed35..95b99a40f 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -724,7 +724,7 @@ func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.Pu func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { pullResource, ok := event.Resource.(*azuredevops.GitPullRequest) if !ok { - errMsg := fmt.Sprintf("Failed to type assert event.Resource.") + errMsg := fmt.Sprintf("failed to type assert event.Resource.") err = errors.New(errMsg) return pull, pullEventType, baseRepo, headRepo, user, err } diff --git a/server/events/models/models.go b/server/events/models/models.go index a8cf89497..f49429beb 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -81,7 +81,7 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU // https://docs.microsoft.com/en-us/azure/devops/organizations/settings/naming-restrictions?view=azure-devops matched, err := regexp.MatchString("[A-z0-9][A-z0-9._]+/[A-z0-9][A-z0-9._]+/[A-z0-9][A-z0-9._]+", repoFullName) if err != nil { - e := fmt.Sprintf("NewRepo error vcsHostType = AzureDevops: %s", err) + e := fmt.Sprintf("newRepo error vcsHostType = AzureDevops: %s", err) return Repo{}, errors.New(e) } if !matched { From 40cd5358811a9b8e0a5649f2effe788297e00437 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sat, 27 Jul 2019 22:09:40 -0500 Subject: [PATCH 40/64] Add Azure Devops pull request example for tests --- .../events/vcs/fixtures/azuredevops-pr.json | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 server/events/vcs/fixtures/azuredevops-pr.json diff --git a/server/events/vcs/fixtures/azuredevops-pr.json b/server/events/vcs/fixtures/azuredevops-pr.json new file mode 100644 index 000000000..8e5cc5c9c --- /dev/null +++ b/server/events/vcs/fixtures/azuredevops-pr.json @@ -0,0 +1,33 @@ +{ + "status": "completed", + "mergeStatus": "NotSet", + "autoCompleteSetBy": { + "id": "02228777-0784-420f-bdaa-8bc4830a14a6", + "displayName": "Atlantis User", + "uniqueName": "atlantis.user@example.com", + "url": "https://vssps.dev.azure.com/owner/_apis/Identities/02228777-0784-420f-bdaa-8bc4830a14a6", + "imageUrl": "https://dev.azure.com/owner/DefaultCollection/_api/_common/identityImage?id=02228777-0784-420f-bdaa-8bc4830a14a6" + }, + "pullRequestId": 22, + "completionOptions": { + "bypassPolicy":false, + "bypassReason":"", + "deleteSourceBranch":false, + "mergeCommitMessage":"TEST MERGE COMMIT MESSAGE", + "mergeStrategy":"noFastForward", + "squashMerge":false, + "transitionWorkItems":true, + "triggeredByAutoComplete":false + }, + "reviewers": [ + { + "reviewerUrl": "https://example:8080/tfs/_apis/git/repositories/8010495e-1002-438d-acbf-aaf245dac7c2/pullRequests/22/reviewers/8010495e-1002-438d-acbf-aaf245dac7c2", + "vote": 0, + "id": "8010495e-1002-438d-acbf-aaf245dac7c2", + "displayName": "Atlantis User", + "uniqueName": "atlantis.user@example.com", + "url": "https://owner:8080/tfs/_apis/Identities/8010495e-1002-438d-acbf-aaf245dac7c2", + "imageUrl": "https://owner:8080/tfs/_api/_common/identityImage?id=8010495e-1002-438d-acbf-aaf245dac7c2" + } + ] +} From acef89adc434a2523442f6e2d683b836e479ddab Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Jul 2019 10:37:45 -0500 Subject: [PATCH 41/64] Azure Devops PR minor cleanup, fix tests, checks - Re-write tests for azuredevops_client - CircleCI checks should pass - NewAzureDevopsClient() now takes a hostname like NewGithubClient() - Docs include comment about default merge strategy requirements --- .circleci/config.yml | 3 + cmd/server.go | 12 +- runatlantis.io/docs/apply-requirements.md | 7 +- server/azuredevops_request_validator.go | 3 +- server/events/event_parser.go | 7 +- server/events/event_parser_test.go | 9 +- server/events/vcs/azuredevops_client.go | 79 ++-- server/events/vcs/azuredevops_client_test.go | 467 ++++++++++++++----- server/events/vcs/fixtures/fixtures.go | 30 +- server/locks_controller.go | 2 +- server/server.go | 2 +- 11 files changed, 444 insertions(+), 177 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1436140f3..0647b3d77 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,6 +5,9 @@ jobs: docker: - image: runatlantis/testing-env:latest steps: + - run: go get github.com/mcdafydd/go-azuredevops/azuredevops + - run: go get github.com/microcosm-cc/bluemonday + - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make test-coverage - run: diff --git a/cmd/server.go b/cmd/server.go index 2343beb45..96373ae8c 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -37,12 +37,12 @@ import ( const ( // Flag names. ADBasicAuthFlag = "azuredevops-basic-auth" - ADBasicPasswordFlag = "azuredevops-basic-password" // inbound requests - ADBasicUserFlag = "azuredevops-basic-user" // inbound requests + ADBasicPasswordFlag = "azuredevops-basic-password" // nolint: gosec + ADBasicUserFlag = "azuredevops-basic-user" ADOrgFlag = "azuredevops-org" ADProjectFlag = "azuredevops-project" - ADTokenFlag = "azuredevops-token" // outbound requests - ADUserFlag = "azuredevops-user" // outbound requests + ADTokenFlag = "azuredevops-token" // nolint: gosec + ADUserFlag = "azuredevops-user" AllowForkPRsFlag = "allow-fork-prs" AllowRepoConfigFlag = "allow-repo-config" AtlantisURLFlag = "atlantis-url" @@ -600,10 +600,10 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs") } if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookBasicUser == "" && !s.SilenceOutput { - s.Logger.Warn("No Azure Devops webhook basic user set. This could allow attackers to spoof requests from Azure Devops.") + s.Logger.Warn("no Azure Devops webhook basic user set. This could allow attackers to spoof requests from Azure Devops.") } if userConfig.AzureDevopsUser != "" && userConfig.AzureDevopsWebhookBasicPassword == "" && !s.SilenceOutput { - s.Logger.Warn("No Azure Devops webhook basic password set. This could allow attackers to spoof requests from Azure Devops.") + s.Logger.Warn("no Azure Devops webhook basic password set. This could allow attackers to spoof requests from Azure Devops.") } } diff --git a/runatlantis.io/docs/apply-requirements.md b/runatlantis.io/docs/apply-requirements.md index 182f49ae3..8990559b6 100644 --- a/runatlantis.io/docs/apply-requirements.md +++ b/runatlantis.io/docs/apply-requirements.md @@ -110,7 +110,7 @@ a pull request mergeable. For GitLab, a merge request will be mergeable if it has no conflicts and if all required approvers have approved the pull request. -We **do not** check if there are [Unresolved Discussions](https://docs.gitlab.com/ee/user/discussions/#resolvable-comments-and-discussions) because GitLab doesn't +We **do not** check if there are [Unresolved Discussions](https://docs.gitlab.com/ee/user/discussions/#resolvable-comments-and-threads) because GitLab doesn't provide that information in their API response. If you need this feature please [open an issue](https://github.com/runatlantis/atlantis/issues/new). @@ -129,6 +129,11 @@ In Azure Devops, all pull requests are mergeable unless there is a conflict. You * Allow users to approve their own changes * Allow completion even if some reviewers vote "Waiting" or "Reject" * Reset code reviewer votes when there are new changes +* Require a specfic merge strategy (squash, rebase, etc.) + +::: warning +At this time, the Azure Devops client only supports merging using the default 'no fast-forward' strategy. Make sure your branch policies permit this type of merge. +::: ## Setting Apply Requirements As mentioned above, you can set apply requirements via flags, in `repos.yaml`, or in `atlantis.yaml` if `repos.yaml` diff --git a/server/azuredevops_request_validator.go b/server/azuredevops_request_validator.go index 20e1ef256..e7519187c 100644 --- a/server/azuredevops_request_validator.go +++ b/server/azuredevops_request_validator.go @@ -65,7 +65,6 @@ func (d *DefaultAzureDevopsRequestValidator) validateWithoutBasicAuth(r *http.Re return nil, fmt.Errorf("could not read body: %s", err) } return payload, nil - } else { - return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) } + return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) } diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 95b99a40f..88c5bd2bc 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -861,8 +861,7 @@ func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevop } httpClient := tp.Client() httpClient.Timeout = 10 * time.Second - client := new(azuredevops.Client) - client, err = azuredevops.NewClient(httpClient) + client, err := azuredevops.NewClient(httpClient) if err != nil { return pullRefs, err } @@ -876,9 +875,8 @@ func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevop } client.BaseURL = *parsed } - pr := new(azuredevops.GitPullRequest) opts := azuredevops.PullRequestListOptions{} - pr, _, err = client.PullRequests.Get(context.Background(), e.AzureDevopsOrg, e.AzureDevopsProject, ref.PullNum, &opts) + pr, _, err := client.PullRequests.Get(context.Background(), e.AzureDevopsOrg, e.AzureDevopsProject, ref.PullNum, &opts) if err != nil { return pullRefs, err } @@ -889,7 +887,6 @@ func (e *EventParser) ParseAzureDevopsWorkItemCommentedEvent(comment *azuredevop return pullRefs, err } pullRefs = append(pullRefs, *ref) - err = nil } } diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index f0f4f0085..ba6be1712 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -1205,14 +1205,14 @@ func TestParseAzureDevopsPullEvent(t *testing.T) { _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "url is null", err) testEvent := deepcopy.Copy(ADPullEvent).(azuredevops.Event) - resource := deepcopy.Copy(testEvent.Resource).(azuredevops.GitPullRequest) + resource := deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy = nil testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) ErrEquals(t, "CreatedBy is null", err) testEvent = deepcopy.Copy(ADPullEvent).(azuredevops.Event) - resource = deepcopy.Copy(testEvent.Resource).(azuredevops.GitPullRequest) + resource = deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy.UniqueName = azuredevops.String("") testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) @@ -1262,7 +1262,7 @@ func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { exp: models.OpenedPullEvent, }, { - action: "git.pullrequest.merged", + action: "git.pullrequest.updated", exp: models.ClosedPullEvent, }, { @@ -1274,6 +1274,9 @@ func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { for _, c := range cases { t.Run(c.action, func(t *testing.T) { event := deepcopy.Copy(ADPullEvent).(azuredevops.Event) + if c.exp == models.ClosedPullEvent { + event = deepcopy.Copy(ADPullClosedEvent).(azuredevops.Event) + } event.EventType = c.action _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(event) Ok(t, err) diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index aef4b93a9..144bd6265 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -16,6 +16,7 @@ package vcs import ( "context" "fmt" + "net/url" "strconv" "strings" "time" @@ -30,6 +31,7 @@ import ( "gopkg.in/russross/blackfriday.v2" ) +// AzureDevopsClient represents an Azure Devops VCS client type AzureDevopsClient struct { Client *azuredevops.Client // Version is set to the server version. @@ -37,11 +39,8 @@ type AzureDevopsClient struct { ctx context.Context } -// azuredevopsClientUnderTest is true if we're running under go test. -var azuredevopsClientUnderTest = false - // NewAzureDevopsClient returns a valid Azure Devops client. -func NewAzureDevopsClient(org string, username string, project string, token string) (*AzureDevopsClient, error) { +func NewAzureDevopsClient(hostname string, org string, username string, project string, token string) (*AzureDevopsClient, error) { tp := azuredevops.BasicAuthTransport{ Username: "", Password: strings.TrimSpace(token), @@ -53,6 +52,15 @@ func NewAzureDevopsClient(org string, username string, project string, token str return nil, errors.Wrapf(err, "azuredevops.NewClient() %p", adClient) } + if hostname != "dev.azure.com" { + baseURL := fmt.Sprintf("https://%s/", hostname) + base, err := url.Parse(baseURL) + if err != nil { + return nil, errors.Wrapf(err, "invalid azure devops hostname trying to parse %s", baseURL) + } + adClient.BaseURL = *base + } + client := &AzureDevopsClient{ Client: adClient, ctx: context.Background(), @@ -186,8 +194,8 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment } // PullIsApproved returns true if the merge request was approved. +// https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#require-a-minimum-number-of-reviewers func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { - // previews List() args were g.ctx, repo.Owner, repo.Name, pull.Num, nil opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } @@ -195,18 +203,16 @@ func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullReq if err != nil { return false, errors.Wrap(err, "getting pull request") } - reviews := adPull.Reviewers - if err != nil { - return false, errors.Wrap(err, "getting list of pull request reviewers") - } - for _, review := range reviews { - if *review.Vote == azuredevops.VoteApproved { - return true, nil + for _, review := range adPull.Reviewers { + if review == nil { + continue + } + if *review.Vote != azuredevops.VoteApproved { + return false, err } } - // what happens if no reviewers? must have reviewers? handle both states - //return false, nil - return true, nil + + return true, err } // PullIsMergeable returns true if the merge request can be merged. @@ -259,32 +265,16 @@ func (g *AzureDevopsClient) UpdateStatus(repo models.Repo, pull models.PullReque if url != "" { status.TargetURL = &url } - _, _, err := g.Client.Git.CreateStatus(g.ctx, repo.Owner, repo.Project, repo.Name, pull.HeadCommit, status) + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + _, _, err := g.Client.Git.CreateStatus(g.ctx, owner, project, repoName, pull.HeadCommit, status) return err } -// MergePull merges the merge request. +// MergePull merges the merge request using the default no fast-forward strategy +// If the user has set a branch policy that disallows no fast-forward, the merge will fail +// until we handle branch policies +// https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { - // Users can set their repo to disallow certain types of merging. - // We detect which types aren't allowed and use the type that is. - - // Azure Devops supports squash merge and noFastForward - // in branch policies - // Ignoring these for now - // https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops - - /* - const ( - defaultMergeMethod = "merge" - ) - method := defaultMergeMethod - */ - // Now we're ready to make our API call to merge the pull request. - /* - options := &azuredevops.GitPullRequestCompletionOptions{ - Status: "completed", - */ - // id *IdentityRef, commitMsg string, opts *PullRequestGetOptions descriptor := "Atlantis Terraform Pull Request Automation" i := "atlantis" @@ -309,21 +299,6 @@ func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { TriggeredByAutoComplete: new(bool), } - /* - const ( - defaultMergeMethod = "merge" - rebaseMergeMethod = "rebase" - squashMergeMethod = "squash" - ) - method := defaultMergeMethod - if !repo.GetAllowMergeCommit() { - if repo.GetAllowRebaseMerge() { - method = rebaseMergeMethod - } else if repo.GetAllowSquashMerge() { - method = squashMergeMethod - } - } - */ // Construct request body from supplied parameters mergePull := new(azuredevops.GitPullRequest) mergePull.AutoCompleteSetBy = &id diff --git a/server/events/vcs/azuredevops_client_test.go b/server/events/vcs/azuredevops_client_test.go index 542fdef60..de170d3c7 100644 --- a/server/events/vcs/azuredevops_client_test.go +++ b/server/events/vcs/azuredevops_client_test.go @@ -1,62 +1,32 @@ -package vcs +package vcs_test import ( + "context" "fmt" "io/ioutil" "net/http" "net/http/httptest" + "net/url" "strings" "testing" - "time" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/vcs/fixtures" . "github.com/runatlantis/atlantis/testing" ) -// Test that the base url gets set properly. -func TestNewAzureDevopsClient_BaseURL(t *testing.T) { - azuredevopsClientUnderTest = true - defer func() { azuredevopsClientUnderTest = false }() - cases := []struct { - Hostname string - Account string - Project string - ExpBaseURL string - }{ - { - "dev.azure.com", - "testorg", - "testproject", - "https://dev.azure.com/testorg/testproject", - }, - { - "visualstudio.com", - "testorg", - "testproject", - "https://testorg.visualstudio.com/testproject", - }, - } - - for _, c := range cases { - t.Run(c.Hostname, c.Account, c.Project, func(t *testing.T) { - client, err := NewAzureDevopsClient(c.Hostname, c.Account, c.Project, "token", nil) - Ok(t, err) - Equals(t, c.ExpBaseURL, client.Client.BaseURL().String()) - }) - } -} - func TestAzureDevopsClient_MergePull(t *testing.T) { cases := []struct { description string - glResponse string + response string code int expErr string }{ { "success", - mergeSuccess, + adMergeSuccess, 200, "", }, @@ -74,40 +44,69 @@ func TestAzureDevopsClient_MergePull(t *testing.T) { }, } + // Set default pull request completion options + mcm := azuredevops.NoFastForward.String() + twi := new(bool) + *twi = true + completionOptions := azuredevops.GitPullRequestCompletionOptions{ + BypassPolicy: new(bool), + BypassReason: azuredevops.String(""), + DeleteSourceBranch: new(bool), + MergeCommitMessage: azuredevops.String("commit message"), + MergeStrategy: &mcm, + SquashMerge: new(bool), + TransitionWorkItems: twi, + TriggeredByAutoComplete: new(bool), + } + + id := azuredevops.IdentityRef{} + pull := azuredevops.GitPullRequest{ + PullRequestID: azuredevops.Int(22), + } + for _, c := range cases { t.Run(c.description, func(t *testing.T) { - testServer := httptest.NewServer( + testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. - case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1/merge": + case "/owner/project/_apis/git/repositories/repo/pullrequests/22?api-version=5.1-preview.1": w.WriteHeader(c.code) - w.Write([]byte(c.glResponse)) // nolint: errcheck + w.Write([]byte(c.response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) - tp := azuredevops.BasicAuthTransport{ - Username: "", - Password: strings.TrimSpace("token"), - } - httpClient := tp.Client() - httpClient.Timeout = time.Second * 10 - internalClient, _ := azuredevops.NewClient(httpClient) - Ok(t, internalClient.SetBaseURL(testServer.URL)) - client := &AzureDevopsClient{ - Client: internalClient, - Version: nil, - } + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() - err := client.MergePull(models.PullRequest{ - Num: 1, + merge, _, err := client.Client.PullRequests.Merge(context.Background(), + "owner", + "project", + "repo", + pull.GetPullRequestID(), + &pull, + completionOptions, + id, + ) + + if err != nil { + fmt.Printf("Merge failed: %+v\n", err) + return + } + fmt.Printf("Successfully merged pull request: %+v\n", merge) + + err = client.MergePull(models.PullRequest{ + Num: 22, BaseRepo: models.Repo{ - FullName: "runatlantis/atlantis", - Owner: "runatlantis", - Name: "atlantis", + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", }, }) if c.expErr == "" { @@ -131,54 +130,48 @@ func TestAzureDevopsClient_UpdateStatus(t *testing.T) { }, { models.SuccessCommitStatus, - "success", + "succeeded", }, { models.FailedCommitStatus, "failed", }, } + response := `{"context":{"genre":"Atlantis Bot","name":"src"},"description":"description","state":"%s","targetUrl":"https://google.com"} +` for _, c := range cases { t.Run(c.expState, func(t *testing.T) { gotRequest := false - testServer := httptest.NewServer( + testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { - case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": + case "/owner/project/_apis/git/repositories/repo/commits/sha/statuses?api-version=5.1-preview.1": gotRequest = true - body, err := ioutil.ReadAll(r.Body) Ok(t, err) - exp := fmt.Sprintf(`{"state":"%s","context":"src","target_url":"https://google.com","description":"description"}`, c.expState) + exp := fmt.Sprintf(response, c.expState) Equals(t, exp, string(body)) - defer r.Body.Close() // nolint: errcheck - w.Write([]byte("{}")) // nolint: errcheck + defer r.Body.Close() // nolint: errcheck + w.Write([]byte(response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) - tp := azuredevops.BasicAuthTransport{ - Username: "", - Password: strings.TrimSpace("token"), - } - httpClient := tp.Client() - httpClient.Timeout = time.Second * 10 - internalClient, _ := azuredevops.NewClient(httpClient) - Ok(t, internalClient.SetBaseURL(testServer.URL)) - client := &AzureDevopsClient{ - Client: internalClient, - Version: nil, - } + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() repo := models.Repo{ - FullName: "runatlantis/atlantis", - Owner: "runatlantis", - Name: "atlantis", + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", } - err := client.UpdateStatus(repo, models.PullRequest{ - Num: 1, + err = client.UpdateStatus(repo, models.PullRequest{ + Num: 22, BaseRepo: repo, HeadCommit: "sha", }, c.status, "src", "description", "https://google.com") @@ -188,29 +181,293 @@ func TestAzureDevopsClient_UpdateStatus(t *testing.T) { } } -var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://github.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://github.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://github.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}` +// GetModifiedFiles should make multiple requests if more than one page +// and concat results. +func TestAzureDevopsClient_GetModifiedFiles(t *testing.T) { + itemRespTemplate := `{ + "changes": [ + { + "item": { + "gitObjectType": "blob", + "path": "%s", + "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/%s?versionType=Commit" + }, + "changeType": "add" + }, + { + "item": { + "gitObjectType": "blob", + "path": "%s", + "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/%s?versionType=Commit" + }, + "changeType": "add" + } +]}` + resp := fmt.Sprintf(itemRespTemplate, "file1.txt", "file1.txt", "file2.txt", "file2.txt") + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": + w.Write([]byte(fixtures.ADPullJSON)) // nolint: errcheck + // The second should hit this URL. + case "/owner/project/_apis/git/repositories/repo/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3/changes?api-version=5.1-preview.1": + // We write a header that means there's an additional page. + w.Write([]byte(resp)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) -var getChangesResp = `{ - "changeCounts": { - "Add": 456 - }, - "changes": [ - { - "item": { - "gitObjectType": "blob", - "path": "/MyWebSite/MyWebSite/favicon.ico", - "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/favicon.ico?versionType=Commit" - }, - "changeType": "add" - }, - { - "item": { - "gitObjectType": "tree", - "path": "/MyWebSite/MyWebSite/fonts", - "isFolder": true, - "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/fonts?versionType=Commit" - }, - "changeType": "add" - } - ] + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() + + files, err := client.GetModifiedFiles(models.Repo{ + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.AzureDevops, + Hostname: "dev.azure.com", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, []string{"file1.txt", "file2.txt"}, files) +} + +func TestAzureDevopsClient_PullIsMergeable(t *testing.T) { + cases := []struct { + state string + expMergeable bool + }{ + { + azuredevops.MergeConflicts.String(), + false, + }, + { + azuredevops.MergeRejectedByPolicy.String(), + false, + }, + { + azuredevops.MergeFailure.String(), + true, + }, + { + azuredevops.MergeNotSet.String(), + true, + }, + { + azuredevops.MergeQueued.String(), + true, + }, + { + azuredevops.MergeSucceeded.String(), + true, + }, + } + + // Use a real Azure Devops json response and edit the mergeable_state field. + jsBytes, err := ioutil.ReadFile("fixtures/azuredevops-pr.json") + Ok(t, err) + json := string(jsBytes) + + for _, c := range cases { + t.Run(c.state, func(t *testing.T) { + response := strings.Replace(json, + `"mergeStatus": "NotSet"`, + fmt.Sprintf(`"mergeStatus": "%s"`, c.state), + 1, + ) + + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": + w.Write([]byte(response)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() + + actMergeable, err := client.PullIsMergeable(models.Repo{ + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.AzureDevops, + Hostname: "dev.azure.com", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, c.expMergeable, actMergeable) + }) + } +} + +func TestAzureDevopsClient_PullIsApproved(t *testing.T) { + cases := []struct { + testName string + vote int + expApproved bool + }{ + { + "approved", + azuredevops.VoteApproved, + true, + }, + { + "approved with suggestions", + azuredevops.VoteApprovedWithSuggestions, + false, + }, + { + "no vote", + azuredevops.VoteNone, + false, + }, + { + "vote waiting for author", + azuredevops.VoteWaitingForAuthor, + false, + }, + { + "vote rejected", + azuredevops.VoteRejected, + false, + }, + } + + // Use a real Azure Devops json response and edit the mergeable_state field. + jsBytes, err := ioutil.ReadFile("fixtures/azuredevops-pr.json") + Ok(t, err) + json := string(jsBytes) + + for _, c := range cases { + t.Run(c.testName, func(t *testing.T) { + response := strings.Replace(json, + `"vote": 0,`, + fmt.Sprintf(`"vote": %d,`, c.vote), + 1, + ) + + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": + w.Write([]byte(response)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() + + actApproved, err := client.PullIsApproved(models.Repo{ + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.AzureDevops, + Hostname: "dev.azure.com", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, c.expApproved, actApproved) + }) + } +} + +func TestAzureDevopsClient_GetPullRequest(t *testing.T) { + // Use a real Azure Devops json response and edit the mergeable_state field. + jsBytes, err := ioutil.ReadFile("fixtures/azuredevops-pr.json") + Ok(t, err) + response := string(jsBytes) + + t.Run("get pull request", func(t *testing.T) { + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": + w.Write([]byte(response)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewAzureDevopsClient(testServerURL.Host, "owner", "user", "project", "token") + Ok(t, err) + defer disableSSLVerification()() + + _, err = client.GetPullRequest(models.Repo{ + FullName: "owner/project/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.AzureDevops, + Hostname: "dev.azure.com", + }, + }, 1) + Ok(t, err) + }) +} + +var adMergeSuccess = `{ + "status": "completed", + "mergeStatus": "succeeded", + "autoCompleteSetBy": { + "id": "54d125f7-69f7-4191-904f-c5b96b6261c8", + "displayName": "Jamal Hartnett", + "uniqueName": "fabrikamfiber4@hotmail.com", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/54d125f7-69f7-4191-904f-c5b96b6261c8", + "imageUrl": "https://dev.azure.com/fabrikam/DefaultCollection/_api/_common/identityImage?id=54d125f7-69f7-4191-904f-c5b96b6261c8" + }, + "pullRequestId": 22, + "completionOptions": { + "bypassPolicy":false, + "bypassReason":"", + "deleteSourceBranch":false, + "mergeCommitMessage":"TEST MERGE COMMIT MESSAGE", + "mergeStrategy":"noFastForward", + "squashMerge":false, + "transitionWorkItems":true, + "triggeredByAutoComplete":false + } }` diff --git a/server/events/vcs/fixtures/fixtures.go b/server/events/vcs/fixtures/fixtures.go index d38051770..053152eb7 100644 --- a/server/events/vcs/fixtures/fixtures.go +++ b/server/events/vcs/fixtures/fixtures.go @@ -55,7 +55,17 @@ var Repo = github.Repository{ var ADPullEvent = azuredevops.Event{ EventType: "git.pullrequest.created", - Resource: ADPull, + Resource: &ADPull, +} + +var ADPullUpdatedEvent = azuredevops.Event{ + EventType: "git.pullrequest.updated", + Resource: &ADPull, +} + +var ADPullClosedEvent = azuredevops.Event{ + EventType: "git.pullrequest.merged", + Resource: &ADPullCompleted, } var ADPull = azuredevops.GitPullRequest{ @@ -76,6 +86,24 @@ var ADPull = azuredevops.GitPullRequest{ URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), } +var ADPullCompleted = azuredevops.GitPullRequest{ + CreatedBy: &azuredevops.IdentityRef{ + ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), + DisplayName: azuredevops.String("User"), + UniqueName: azuredevops.String("user@example.com"), + }, + LastMergeSourceCommit: &azuredevops.GitCommitRef{ + CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), + URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), + }, + PullRequestID: azuredevops.Int(1), + Repository: &ADRepo, + SourceRefName: azuredevops.String("refs/heads/owner/sourceBranch"), + Status: azuredevops.String("completed"), + TargetRefName: azuredevops.String("refs/heads/targetBranch"), + URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), +} + var ADRepo = azuredevops.GitRepository{ DefaultBranch: azuredevops.String("refs/heads/master"), Name: azuredevops.String("repo"), diff --git a/server/locks_controller.go b/server/locks_controller.go index 28f7054c8..a586c564c 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -62,7 +62,7 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { } var owner, project, repo string if lock.Pull.BaseRepo.VCSHost.Type == models.AzureDevops { - owner, project, repo = models.SplitAzureDevopsRepoFullName(lock.Project.RepoFullName) + owner, project, repo = vcs.SplitAzureDevopsRepoFullName(lock.Project.RepoFullName) } else { owner, repo = models.SplitRepoFullName(lock.Project.RepoFullName) project = "" diff --git a/server/server.go b/server/server.go index e4fbf42cc..eeefb7660 100644 --- a/server/server.go +++ b/server/server.go @@ -155,7 +155,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if userConfig.AzureDevopsUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops) var err error - azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevopsOrg, userConfig.AzureDevopsUser, userConfig.AzureDevopsProject, userConfig.AzureDevopsToken) + azuredevopsClient, err = vcs.NewAzureDevopsClient("dev.azure.com", userConfig.AzureDevopsOrg, userConfig.AzureDevopsUser, userConfig.AzureDevopsProject, userConfig.AzureDevopsToken) if err != nil { return nil, err } From 83d0b04532597fc4f6faae9e0dc7b8ceac792d69 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Jul 2019 10:45:48 -0500 Subject: [PATCH 42/64] Remove Project member from models Repo struct --- server/events/event_parser_test.go | 15 +++---- server/events/models/models.go | 38 ++-------------- server/events/models/models_test.go | 7 +-- server/events/vcs/azuredevops_client.go | 58 +++++++++++++++++++------ 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index ba6be1712..f4412481b 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -1087,8 +1087,7 @@ func TestParseAzureDevopsRepo(t *testing.T) { r, err := parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ - Owner: "owner", - Project: "project", + Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", @@ -1105,8 +1104,7 @@ func TestParseAzureDevopsRepo(t *testing.T) { r, err = parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ - Owner: "owner", - Project: "project", + Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", @@ -1173,8 +1171,7 @@ func TestParseAzureDevopsWorkItemCommentedEvent(t *testing.T) { for _, ref := range pullRefs { Ok(t, err) Equals(t, models.Repo{ - Owner: "owner", - Project: "project", + Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", @@ -1221,8 +1218,7 @@ func TestParseAzureDevopsPullEvent(t *testing.T) { actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(ADPullEvent) Ok(t, err) expBaseRepo := models.Repo{ - Owner: "owner", - Project: "project", + Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", @@ -1324,8 +1320,7 @@ func TestParseAzureDevopsPull(t *testing.T) { actPull, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&ADPull) Ok(t, err) expBaseRepo := models.Repo{ - Owner: "owner", - Project: "project", + Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", diff --git a/server/events/models/models.go b/server/events/models/models.go index f49429beb..7194a0d6f 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -42,9 +42,6 @@ type Repo struct { // subgroups or Azure Devops Team Projects. This may contain spaces in // the case of Bitbucket Server. Owner string - // Project is juts the project name, only required for Azure Devops. This will never - // have /'s in it. - Project string // Name is just the repo name, ex. "atlantis". This will never have // /'s in it. Name string @@ -125,19 +122,14 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU sanitizedCloneURL := strings.Replace(cloneURL, "https://", "https://"+redactedAuth, -1) sanitizedCloneURL = strings.Replace(sanitizedCloneURL, "http://", "http://"+redactedAuth, -1) - // Get the owner, project, and repo names from the full name. - var owner, project, repo string - if vcsHostType == AzureDevops { - owner, project, repo = SplitAzureDevopsRepoFullName(repoFullName) - } else { - owner, repo = SplitRepoFullName(repoFullName) - } + // Get the owner and repo names from the full name. + owner, repo := SplitRepoFullName(repoFullName) if owner == "" || repo == "" { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q or repo %q was empty", repoFullName, owner, repo) } // Only GitLab repos can have /'s in their owners. // This is for GitLab subgroups and Azure Devops Team Projects. - if strings.Contains(owner, "/") && vcsHostType != Gitlab { + if strings.Contains(owner, "/") && vcsHostType != Gitlab && vcsHostType != AzureDevops { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q should not contain any /'s", repoFullName, owner) } if strings.Contains(repo, "/") { @@ -147,7 +139,6 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU return Repo{ FullName: repoFullName, Owner: owner, - Project: project, Name: repo, CloneURL: authedCloneURL, SanitizedCloneURL: sanitizedCloneURL, @@ -391,29 +382,6 @@ func SplitRepoFullName(repoFullName string) (owner string, repo string) { return repoFullName[:lastSlashIdx], repoFullName[lastSlashIdx+1:] } -// SplitAzureDevopsRepoFullName splits a repo full name up into its owner, -// repo and project name segments. If the repoFullName is malformed, may -// return empty strings for owner, repo, or project. Azure Devops uses -// repoFullName format owner/project/repo. -// -// Ex. runatlantis/atlantis => (runatlantis, atlantis) -// gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) -// azuredevops/project/atlantis => (azuredevops, project, atlantis) -func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project string, repo string) { - firstSlashIdx := strings.Index(repoFullName, "/") - lastSlashIdx := strings.LastIndex(repoFullName, "/") - slashCount := strings.Count(repoFullName, "/") - if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { - return "", "", "" - } - if firstSlashIdx != lastSlashIdx && slashCount == 2 { - return repoFullName[:firstSlashIdx], - repoFullName[firstSlashIdx+1 : lastSlashIdx], - repoFullName[lastSlashIdx+1:] - } - return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] -} - // ProjectResult is the result of executing a plan/apply for a specific project. type ProjectResult struct { Command CommandName diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index 78281499a..3d35ef9df 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -19,6 +19,7 @@ import ( "testing" "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" . "github.com/runatlantis/atlantis/testing" ) @@ -43,8 +44,8 @@ func TestNewRepo_CloneURLWrongRepo(t *testing.T) { } func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) { - _, err := models.NewRepo(models.AzureDevops, "owner/repo", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") - ErrEquals(t, "AzureDevops project name can't be empty", err) + _, err := models.NewRepo(models.AzureDevops, "", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") + ErrEquals(t, "repoFullName can't be empty", err) } // For bitbucket server we don't validate the clone URL because the callers @@ -334,7 +335,7 @@ func TestAzureDevopsSplitRepoFullName(t *testing.T) { for _, c := range cases { t.Run(c.input, func(t *testing.T) { - owner, project, repo := models.SplitAzureDevopsRepoFullName(c.input) + owner, project, repo := vcs.SplitAzureDevopsRepoFullName(c.input) Equals(t, c.expOwner, owner) Equals(t, c.expProject, project) Equals(t, c.expRepo, repo) diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 144bd6265..7c3b8e108 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -73,17 +73,16 @@ func NewAzureDevopsClient(hostname string, org string, username string, project // The names include the path to the file from the repo root, ex. parent/child/file.txt. func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { var files []string - commitIDResponse := new(azuredevops.GitPullRequest) opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } - commitIDResponse, _, _ = g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + commitIDResponse, _, _ := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) commitID := commitIDResponse.GetLastMergeSourceCommit().GetCommitID() - r := new(azuredevops.GitCommitChanges) - r, _, _ = g.Client.Git.GetChanges(g.ctx, repo.Owner, repo.Project, repo.Name, commitID) + r, _, _ := g.Client.Git.GetChanges(g.ctx, owner, project, repoName, commitID) for _, change := range r.Changes { item := change.GetItem() @@ -132,7 +131,8 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } - pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pullNum, &opts) + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pullNum, &opts) if err != nil { return err } @@ -147,7 +147,8 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment workItemComment := azuredevops.WorkItemComment{ Text: &s, } - _, _, err := g.Client.WorkItems.CreateComment(g.ctx, repo.Owner, repo.Project, workItemID, &workItemComment) + owner, project, _ := SplitAzureDevopsRepoFullName(repo.FullName) + _, _, err := g.Client.WorkItems.CreateComment(g.ctx, owner, project, workItemID, &workItemComment) if err != nil { return err } @@ -166,7 +167,8 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment return err } opts := azuredevops.WorkItemCommentListOptions{} - r, _, err := g.Client.WorkItems.ListComments(g.ctx, repo.Owner, repo.Project, workItemID, &opts) + owner, project, _ := SplitAzureDevopsRepoFullName(repo.FullName) + r, _, err := g.Client.WorkItems.ListComments(g.ctx, owner, project, workItemID, &opts) if err != nil { return err } @@ -180,7 +182,7 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment workItemComment := azuredevops.WorkItemComment{ Text: &s, } - _, _, err = g.Client.WorkItems.CreateComment(g.ctx, repo.Owner, repo.Project, workItemID, &workItemComment) + _, _, err = g.Client.WorkItems.CreateComment(g.ctx, owner, project, workItemID, &workItemComment) if err != nil { return err } @@ -199,7 +201,8 @@ func (g *AzureDevopsClient) PullIsApproved(repo models.Repo, pull models.PullReq opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } - adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) if err != nil { return false, errors.Wrap(err, "getting pull request") } @@ -221,7 +224,8 @@ func (g *AzureDevopsClient) PullIsMergeable(repo models.Repo, pull models.PullRe opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } - adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, pull.Num, &opts) + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) if err != nil { return false, errors.Wrap(err, "getting pull request") } @@ -237,7 +241,9 @@ func (g *AzureDevopsClient) GetPullRequest(repo models.Repo, num int) (*azuredev opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } - pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, repo.Owner, repo.Project, repo.Name, num, &opts) + + owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) + pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, num, &opts) return pull, err } @@ -304,11 +310,12 @@ func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { mergePull.AutoCompleteSetBy = &id mergePull.CompletionOptions = &completionOpts + owner, project, repoName := SplitAzureDevopsRepoFullName(pull.BaseRepo.FullName) mergeResult, _, err := g.Client.PullRequests.Merge( g.ctx, - pull.BaseRepo.Owner, - pull.BaseRepo.Project, - pull.BaseRepo.Name, + owner, + project, + repoName, pull.Num, mergePull, completionOpts, @@ -322,3 +329,26 @@ func (g *AzureDevopsClient) MergePull(pull models.PullRequest) error { } return nil } + +// SplitAzureDevopsRepoFullName splits a repo full name up into its owner, +// repo and project name segments. If the repoFullName is malformed, may +// return empty strings for owner, repo, or project. Azure Devops uses +// repoFullName format owner/project/repo. +// +// Ex. runatlantis/atlantis => (runatlantis, atlantis) +// gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) +// azuredevops/project/atlantis => (azuredevops, project, atlantis) +func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project string, repo string) { + firstSlashIdx := strings.Index(repoFullName, "/") + lastSlashIdx := strings.LastIndex(repoFullName, "/") + slashCount := strings.Count(repoFullName, "/") + if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { + return "", "", "" + } + if firstSlashIdx != lastSlashIdx && slashCount == 2 { + return repoFullName[:firstSlashIdx], + repoFullName[firstSlashIdx+1 : lastSlashIdx], + repoFullName[lastSlashIdx+1:] + } + return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] +} From 3879daa896bfaf426173a577ae2282fbb29aab6f Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Sun, 28 Jul 2019 13:37:21 -0500 Subject: [PATCH 43/64] Add new dependencies to remaining CircleCI jobs --- .circleci/config.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0647b3d77..2b1f69107 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,6 +26,9 @@ jobs: # condition. TERRAFORM_VERSION: 0.12.3 steps: + - run: go get github.com/mcdafydd/go-azuredevops/azuredevops + - run: go get github.com/microcosm-cc/bluemonday + - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service # We don't run e2e tests on fork PRs because they don't have access to the secret env vars. @@ -60,6 +63,9 @@ jobs: docker: - image: circleci/golang:1.12 steps: + - run: go get github.com/mcdafydd/go-azuredevops/azuredevops + - run: go get github.com/microcosm-cc/bluemonday + - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service - setup_remote_docker @@ -82,6 +88,9 @@ jobs: docker: - image: circleci/golang:1.12 steps: + - run: go get github.com/mcdafydd/go-azuredevops/azuredevops + - run: go get github.com/microcosm-cc/bluemonday + - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service - setup_remote_docker From b13bb2feacc86676f514980ac469593afec93ba8 Mon Sep 17 00:00:00 2001 From: David McPike <7037366+mcdafydd@users.noreply.github.com> Date: Mon, 29 Jul 2019 18:51:53 -0500 Subject: [PATCH 44/64] Resolve Azure Devops PR comments - Remove AzureDevopsWorkItemID from CommandContext struct - Add new dependencies to dep vendoring - Change final CreateComment return to nil error value - Clean up unused code/comments --- .circleci/config.yml | 12 - Gopkg.lock | 79 +- Gopkg.toml | 6 +- server/events/command_context.go | 6 - server/events/vcs/azuredevops_client.go | 55 +- .../mcdafydd/go-azuredevops/LICENSE | 21 + .../mcdafydd/go-azuredevops/LICENSE.go-github | 28 + .../azuredevops/azuredevops-accessors.go | 3556 +++++++++++++++++ .../go-azuredevops/azuredevops/azuredevops.go | 304 ++ .../go-azuredevops/azuredevops/boards.go | 124 + .../azuredevops/build_controller.go | 14 + .../azuredevops/build_definitions.go | 64 + .../go-azuredevops/azuredevops/builds.go | 203 + .../go-azuredevops/azuredevops/debug.go | 20 + .../azuredevops/delivery_plans.go | 109 + .../go-azuredevops/azuredevops/doc.go | 5 + .../go-azuredevops/azuredevops/event_types.go | 49 + .../go-azuredevops/azuredevops/events.go | 99 + .../go-azuredevops/azuredevops/favourites.go | 47 + .../go-azuredevops/azuredevops/fields.json | 449 +++ .../azuredevops/gen-accessors.go | 332 ++ .../go-azuredevops/azuredevops/git.go | 481 +++ .../azuredevops/identity_ref.go | 27 + .../go-azuredevops/azuredevops/iterations.go | 69 + .../go-azuredevops/azuredevops/messages.go | 161 + .../azuredevops/pull-two-links.json | 103 + .../go-azuredevops/azuredevops/pull.json | 103 + .../azuredevops/pull_requests.go | 366 ++ .../go-azuredevops/azuredevops/release.go | 11 + .../go-azuredevops/azuredevops/teams.go | 89 + .../go-azuredevops/azuredevops/tests.go | 160 + .../go-azuredevops/azuredevops/users.go | 189 + .../go-azuredevops/azuredevops/work_items.go | 260 ++ .../microcosm-cc/bluemonday/.coveralls.yml | 1 + .../microcosm-cc/bluemonday/.travis.yml | 22 + .../microcosm-cc/bluemonday/CONTRIBUTING.md | 51 + .../microcosm-cc/bluemonday/CREDITS.md | 6 + .../microcosm-cc/bluemonday/LICENSE.md | 28 + .../microcosm-cc/bluemonday/Makefile | 42 + .../microcosm-cc/bluemonday/README.md | 350 ++ .../github.com/microcosm-cc/bluemonday/doc.go | 104 + .../github.com/microcosm-cc/bluemonday/go.mod | 5 + .../github.com/microcosm-cc/bluemonday/go.sum | 2 + .../microcosm-cc/bluemonday/helpers.go | 297 ++ .../microcosm-cc/bluemonday/policies.go | 253 ++ .../microcosm-cc/bluemonday/policy.go | 552 +++ .../microcosm-cc/bluemonday/sanitize.go | 581 +++ .../sanitized_anchor_name/.travis.yml | 16 + .../shurcooL/sanitized_anchor_name/LICENSE | 21 + .../shurcooL/sanitized_anchor_name/README.md | 36 + .../shurcooL/sanitized_anchor_name/go.mod | 1 + .../shurcooL/sanitized_anchor_name/main.go | 29 + vendor/golang.org/x/net/html/atom/atom.go | 78 + vendor/golang.org/x/net/html/atom/gen.go | 709 ++++ vendor/golang.org/x/net/html/atom/table.go | 777 ++++ vendor/golang.org/x/net/html/const.go | 104 + vendor/golang.org/x/net/html/doc.go | 106 + vendor/golang.org/x/net/html/doctype.go | 156 + vendor/golang.org/x/net/html/entity.go | 2253 +++++++++++ vendor/golang.org/x/net/html/escape.go | 258 ++ vendor/golang.org/x/net/html/foreign.go | 226 ++ vendor/golang.org/x/net/html/node.go | 193 + vendor/golang.org/x/net/html/parse.go | 2094 ++++++++++ vendor/golang.org/x/net/html/render.go | 271 ++ vendor/golang.org/x/net/html/token.go | 1219 ++++++ .../russross/blackfriday.v2/.gitignore | 8 + .../russross/blackfriday.v2/.travis.yml | 17 + .../russross/blackfriday.v2/LICENSE.txt | 29 + .../russross/blackfriday.v2/README.md | 291 ++ .../gopkg.in/russross/blackfriday.v2/block.go | 1590 ++++++++ .../gopkg.in/russross/blackfriday.v2/doc.go | 18 + .../gopkg.in/russross/blackfriday.v2/esc.go | 34 + .../gopkg.in/russross/blackfriday.v2/go.mod | 1 + .../gopkg.in/russross/blackfriday.v2/html.go | 949 +++++ .../russross/blackfriday.v2/inline.go | 1228 ++++++ .../russross/blackfriday.v2/markdown.go | 950 +++++ .../gopkg.in/russross/blackfriday.v2/node.go | 354 ++ .../russross/blackfriday.v2/smartypants.go | 457 +++ 78 files changed, 24277 insertions(+), 91 deletions(-) create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/LICENSE create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/LICENSE.go-github create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops-accessors.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/boards.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_controller.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_definitions.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/builds.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/debug.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/delivery_plans.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/doc.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/event_types.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/events.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/favourites.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/fields.json create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/gen-accessors.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/git.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/identity_ref.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/iterations.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/messages.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull-two-links.json create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull.json create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull_requests.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/release.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/teams.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/tests.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/users.go create mode 100644 vendor/github.com/mcdafydd/go-azuredevops/azuredevops/work_items.go create mode 100644 vendor/github.com/microcosm-cc/bluemonday/.coveralls.yml create mode 100644 vendor/github.com/microcosm-cc/bluemonday/.travis.yml create mode 100644 vendor/github.com/microcosm-cc/bluemonday/CONTRIBUTING.md create mode 100644 vendor/github.com/microcosm-cc/bluemonday/CREDITS.md create mode 100644 vendor/github.com/microcosm-cc/bluemonday/LICENSE.md create mode 100644 vendor/github.com/microcosm-cc/bluemonday/Makefile create mode 100644 vendor/github.com/microcosm-cc/bluemonday/README.md create mode 100644 vendor/github.com/microcosm-cc/bluemonday/doc.go create mode 100644 vendor/github.com/microcosm-cc/bluemonday/go.mod create mode 100644 vendor/github.com/microcosm-cc/bluemonday/go.sum create mode 100644 vendor/github.com/microcosm-cc/bluemonday/helpers.go create mode 100644 vendor/github.com/microcosm-cc/bluemonday/policies.go create mode 100644 vendor/github.com/microcosm-cc/bluemonday/policy.go create mode 100644 vendor/github.com/microcosm-cc/bluemonday/sanitize.go create mode 100644 vendor/github.com/shurcooL/sanitized_anchor_name/.travis.yml create mode 100644 vendor/github.com/shurcooL/sanitized_anchor_name/LICENSE create mode 100644 vendor/github.com/shurcooL/sanitized_anchor_name/README.md create mode 100644 vendor/github.com/shurcooL/sanitized_anchor_name/go.mod create mode 100644 vendor/github.com/shurcooL/sanitized_anchor_name/main.go create mode 100644 vendor/golang.org/x/net/html/atom/atom.go create mode 100644 vendor/golang.org/x/net/html/atom/gen.go create mode 100644 vendor/golang.org/x/net/html/atom/table.go create mode 100644 vendor/golang.org/x/net/html/const.go create mode 100644 vendor/golang.org/x/net/html/doc.go create mode 100644 vendor/golang.org/x/net/html/doctype.go create mode 100644 vendor/golang.org/x/net/html/entity.go create mode 100644 vendor/golang.org/x/net/html/escape.go create mode 100644 vendor/golang.org/x/net/html/foreign.go create mode 100644 vendor/golang.org/x/net/html/node.go create mode 100644 vendor/golang.org/x/net/html/parse.go create mode 100644 vendor/golang.org/x/net/html/render.go create mode 100644 vendor/golang.org/x/net/html/token.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/.gitignore create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/.travis.yml create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/LICENSE.txt create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/README.md create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/block.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/doc.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/esc.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/go.mod create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/html.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/inline.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/markdown.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/node.go create mode 100644 vendor/gopkg.in/russross/blackfriday.v2/smartypants.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 2b1f69107..1436140f3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,9 +5,6 @@ jobs: docker: - image: runatlantis/testing-env:latest steps: - - run: go get github.com/mcdafydd/go-azuredevops/azuredevops - - run: go get github.com/microcosm-cc/bluemonday - - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make test-coverage - run: @@ -26,9 +23,6 @@ jobs: # condition. TERRAFORM_VERSION: 0.12.3 steps: - - run: go get github.com/mcdafydd/go-azuredevops/azuredevops - - run: go get github.com/microcosm-cc/bluemonday - - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service # We don't run e2e tests on fork PRs because they don't have access to the secret env vars. @@ -63,9 +57,6 @@ jobs: docker: - image: circleci/golang:1.12 steps: - - run: go get github.com/mcdafydd/go-azuredevops/azuredevops - - run: go get github.com/microcosm-cc/bluemonday - - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service - setup_remote_docker @@ -88,9 +79,6 @@ jobs: docker: - image: circleci/golang:1.12 steps: - - run: go get github.com/mcdafydd/go-azuredevops/azuredevops - - run: go get github.com/microcosm-cc/bluemonday - - run: go get gopkg.in/russross/blackfriday.v2 - checkout - run: make build-service - setup_remote_docker diff --git a/Gopkg.lock b/Gopkg.lock index 3454107d7..85eea7c25 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -11,7 +11,7 @@ "internal/optional", "internal/trace", "internal/version", - "storage", + "storage" ] pruneopts = "UT" revision = "c9474f2f8deb81759839474b6bd1726bbfe1c1c4" @@ -78,7 +78,7 @@ "private/protocol/restxml", "private/protocol/xml/xmlutil", "service/s3", - "service/sts", + "service/sts" ] pruneopts = "UT" revision = "8b705a6dec722bcda3a9309c0924d4eca24f7c72" @@ -169,7 +169,7 @@ name = "github.com/go-playground/locales" packages = [ ".", - "currency", + "currency" ] pruneopts = "UT" revision = "f63010822830b6fe52288ee52d5a1151088ce039" @@ -200,7 +200,7 @@ "ptypes", "ptypes/any", "ptypes/duration", - "ptypes/timestamp", + "ptypes/timestamp" ] pruneopts = "UT" revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" @@ -275,7 +275,7 @@ name = "github.com/hashicorp/go-getter" packages = [ ".", - "helper/url", + "helper/url" ] pruneopts = "UT" revision = "e1437d0bbb37a1fa61cdb924b034352c823cb89b" @@ -326,7 +326,7 @@ "hcl/token", "json/parser", "json/scanner", - "json/token", + "json/token" ] pruneopts = "UT" revision = "68e816d1c783414e79bc65b3994d9ab6b0a722ab" @@ -394,6 +394,22 @@ revision = "fc9e8d8ef48496124e79ae0df75490096eccf6fe" version = "v0.0.2" +[[projects]] + digest = "1:09e7697f7b25b7ab6e6470783acaed14c9c360bb80bff7282a3aa818c09a40cf" + name = "github.com/mcdafydd/go-azuredevops" + packages = ["azuredevops"] + pruneopts = "UT" + revision = "1e9cd7e952ca2b5735f8a9fe41005a0bed25cc47" + version = "v0.8.1" + +[[projects]] + digest = "1:6ff1026b8d873d074ddec82ad60bb0eb537dc013f456ef26b63d30beba2f99e7" + name = "github.com/microcosm-cc/bluemonday" + packages = ["."] + pruneopts = "UT" + revision = "506f3da9b7c86d737e91f16b7431df8635871552" + version = "v1.0.2" + [[projects]] branch = "master" digest = "1:ded79ae4dc43f8382f782459c8082f8910e5002fc04a492e98c7a72734ae0dc5" @@ -471,7 +487,7 @@ name = "github.com/petergtz/pegomock" packages = [ ".", - "internal/verify", + "internal/verify" ] pruneopts = "UT" revision = "3821b977214496511e7e49a911307c68f0785ef9" @@ -485,6 +501,14 @@ revision = "645ef00459ed84a119197bfb8d8205042c6df63d" version = "v0.8.0" +[[projects]] + digest = "1:9421f6e9e28ef86933e824b5caff441366f2b69bb281085b9dca40e1f27a1602" + name = "github.com/shurcooL/sanitized_anchor_name" + packages = ["."] + pruneopts = "UT" + revision = "7bfe4c7ecddb3666a94b053b422cdd8f5aaa3615" + version = "v1.0.0" + [[projects]] digest = "1:9e9193aa51197513b3abcb108970d831fbcf40ef96aa845c4f03276e1fa316d2" name = "github.com/sirupsen/logrus" @@ -499,7 +523,7 @@ name = "github.com/spf13/afero" packages = [ ".", - "mem", + "mem" ] pruneopts = "UT" revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b" @@ -551,7 +575,7 @@ ".", "internal/hash", "internal/xlog", - "lzma", + "lzma" ] pruneopts = "UT" revision = "6f934d456d51e742b4eeab20d925a827ef22320a" @@ -590,7 +614,7 @@ "trace", "trace/internal", "trace/propagation", - "trace/tracestate", + "trace/tracestate" ] pruneopts = "UT" revision = "f305e5c4e2cf345eba88de13d10de1126fa45a61" @@ -603,25 +627,27 @@ packages = [ "pbkdf2", "scrypt", - "ssh/terminal", + "ssh/terminal" ] pruneopts = "UT" revision = "7d9177d70076375b9a59c8fde23d52d9c4a7ecd5" [[projects]] branch = "master" - digest = "1:11e1ce0b94b5336730b20ec0e67ade07f2c4d562cd297cea0d1046ab390754f3" + digest = "1:4056e0d0dac89ce489912e0add8267cd4fe28cf0ac732ff1bfedb24f256ee739" name = "golang.org/x/net" packages = [ "context", "context/ctxhttp", + "html", + "html/atom", "http2", "http2/hpack", "idna", "internal/timeseries", "lex/httplex", "trace", - "websocket", + "websocket" ] pruneopts = "UT" revision = "9dfe39835686865bff950a07b394c12a98ddc811" @@ -635,7 +661,7 @@ "google", "internal", "jws", - "jwt", + "jwt" ] pruneopts = "UT" revision = "d668ce993890a79bda886613ee587a69dd5da7a6" @@ -646,7 +672,7 @@ name = "golang.org/x/sys" packages = [ "unix", - "windows", + "windows" ] pruneopts = "UT" revision = "fead79001313d15903fb4605b4a1b781532cd93e" @@ -669,7 +695,7 @@ "unicode/bidi", "unicode/cldr", "unicode/norm", - "unicode/rangetable", + "unicode/rangetable" ] pruneopts = "UT" revision = "1cbadb444a806fd9430d14ad08967ed91da4fa0a" @@ -687,7 +713,7 @@ "option", "storage/v1", "transport/http", - "transport/http/internal/propagation", + "transport/http/internal/propagation" ] pruneopts = "UT" revision = "19e022d8cf43ce81f046bae8cc18c5397cc7732f" @@ -706,7 +732,7 @@ "internal/modules", "internal/remote_api", "internal/urlfetch", - "urlfetch", + "urlfetch" ] pruneopts = "UT" revision = "4a4468ece617fc8205e99368fa2200e9d1fad421" @@ -720,7 +746,7 @@ "googleapis/api/annotations", "googleapis/iam/v1", "googleapis/rpc/code", - "googleapis/rpc/status", + "googleapis/rpc/status" ] pruneopts = "UT" revision = "5fe7a883aa19554f42890211544aa549836af7b7" @@ -759,7 +785,7 @@ "resolver/passthrough", "stats", "status", - "tap", + "tap" ] pruneopts = "UT" revision = "2fdaae294f38ed9a121193c51ec99fecd3b13eb7" @@ -773,6 +799,14 @@ revision = "ab2a8a99087e827c9af87ed6777ba897348fb178" version = "v9.20.2" +[[projects]] + digest = "1:2ee0f15eb0fb04f918db7c2dcf39745f40d69f798ef171610a730e8a56aaa4fd" + name = "gopkg.in/russross/blackfriday.v2" + packages = ["."] + pruneopts = "UT" + revision = "d3b5b032dc8e8927d31a5071b56e14c89f045135" + version = "v2.0.1" + [[projects]] branch = "v2" digest = "1:6570992c02a2137a20be83990a979b6fe892e20ecdc6b756449989b2a7efb8ae" @@ -800,6 +834,8 @@ "github.com/hashicorp/go-multierror", "github.com/hashicorp/go-version", "github.com/lkysow/go-gitlab", + "github.com/mcdafydd/go-azuredevops/azuredevops", + "github.com/microcosm-cc/bluemonday", "github.com/mitchellh/colorstring", "github.com/mitchellh/go-homedir", "github.com/mohae/deepcopy", @@ -813,7 +849,8 @@ "github.com/urfave/negroni", "golang.org/x/crypto/ssh/terminal", "gopkg.in/go-playground/validator.v9", - "gopkg.in/yaml.v2", + "gopkg.in/russross/blackfriday.v2", + "gopkg.in/yaml.v2" ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index bce5e0d98..013a880d3 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -97,16 +97,16 @@ name = "github.com/lkysow/go-gitlab" [[constraint]] - branch = "master" + version = "~0.8.1" name = "github.com/mcdafydd/go-azuredevops" [[constraint]] - branch = "master" name = "github.com/microcosm-cc/bluemonday" + version = "1.0.2" [[constraint]] - branch = "master" name = "gopkg.in/russross/blackfriday.v2" + version = "2.0.1" [[constraint]] name = "github.com/go-test/deep" diff --git a/server/events/command_context.go b/server/events/command_context.go index 4c181e594..5a2bcfe0b 100644 --- a/server/events/command_context.go +++ b/server/events/command_context.go @@ -37,10 +37,4 @@ type CommandContext struct { // set our own build statuses which can affect mergeability if users have // required the Atlantis status to be successful prior to merging. PullMergeable bool - // AzureDevopsWorkItemID Azure Devops Atlantis commands come from a work item - // comment with a link to one or more pull requests. Pull requests may - // also be linked to more than one work item, so we need to know which work item - // triggered this pull request command. - // https://docs.microsoft.com/en-us/azure/devops/boards/queries/link-work-items-support-traceability?view=azure-devops&tabs=new-web-form - AzureDevopsWorkItemID int } diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 7c3b8e108..beb585a2d 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -100,23 +100,16 @@ func (g *AzureDevopsClient) GetModifiedFiles(repo models.Repo, pull models.PullR } // CreateComment creates a comment on a work item linked to pullNum. -// Comments made on pull requests cannot trigger webhooks as of April, 2019. -// Since it is possible to link multiple work items to a single pull request, we: -// 1. Get the pull request -// 2. Get all linked workItems -// a. If only one, comment on it -// b. If more than one, loop through each item and -// i. Get comments for that work item -// ii. Comment on the first workItem with a valid atlantis command in a comment -// iii. Return, ignoring any other items -// iv. If no work items contain comments with a valid atlantis command, return err -// c. If anything else, return warning error +// Comments made on pull requests do not have an associated webhook event +// trigger, only comments made on work items (user stories, tasks, etc.). +// If pull request is linked to multiple work items, log an error and ignore. +// // If comment length is greater than the max comment length we split into // multiple comments. // Azure Devops doesn't support markdown in Work Item comments, but it will // convert text to HTML. We use the blackfriday library to convert Atlantis // comment markdown before submission. -func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment string) error { +func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment string) (err error) { sepEnd := "\n```\n
    " + "\n
    \n\n**Warning**: Output length greater than max comment size. Continued in next comment." sepStart := "Continued from previous comment.\n
    Show Output\n\n" + @@ -153,46 +146,10 @@ func (g *AzureDevopsClient) CreateComment(repo models.Repo, pullNum int, comment return err } } - return nil } else if len(pull.WorkItemRefs) > 1 { - // Until we figure out a nice way to leverage CommentParser.Parse() - // here, we're going to log an error and continue if the user has a - // pull request linked to more than one work item. An example - // implementation is commented below. return errors.New("pull request linked to more than one work item - ignoring") - /* Example handling of PR linked to multiple work items - for _, ref := range pull.WorkItemRefs { - workItemID, err := strconv.Atoi(*ref.ID) - if err != nil { - return err - } - opts := azuredevops.WorkItemCommentListOptions{} - owner, project, _ := SplitAzureDevopsRepoFullName(repo.FullName) - r, _, err := g.Client.WorkItems.ListComments(g.ctx, owner, project, workItemID, &opts) - if err != nil { - return err - } - for _, comment := range r.Comments { - fmt.Println(comment) - parseResult := commentParser.Parse(comment, vcsHost) - if !parseResult.Ignore { - // This is likely the correct Work Item - input := blackfriday.Run([]byte(comment.GetText())) - s := string(input) - workItemComment := azuredevops.WorkItemComment{ - Text: &s, - } - _, _, err = g.Client.WorkItems.CreateComment(g.ctx, owner, project, workItemID, &workItemComment) - if err != nil { - return err - } - return nil - } - } - } - */ } - return err + return } // PullIsApproved returns true if the merge request was approved. diff --git a/vendor/github.com/mcdafydd/go-azuredevops/LICENSE b/vendor/github.com/mcdafydd/go-azuredevops/LICENSE new file mode 100644 index 000000000..cd22318bb --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) for portions of this fork of go-azuredevops are held by Ben Selby, 2019. All other copyright for this project are held by David McPike, 2019. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/mcdafydd/go-azuredevops/LICENSE.go-github b/vendor/github.com/mcdafydd/go-azuredevops/LICENSE.go-github new file mode 100644 index 000000000..7959bdc61 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/LICENSE.go-github @@ -0,0 +1,28 @@ +Copyright (c) 2013 The go-github AUTHORS. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops-accessors.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops-accessors.go new file mode 100644 index 000000000..fb6c64885 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops-accessors.go @@ -0,0 +1,3556 @@ +// Copyright 2017 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. + +// Code generated by gen-accessors; DO NOT EDIT. + +package azuredevops + +import ( + "time" +) + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (a *AgentPoolQueue) GetID() int { + if a == nil || a.ID == nil { + return 0 + } + return *a.ID +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (a *AgentPoolQueue) GetLinks() map[string]Link { + if a == nil || a.Links == nil { + return map[string]Link{} + } + return *a.Links +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (a *AgentPoolQueue) GetName() string { + if a == nil || a.Name == nil { + return "" + } + return *a.Name +} + +// GetPool returns the Pool field. +func (a *AgentPoolQueue) GetPool() *TaskAgentPoolReference { + if a == nil { + return nil + } + return a.Pool +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (a *AgentPoolQueue) GetURL() string { + if a == nil || a.URL == nil { + return "" + } + return *a.URL +} + +// GetAllowedMappings returns the AllowedMappings field if it's non-nil, zero value otherwise. +func (b *Board) GetAllowedMappings() string { + if b == nil || b.AllowedMappings == nil { + return "" + } + return *b.AllowedMappings +} + +// GetCanEdit returns the CanEdit field if it's non-nil, zero value otherwise. +func (b *Board) GetCanEdit() bool { + if b == nil || b.CanEdit == nil { + return false + } + return *b.CanEdit +} + +// GetFields returns the Fields field. +func (b *Board) GetFields() *BoardFields { + if b == nil { + return nil + } + return b.Fields +} + +// GetIsValid returns the IsValid field if it's non-nil, zero value otherwise. +func (b *Board) GetIsValid() bool { + if b == nil || b.IsValid == nil { + return false + } + return *b.IsValid +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (b *Board) GetLinks() map[string]Link { + if b == nil || b.Links == nil { + return map[string]Link{} + } + return *b.Links +} + +// GetRevision returns the Revision field if it's non-nil, zero value otherwise. +func (b *Board) GetRevision() int { + if b == nil || b.Revision == nil { + return 0 + } + return *b.Revision +} + +// GetColumnType returns the ColumnType field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetColumnType() string { + if b == nil || b.ColumnType == nil { + return "" + } + return *b.ColumnType +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetDescription() string { + if b == nil || b.Description == nil { + return "" + } + return *b.Description +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetID() string { + if b == nil || b.ID == nil { + return "" + } + return *b.ID +} + +// GetIsSplit returns the IsSplit field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetIsSplit() bool { + if b == nil || b.IsSplit == nil { + return false + } + return *b.IsSplit +} + +// GetItemLimit returns the ItemLimit field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetItemLimit() int { + if b == nil || b.ItemLimit == nil { + return 0 + } + return *b.ItemLimit +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BoardColumn) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetColumnField returns the ColumnField field. +func (b *BoardFields) GetColumnField() *FieldReference { + if b == nil { + return nil + } + return b.ColumnField +} + +// GetDoneField returns the DoneField field. +func (b *BoardFields) GetDoneField() *FieldReference { + if b == nil { + return nil + } + return b.DoneField +} + +// GetRowField returns the RowField field. +func (b *BoardFields) GetRowField() *FieldReference { + if b == nil { + return nil + } + return b.RowField +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BoardReference) GetID() string { + if b == nil || b.ID == nil { + return "" + } + return *b.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BoardReference) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (b *BoardReference) GetURL() string { + if b == nil || b.URL == nil { + return "" + } + return *b.URL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BoardRow) GetID() string { + if b == nil || b.ID == nil { + return "" + } + return *b.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BoardRow) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetBuildNumber returns the BuildNumber field if it's non-nil, zero value otherwise. +func (b *Build) GetBuildNumber() string { + if b == nil || b.BuildNumber == nil { + return "" + } + return *b.BuildNumber +} + +// GetBuildNumberRevision returns the BuildNumberRevision field if it's non-nil, zero value otherwise. +func (b *Build) GetBuildNumberRevision() int { + if b == nil || b.BuildNumberRevision == nil { + return 0 + } + return *b.BuildNumberRevision +} + +// GetChangedDate returns the ChangedDate field if it's non-nil, zero value otherwise. +func (b *Build) GetChangedDate() string { + if b == nil || b.ChangedDate == nil { + return "" + } + return *b.ChangedDate +} + +// GetController returns the Controller field. +func (b *Build) GetController() *BuildController { + if b == nil { + return nil + } + return b.Controller +} + +// GetDefinition returns the Definition field. +func (b *Build) GetDefinition() *BuildDefinition { + if b == nil { + return nil + } + return b.Definition +} + +// GetDeleted returns the Deleted field if it's non-nil, zero value otherwise. +func (b *Build) GetDeleted() bool { + if b == nil || b.Deleted == nil { + return false + } + return *b.Deleted +} + +// GetDeletedBy returns the DeletedBy field. +func (b *Build) GetDeletedBy() *IdentityRef { + if b == nil { + return nil + } + return b.DeletedBy +} + +// GetDeletedDate returns the DeletedDate field if it's non-nil, zero value otherwise. +func (b *Build) GetDeletedDate() string { + if b == nil || b.DeletedDate == nil { + return "" + } + return *b.DeletedDate +} + +// GetDeletedReason returns the DeletedReason field if it's non-nil, zero value otherwise. +func (b *Build) GetDeletedReason() string { + if b == nil || b.DeletedReason == nil { + return "" + } + return *b.DeletedReason +} + +// GetFinishTime returns the FinishTime field if it's non-nil, zero value otherwise. +func (b *Build) GetFinishTime() string { + if b == nil || b.FinishTime == nil { + return "" + } + return *b.FinishTime +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *Build) GetID() int { + if b == nil || b.ID == nil { + return 0 + } + return *b.ID +} + +// GetKeepForever returns the KeepForever field if it's non-nil, zero value otherwise. +func (b *Build) GetKeepForever() bool { + if b == nil || b.KeepForever == nil { + return false + } + return *b.KeepForever +} + +// GetLastChangedBy returns the LastChangedBy field. +func (b *Build) GetLastChangedBy() *IdentityRef { + if b == nil { + return nil + } + return b.LastChangedBy +} + +// GetLogs returns the Logs field. +func (b *Build) GetLogs() *BuildLogReference { + if b == nil { + return nil + } + return b.Logs +} + +// GetOrchestrationPlan returns the OrchestrationPlan field. +func (b *Build) GetOrchestrationPlan() *TaskOrchestrationPlanReference { + if b == nil { + return nil + } + return b.OrchestrationPlan +} + +// GetParams returns the Params field if it's non-nil, zero value otherwise. +func (b *Build) GetParams() string { + if b == nil || b.Params == nil { + return "" + } + return *b.Params +} + +// GetPriority returns the Priority field if it's non-nil, zero value otherwise. +func (b *Build) GetPriority() string { + if b == nil || b.Priority == nil { + return "" + } + return *b.Priority +} + +// GetProject returns the Project field. +func (b *Build) GetProject() *TeamProjectReference { + if b == nil { + return nil + } + return b.Project +} + +// GetQuality returns the Quality field if it's non-nil, zero value otherwise. +func (b *Build) GetQuality() string { + if b == nil || b.Quality == nil { + return "" + } + return *b.Quality +} + +// GetQueue returns the Queue field. +func (b *Build) GetQueue() *AgentPoolQueue { + if b == nil { + return nil + } + return b.Queue +} + +// GetQueuePosition returns the QueuePosition field if it's non-nil, zero value otherwise. +func (b *Build) GetQueuePosition() int { + if b == nil || b.QueuePosition == nil { + return 0 + } + return *b.QueuePosition +} + +// GetQueueTime returns the QueueTime field if it's non-nil, zero value otherwise. +func (b *Build) GetQueueTime() string { + if b == nil || b.QueueTime == nil { + return "" + } + return *b.QueueTime +} + +// GetRepository returns the Repository field. +func (b *Build) GetRepository() *BuildRepository { + if b == nil { + return nil + } + return b.Repository +} + +// GetResult returns the Result field if it's non-nil, zero value otherwise. +func (b *Build) GetResult() string { + if b == nil || b.Result == nil { + return "" + } + return *b.Result +} + +// GetRetainedByRelease returns the RetainedByRelease field if it's non-nil, zero value otherwise. +func (b *Build) GetRetainedByRelease() bool { + if b == nil || b.RetainedByRelease == nil { + return false + } + return *b.RetainedByRelease +} + +// GetSourceBranch returns the SourceBranch field if it's non-nil, zero value otherwise. +func (b *Build) GetSourceBranch() string { + if b == nil || b.SourceBranch == nil { + return "" + } + return *b.SourceBranch +} + +// GetStartTime returns the StartTime field if it's non-nil, zero value otherwise. +func (b *Build) GetStartTime() string { + if b == nil || b.StartTime == nil { + return "" + } + return *b.StartTime +} + +// GetStatus returns the Status field if it's non-nil, zero value otherwise. +func (b *Build) GetStatus() string { + if b == nil || b.Status == nil { + return "" + } + return *b.Status +} + +// GetTriggerBuild returns the TriggerBuild field. +func (b *Build) GetTriggerBuild() *Build { + if b == nil { + return nil + } + return b.TriggerBuild +} + +// GetTriggerInfo returns the TriggerInfo field. +func (b *Build) GetTriggerInfo() *TriggerInfo { + if b == nil { + return nil + } + return b.TriggerInfo +} + +// GetURI returns the URI field if it's non-nil, zero value otherwise. +func (b *Build) GetURI() string { + if b == nil || b.URI == nil { + return "" + } + return *b.URI +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (b *Build) GetURL() string { + if b == nil || b.URL == nil { + return "" + } + return *b.URL +} + +// GetVersion returns the Version field if it's non-nil, zero value otherwise. +func (b *Build) GetVersion() string { + if b == nil || b.Version == nil { + return "" + } + return *b.Version +} + +// GetCreatedDate returns the CreatedDate field if it's non-nil, zero value otherwise. +func (b *BuildController) GetCreatedDate() string { + if b == nil || b.CreatedDate == nil { + return "" + } + return *b.CreatedDate +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (b *BuildController) GetDescription() string { + if b == nil || b.Description == nil { + return "" + } + return *b.Description +} + +// GetEnabled returns the Enabled field if it's non-nil, zero value otherwise. +func (b *BuildController) GetEnabled() bool { + if b == nil || b.Enabled == nil { + return false + } + return *b.Enabled +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BuildController) GetID() int { + if b == nil || b.ID == nil { + return 0 + } + return *b.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BuildController) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetStatus returns the Status field if it's non-nil, zero value otherwise. +func (b *BuildController) GetStatus() string { + if b == nil || b.Status == nil { + return "" + } + return *b.Status +} + +// GetUpdateDate returns the UpdateDate field if it's non-nil, zero value otherwise. +func (b *BuildController) GetUpdateDate() string { + if b == nil || b.UpdateDate == nil { + return "" + } + return *b.UpdateDate +} + +// GetURI returns the URI field if it's non-nil, zero value otherwise. +func (b *BuildController) GetURI() string { + if b == nil || b.URI == nil { + return "" + } + return *b.URI +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (b *BuildController) GetURL() string { + if b == nil || b.URL == nil { + return "" + } + return *b.URL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BuildDefinition) GetID() int { + if b == nil || b.ID == nil { + return 0 + } + return *b.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BuildDefinition) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetRepository returns the Repository field. +func (b *BuildDefinition) GetRepository() *BuildRepository { + if b == nil { + return nil + } + return b.Repository +} + +// GetIncludeAllProperties returns the IncludeAllProperties field if it's non-nil, zero value otherwise. +func (b *BuildDefinitionsListOptions) GetIncludeAllProperties() bool { + if b == nil || b.IncludeAllProperties == nil { + return false + } + return *b.IncludeAllProperties +} + +// GetPath returns the Path field if it's non-nil, zero value otherwise. +func (b *BuildDefinitionsListOptions) GetPath() string { + if b == nil || b.Path == nil { + return "" + } + return *b.Path +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BuildDemand) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetValue returns the Value field if it's non-nil, zero value otherwise. +func (b *BuildDemand) GetValue() string { + if b == nil || b.Value == nil { + return "" + } + return *b.Value +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BuildLogReference) GetID() int { + if b == nil || b.ID == nil { + return 0 + } + return *b.ID +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (b *BuildLogReference) GetType() string { + if b == nil || b.Type == nil { + return "" + } + return *b.Type +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (b *BuildLogReference) GetURL() string { + if b == nil || b.URL == nil { + return "" + } + return *b.URL +} + +// GetCheckoutSubmodules returns the CheckoutSubmodules field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetCheckoutSubmodules() bool { + if b == nil || b.CheckoutSubmodules == nil { + return false + } + return *b.CheckoutSubmodules +} + +// GetClean returns the Clean field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetClean() string { + if b == nil || b.Clean == nil { + return "" + } + return *b.Clean +} + +// GetDefaultBranch returns the DefaultBranch field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetDefaultBranch() string { + if b == nil || b.DefaultBranch == nil { + return "" + } + return *b.DefaultBranch +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetID() string { + if b == nil || b.ID == nil { + return "" + } + return *b.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetName() string { + if b == nil || b.Name == nil { + return "" + } + return *b.Name +} + +// GetRootFolder returns the RootFolder field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetRootFolder() string { + if b == nil || b.RootFolder == nil { + return "" + } + return *b.RootFolder +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetType() string { + if b == nil || b.Type == nil { + return "" + } + return *b.Type +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (b *BuildRepository) GetURL() string { + if b == nil || b.URL == nil { + return "" + } + return *b.URL +} + +// GetBranch returns the Branch field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetBranch() string { + if b == nil || b.Branch == nil { + return "" + } + return *b.Branch +} + +// GetBuildIDs returns the BuildIDs field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetBuildIDs() string { + if b == nil || b.BuildIDs == nil { + return "" + } + return *b.BuildIDs +} + +// GetBuildNumber returns the BuildNumber field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetBuildNumber() string { + if b == nil || b.BuildNumber == nil { + return "" + } + return *b.BuildNumber +} + +// GetCount returns the Count field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetCount() int { + if b == nil || b.Count == nil { + return 0 + } + return *b.Count +} + +// GetDefinitions returns the Definitions field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetDefinitions() string { + if b == nil || b.Definitions == nil { + return "" + } + return *b.Definitions +} + +// GetDeleted returns the Deleted field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetDeleted() string { + if b == nil || b.Deleted == nil { + return "" + } + return *b.Deleted +} + +// GetMaxPerDefinition returns the MaxPerDefinition field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetMaxPerDefinition() string { + if b == nil || b.MaxPerDefinition == nil { + return "" + } + return *b.MaxPerDefinition +} + +// GetMaxTime returns the MaxTime field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetMaxTime() string { + if b == nil || b.MaxTime == nil { + return "" + } + return *b.MaxTime +} + +// GetMinTime returns the MinTime field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetMinTime() string { + if b == nil || b.MinTime == nil { + return "" + } + return *b.MinTime +} + +// GetOrder returns the Order field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetOrder() string { + if b == nil || b.Order == nil { + return "" + } + return *b.Order +} + +// GetProps returns the Props field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetProps() string { + if b == nil || b.Props == nil { + return "" + } + return *b.Props +} + +// GetQueues returns the Queues field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetQueues() string { + if b == nil || b.Queues == nil { + return "" + } + return *b.Queues +} + +// GetReason returns the Reason field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetReason() string { + if b == nil || b.Reason == nil { + return "" + } + return *b.Reason +} + +// GetRepository returns the Repository field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetRepository() string { + if b == nil || b.Repository == nil { + return "" + } + return *b.Repository +} + +// GetRepoType returns the RepoType field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetRepoType() string { + if b == nil || b.RepoType == nil { + return "" + } + return *b.RepoType +} + +// GetResult returns the Result field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetResult() string { + if b == nil || b.Result == nil { + return "" + } + return *b.Result +} + +// GetStatus returns the Status field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetStatus() string { + if b == nil || b.Status == nil { + return "" + } + return *b.Status +} + +// GetTags returns the Tags field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetTags() string { + if b == nil || b.Tags == nil { + return "" + } + return *b.Tags +} + +// GetToken returns the Token field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetToken() string { + if b == nil || b.Token == nil { + return "" + } + return *b.Token +} + +// GetUserID returns the UserID field if it's non-nil, zero value otherwise. +func (b *BuildsListOptions) GetUserID() string { + if b == nil || b.UserID == nil { + return "" + } + return *b.UserID +} + +// GetAuthor returns the Author field. +func (c *Comment) GetAuthor() *IdentityRef { + if c == nil { + return nil + } + return c.Author +} + +// GetCommentType returns the CommentType field if it's non-nil, zero value otherwise. +func (c *Comment) GetCommentType() string { + if c == nil || c.CommentType == nil { + return "" + } + return *c.CommentType +} + +// GetContent returns the Content field if it's non-nil, zero value otherwise. +func (c *Comment) GetContent() string { + if c == nil || c.Content == nil { + return "" + } + return *c.Content +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (c *Comment) GetID() int { + if c == nil || c.ID == nil { + return 0 + } + return *c.ID +} + +// GetIsDeleted returns the IsDeleted field if it's non-nil, zero value otherwise. +func (c *Comment) GetIsDeleted() bool { + if c == nil || c.IsDeleted == nil { + return false + } + return *c.IsDeleted +} + +// GetLastContentUpdatedDate returns the LastContentUpdatedDate field if it's non-nil, zero value otherwise. +func (c *Comment) GetLastContentUpdatedDate() time.Time { + if c == nil || c.LastContentUpdatedDate == nil { + return time.Time{} + } + return *c.LastContentUpdatedDate +} + +// GetLastUpdatedDate returns the LastUpdatedDate field if it's non-nil, zero value otherwise. +func (c *Comment) GetLastUpdatedDate() time.Time { + if c == nil || c.LastUpdatedDate == nil { + return time.Time{} + } + return *c.LastUpdatedDate +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (c *Comment) GetLinks() map[string]Link { + if c == nil || c.Links == nil { + return map[string]Link{} + } + return *c.Links +} + +// GetParentCommentID returns the ParentCommentID field if it's non-nil, zero value otherwise. +func (c *Comment) GetParentCommentID() int { + if c == nil || c.ParentCommentID == nil { + return 0 + } + return *c.ParentCommentID +} + +// GetPublishedDate returns the PublishedDate field if it's non-nil, zero value otherwise. +func (c *Comment) GetPublishedDate() time.Time { + if c == nil || c.PublishedDate == nil { + return time.Time{} + } + return *c.PublishedDate +} + +// GetLine returns the Line field if it's non-nil, zero value otherwise. +func (c *CommentPosition) GetLine() int { + if c == nil || c.Line == nil { + return 0 + } + return *c.Line +} + +// GetOffset returns the Offset field if it's non-nil, zero value otherwise. +func (c *CommentPosition) GetOffset() int { + if c == nil || c.Offset == nil { + return 0 + } + return *c.Offset +} + +// GetCommentID returns the CommentID field if it's non-nil, zero value otherwise. +func (c *CommentVersionRef) GetCommentID() int { + if c == nil || c.CommentID == nil { + return 0 + } + return *c.CommentID +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (c *CommentVersionRef) GetURL() string { + if c == nil || c.URL == nil { + return "" + } + return *c.URL +} + +// GetVersion returns the Version field if it's non-nil, zero value otherwise. +func (c *CommentVersionRef) GetVersion() int { + if c == nil || c.Version == nil { + return 0 + } + return *c.Version +} + +// GetCreated returns the Created field if it's non-nil, zero value otherwise. +func (d *DeliveryPlan) GetCreated() string { + if d == nil || d.Created == nil { + return "" + } + return *d.Created +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (d *DeliveryPlan) GetID() string { + if d == nil || d.ID == nil { + return "" + } + return *d.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (d *DeliveryPlan) GetName() string { + if d == nil || d.Name == nil { + return "" + } + return *d.Name +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (d *DeliveryPlan) GetType() string { + if d == nil || d.Type == nil { + return "" + } + return *d.Type +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (d *DeliveryPlan) GetURL() string { + if d == nil || d.URL == nil { + return "" + } + return *d.URL +} + +// GetEndDate returns the EndDate field if it's non-nil, zero value otherwise. +func (d *DeliveryPlanTimeLine) GetEndDate() string { + if d == nil || d.EndDate == nil { + return "" + } + return *d.EndDate +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (d *DeliveryPlanTimeLine) GetID() string { + if d == nil || d.ID == nil { + return "" + } + return *d.ID +} + +// GetRevision returns the Revision field if it's non-nil, zero value otherwise. +func (d *DeliveryPlanTimeLine) GetRevision() int { + if d == nil || d.Revision == nil { + return 0 + } + return *d.Revision +} + +// GetStartDate returns the StartDate field if it's non-nil, zero value otherwise. +func (d *DeliveryPlanTimeLine) GetStartDate() string { + if d == nil || d.StartDate == nil { + return "" + } + return *d.StartDate +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (d *DeliveryTeam) GetID() string { + if d == nil || d.ID == nil { + return "" + } + return *d.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (d *DeliveryTeam) GetName() string { + if d == nil || d.Name == nil { + return "" + } + return *d.Name +} + +// GetArtifactID returns the ArtifactID field if it's non-nil, zero value otherwise. +func (f *Favourite) GetArtifactID() string { + if f == nil || f.ArtifactID == nil { + return "" + } + return *f.ArtifactID +} + +// GetArtifactName returns the ArtifactName field if it's non-nil, zero value otherwise. +func (f *Favourite) GetArtifactName() string { + if f == nil || f.ArtifactName == nil { + return "" + } + return *f.ArtifactName +} + +// GetArtifactType returns the ArtifactType field if it's non-nil, zero value otherwise. +func (f *Favourite) GetArtifactType() string { + if f == nil || f.ArtifactType == nil { + return "" + } + return *f.ArtifactType +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (f *Favourite) GetID() string { + if f == nil || f.ID == nil { + return "" + } + return *f.ID +} + +// GetReferenceName returns the ReferenceName field if it's non-nil, zero value otherwise. +func (f *FieldReference) GetReferenceName() string { + if f == nil || f.ReferenceName == nil { + return "" + } + return *f.ReferenceName +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (f *FieldReference) GetURL() string { + if f == nil || f.URL == nil { + return "" + } + return *f.URL +} + +// GetContentType returns the ContentType field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetContentType() string { + if f == nil || f.ContentType == nil { + return "" + } + return *f.ContentType +} + +// GetEncoding returns the Encoding field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetEncoding() int { + if f == nil || f.Encoding == nil { + return 0 + } + return *f.Encoding +} + +// GetExtension returns the Extension field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetExtension() string { + if f == nil || f.Extension == nil { + return "" + } + return *f.Extension +} + +// GetFileName returns the FileName field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetFileName() string { + if f == nil || f.FileName == nil { + return "" + } + return *f.FileName +} + +// GetIsBinary returns the IsBinary field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetIsBinary() bool { + if f == nil || f.IsBinary == nil { + return false + } + return *f.IsBinary +} + +// GetIsImage returns the IsImage field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetIsImage() bool { + if f == nil || f.IsImage == nil { + return false + } + return *f.IsImage +} + +// GetVSLink returns the VSLink field if it's non-nil, zero value otherwise. +func (f *FileContentMetadata) GetVSLink() string { + if f == nil || f.VSLink == nil { + return "" + } + return *f.VSLink +} + +// GetChangeID returns the ChangeID field if it's non-nil, zero value otherwise. +func (g *GitChange) GetChangeID() int { + if g == nil || g.ChangeID == nil { + return 0 + } + return *g.ChangeID +} + +// GetChangeType returns the ChangeType field if it's non-nil, zero value otherwise. +func (g *GitChange) GetChangeType() string { + if g == nil || g.ChangeType == nil { + return "" + } + return *g.ChangeType +} + +// GetItem returns the Item field. +func (g *GitChange) GetItem() *GitItem { + if g == nil { + return nil + } + return g.Item +} + +// GetNewContent returns the NewContent field. +func (g *GitChange) GetNewContent() *ItemContent { + if g == nil { + return nil + } + return g.NewContent +} + +// GetNewContentTemplate returns the NewContentTemplate field. +func (g *GitChange) GetNewContentTemplate() *GitTemplate { + if g == nil { + return nil + } + return g.NewContentTemplate +} + +// GetOriginalPath returns the OriginalPath field if it's non-nil, zero value otherwise. +func (g *GitChange) GetOriginalPath() string { + if g == nil || g.OriginalPath == nil { + return "" + } + return *g.OriginalPath +} + +// GetSourceServerItem returns the SourceServerItem field if it's non-nil, zero value otherwise. +func (g *GitChange) GetSourceServerItem() string { + if g == nil || g.SourceServerItem == nil { + return "" + } + return *g.SourceServerItem +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitChange) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetChangeCounts returns the ChangeCounts field if it's non-nil, zero value otherwise. +func (g *GitCommitChanges) GetChangeCounts() map[string]int { + if g == nil || g.ChangeCounts == nil { + return map[string]int{} + } + return *g.ChangeCounts +} + +// GetAuthor returns the Author field. +func (g *GitCommitRef) GetAuthor() *GitUserDate { + if g == nil { + return nil + } + return g.Author +} + +// GetChangeCounts returns the ChangeCounts field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetChangeCounts() map[string]int { + if g == nil || g.ChangeCounts == nil { + return map[string]int{} + } + return *g.ChangeCounts +} + +// GetChanges returns the Changes field. +func (g *GitCommitRef) GetChanges() *GitChange { + if g == nil { + return nil + } + return g.Changes +} + +// GetComment returns the Comment field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetComment() string { + if g == nil || g.Comment == nil { + return "" + } + return *g.Comment +} + +// GetCommentTruncated returns the CommentTruncated field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetCommentTruncated() bool { + if g == nil || g.CommentTruncated == nil { + return false + } + return *g.CommentTruncated +} + +// GetCommitID returns the CommitID field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetCommitID() string { + if g == nil || g.CommitID == nil { + return "" + } + return *g.CommitID +} + +// GetCommitter returns the Committer field. +func (g *GitCommitRef) GetCommitter() *GitUserDate { + if g == nil { + return nil + } + return g.Committer +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetPush returns the Push field. +func (g *GitCommitRef) GetPush() *GitPushRef { + if g == nil { + return nil + } + return g.Push +} + +// GetRemoteURL returns the RemoteURL field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetRemoteURL() string { + if g == nil || g.RemoteURL == nil { + return "" + } + return *g.RemoteURL +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitCommitRef) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetWorkItems returns the WorkItems field. +func (g *GitCommitRef) GetWorkItems() *ResourceRef { + if g == nil { + return nil + } + return g.WorkItems +} + +// GetCommitID returns the CommitID field if it's non-nil, zero value otherwise. +func (g *GitItem) GetCommitID() string { + if g == nil || g.CommitID == nil { + return "" + } + return *g.CommitID +} + +// GetContent returns the Content field if it's non-nil, zero value otherwise. +func (g *GitItem) GetContent() string { + if g == nil || g.Content == nil { + return "" + } + return *g.Content +} + +// GetContentMetadata returns the ContentMetadata field. +func (g *GitItem) GetContentMetadata() *FileContentMetadata { + if g == nil { + return nil + } + return g.ContentMetadata +} + +// GetGitObjectType returns the GitObjectType field if it's non-nil, zero value otherwise. +func (g *GitItem) GetGitObjectType() string { + if g == nil || g.GitObjectType == nil { + return "" + } + return *g.GitObjectType +} + +// GetIsFolder returns the IsFolder field if it's non-nil, zero value otherwise. +func (g *GitItem) GetIsFolder() bool { + if g == nil || g.IsFolder == nil { + return false + } + return *g.IsFolder +} + +// GetIsSymLink returns the IsSymLink field if it's non-nil, zero value otherwise. +func (g *GitItem) GetIsSymLink() bool { + if g == nil || g.IsSymLink == nil { + return false + } + return *g.IsSymLink +} + +// GetLatestProcessedChange returns the LatestProcessedChange field. +func (g *GitItem) GetLatestProcessedChange() *GitCommitRef { + if g == nil { + return nil + } + return g.LatestProcessedChange +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitItem) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetObjectID returns the ObjectID field if it's non-nil, zero value otherwise. +func (g *GitItem) GetObjectID() string { + if g == nil || g.ObjectID == nil { + return "" + } + return *g.ObjectID +} + +// GetOriginalObjectID returns the OriginalObjectID field if it's non-nil, zero value otherwise. +func (g *GitItem) GetOriginalObjectID() string { + if g == nil || g.OriginalObjectID == nil { + return "" + } + return *g.OriginalObjectID +} + +// GetPath returns the Path field if it's non-nil, zero value otherwise. +func (g *GitItem) GetPath() string { + if g == nil || g.Path == nil { + return "" + } + return *g.Path +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitItem) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetArtifactID returns the ArtifactID field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetArtifactID() string { + if g == nil || g.ArtifactID == nil { + return "" + } + return *g.ArtifactID +} + +// GetAutoCompleteSetBy returns the AutoCompleteSetBy field. +func (g *GitPullRequest) GetAutoCompleteSetBy() *IdentityRef { + if g == nil { + return nil + } + return g.AutoCompleteSetBy +} + +// GetClosedBy returns the ClosedBy field. +func (g *GitPullRequest) GetClosedBy() *IdentityRef { + if g == nil { + return nil + } + return g.ClosedBy +} + +// GetClosedDate returns the ClosedDate field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetClosedDate() time.Time { + if g == nil || g.ClosedDate == nil { + return time.Time{} + } + return *g.ClosedDate +} + +// GetCodeReviewID returns the CodeReviewID field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetCodeReviewID() int { + if g == nil || g.CodeReviewID == nil { + return 0 + } + return *g.CodeReviewID +} + +// GetCompletionOptions returns the CompletionOptions field. +func (g *GitPullRequest) GetCompletionOptions() *GitPullRequestCompletionOptions { + if g == nil { + return nil + } + return g.CompletionOptions +} + +// GetCompletionQueueTime returns the CompletionQueueTime field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetCompletionQueueTime() time.Time { + if g == nil || g.CompletionQueueTime == nil { + return time.Time{} + } + return *g.CompletionQueueTime +} + +// GetCreatedBy returns the CreatedBy field. +func (g *GitPullRequest) GetCreatedBy() *IdentityRef { + if g == nil { + return nil + } + return g.CreatedBy +} + +// GetCreationDate returns the CreationDate field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetCreationDate() time.Time { + if g == nil || g.CreationDate == nil { + return time.Time{} + } + return *g.CreationDate +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetDescription() string { + if g == nil || g.Description == nil { + return "" + } + return *g.Description +} + +// GetForkSource returns the ForkSource field. +func (g *GitPullRequest) GetForkSource() *GitRef { + if g == nil { + return nil + } + return g.ForkSource +} + +// GetIsDraft returns the IsDraft field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetIsDraft() bool { + if g == nil || g.IsDraft == nil { + return false + } + return *g.IsDraft +} + +// GetLastMergeCommit returns the LastMergeCommit field. +func (g *GitPullRequest) GetLastMergeCommit() *GitCommitRef { + if g == nil { + return nil + } + return g.LastMergeCommit +} + +// GetLastMergeSourceCommit returns the LastMergeSourceCommit field. +func (g *GitPullRequest) GetLastMergeSourceCommit() *GitCommitRef { + if g == nil { + return nil + } + return g.LastMergeSourceCommit +} + +// GetLastMergeTargetCommit returns the LastMergeTargetCommit field. +func (g *GitPullRequest) GetLastMergeTargetCommit() *GitCommitRef { + if g == nil { + return nil + } + return g.LastMergeTargetCommit +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetMergeFailureMessage returns the MergeFailureMessage field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetMergeFailureMessage() string { + if g == nil || g.MergeFailureMessage == nil { + return "" + } + return *g.MergeFailureMessage +} + +// GetMergeFailureType returns the MergeFailureType field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetMergeFailureType() string { + if g == nil || g.MergeFailureType == nil { + return "" + } + return *g.MergeFailureType +} + +// GetMergeID returns the MergeID field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetMergeID() string { + if g == nil || g.MergeID == nil { + return "" + } + return *g.MergeID +} + +// GetMergeOptions returns the MergeOptions field. +func (g *GitPullRequest) GetMergeOptions() *GitPullRequestMergeOptions { + if g == nil { + return nil + } + return g.MergeOptions +} + +// GetMergeStatus returns the MergeStatus field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetMergeStatus() string { + if g == nil || g.MergeStatus == nil { + return "" + } + return *g.MergeStatus +} + +// GetPullRequestID returns the PullRequestID field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetPullRequestID() int { + if g == nil || g.PullRequestID == nil { + return 0 + } + return *g.PullRequestID +} + +// GetRemoteURL returns the RemoteURL field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetRemoteURL() string { + if g == nil || g.RemoteURL == nil { + return "" + } + return *g.RemoteURL +} + +// GetRepository returns the Repository field. +func (g *GitPullRequest) GetRepository() *GitRepository { + if g == nil { + return nil + } + return g.Repository +} + +// GetSourceRefName returns the SourceRefName field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetSourceRefName() string { + if g == nil || g.SourceRefName == nil { + return "" + } + return *g.SourceRefName +} + +// GetStatus returns the Status field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetStatus() string { + if g == nil || g.Status == nil { + return "" + } + return *g.Status +} + +// GetSupportsIterations returns the SupportsIterations field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetSupportsIterations() bool { + if g == nil || g.SupportsIterations == nil { + return false + } + return *g.SupportsIterations +} + +// GetTargetRefName returns the TargetRefName field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetTargetRefName() string { + if g == nil || g.TargetRefName == nil { + return "" + } + return *g.TargetRefName +} + +// GetTitle returns the Title field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetTitle() string { + if g == nil || g.Title == nil { + return "" + } + return *g.Title +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitPullRequest) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetID() int { + if g == nil || g.ID == nil { + return 0 + } + return *g.ID +} + +// GetIsDeleted returns the IsDeleted field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetIsDeleted() bool { + if g == nil || g.IsDeleted == nil { + return false + } + return *g.IsDeleted +} + +// GetLastUpdatedDate returns the LastUpdatedDate field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetLastUpdatedDate() time.Time { + if g == nil || g.LastUpdatedDate == nil { + return time.Time{} + } + return *g.LastUpdatedDate +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetPublishedDate returns the PublishedDate field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetPublishedDate() time.Time { + if g == nil || g.PublishedDate == nil { + return time.Time{} + } + return *g.PublishedDate +} + +// GetPullRequestThreadContext returns the PullRequestThreadContext field. +func (g *GitPullRequestCommentThread) GetPullRequestThreadContext() *GitPullRequestCommentThreadContext { + if g == nil { + return nil + } + return g.PullRequestThreadContext +} + +// GetStatus returns the Status field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThread) GetStatus() string { + if g == nil || g.Status == nil { + return "" + } + return *g.Status +} + +// GetFilePath returns the FilePath field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCommentThreadContext) GetFilePath() string { + if g == nil || g.FilePath == nil { + return "" + } + return *g.FilePath +} + +// GetLeftFileEnd returns the LeftFileEnd field. +func (g *GitPullRequestCommentThreadContext) GetLeftFileEnd() *CommentPosition { + if g == nil { + return nil + } + return g.LeftFileEnd +} + +// GetLeftFileStart returns the LeftFileStart field. +func (g *GitPullRequestCommentThreadContext) GetLeftFileStart() *CommentPosition { + if g == nil { + return nil + } + return g.LeftFileStart +} + +// GetRightFileEnd returns the RightFileEnd field. +func (g *GitPullRequestCommentThreadContext) GetRightFileEnd() *CommentPosition { + if g == nil { + return nil + } + return g.RightFileEnd +} + +// GetRightFileStart returns the RightFileStart field. +func (g *GitPullRequestCommentThreadContext) GetRightFileStart() *CommentPosition { + if g == nil { + return nil + } + return g.RightFileStart +} + +// GetBypassPolicy returns the BypassPolicy field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetBypassPolicy() bool { + if g == nil || g.BypassPolicy == nil { + return false + } + return *g.BypassPolicy +} + +// GetBypassReason returns the BypassReason field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetBypassReason() string { + if g == nil || g.BypassReason == nil { + return "" + } + return *g.BypassReason +} + +// GetDeleteSourceBranch returns the DeleteSourceBranch field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetDeleteSourceBranch() bool { + if g == nil || g.DeleteSourceBranch == nil { + return false + } + return *g.DeleteSourceBranch +} + +// GetMergeCommitMessage returns the MergeCommitMessage field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetMergeCommitMessage() string { + if g == nil || g.MergeCommitMessage == nil { + return "" + } + return *g.MergeCommitMessage +} + +// GetMergeStrategy returns the MergeStrategy field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetMergeStrategy() string { + if g == nil || g.MergeStrategy == nil { + return "" + } + return *g.MergeStrategy +} + +// GetSquashMerge returns the SquashMerge field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetSquashMerge() bool { + if g == nil || g.SquashMerge == nil { + return false + } + return *g.SquashMerge +} + +// GetTransitionWorkItems returns the TransitionWorkItems field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetTransitionWorkItems() bool { + if g == nil || g.TransitionWorkItems == nil { + return false + } + return *g.TransitionWorkItems +} + +// GetTriggeredByAutoComplete returns the TriggeredByAutoComplete field if it's non-nil, zero value otherwise. +func (g *GitPullRequestCompletionOptions) GetTriggeredByAutoComplete() bool { + if g == nil || g.TriggeredByAutoComplete == nil { + return false + } + return *g.TriggeredByAutoComplete +} + +// GetDetectRenameFalsePositives returns the DetectRenameFalsePositives field if it's non-nil, zero value otherwise. +func (g *GitPullRequestMergeOptions) GetDetectRenameFalsePositives() bool { + if g == nil || g.DetectRenameFalsePositives == nil { + return false + } + return *g.DetectRenameFalsePositives +} + +// GetDisableRenames returns the DisableRenames field if it's non-nil, zero value otherwise. +func (g *GitPullRequestMergeOptions) GetDisableRenames() bool { + if g == nil || g.DisableRenames == nil { + return false + } + return *g.DisableRenames +} + +// GetProperties returns the Properties field if it's non-nil, zero value otherwise. +func (g *GitPullRequestStatus) GetProperties() time.Time { + if g == nil || g.Properties == nil { + return time.Time{} + } + return *g.Properties +} + +// GetDate returns the Date field if it's non-nil, zero value otherwise. +func (g *GitPush) GetDate() time.Time { + if g == nil || g.Date == nil { + return time.Time{} + } + return *g.Date +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitPush) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetPushedBy returns the PushedBy field. +func (g *GitPush) GetPushedBy() *IdentityRef { + if g == nil { + return nil + } + return g.PushedBy +} + +// GetPushID returns the PushID field if it's non-nil, zero value otherwise. +func (g *GitPush) GetPushID() int { + if g == nil || g.PushID == nil { + return 0 + } + return *g.PushID +} + +// GetRepository returns the Repository field. +func (g *GitPush) GetRepository() *GitRepository { + if g == nil { + return nil + } + return g.Repository +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitPush) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetRepository returns the Repository field. +func (g *GitPushRef) GetRepository() *GitRepository { + if g == nil { + return nil + } + return g.Repository +} + +// GetCreator returns the Creator field. +func (g *GitRef) GetCreator() *IdentityRef { + if g == nil { + return nil + } + return g.Creator +} + +// GetIsLocked returns the IsLocked field if it's non-nil, zero value otherwise. +func (g *GitRef) GetIsLocked() bool { + if g == nil || g.IsLocked == nil { + return false + } + return *g.IsLocked +} + +// GetIsLockedBy returns the IsLockedBy field. +func (g *GitRef) GetIsLockedBy() *IdentityRef { + if g == nil { + return nil + } + return g.IsLockedBy +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitRef) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitRef) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetObjectID returns the ObjectID field if it's non-nil, zero value otherwise. +func (g *GitRef) GetObjectID() string { + if g == nil || g.ObjectID == nil { + return "" + } + return *g.ObjectID +} + +// GetPeeledObjectID returns the PeeledObjectID field if it's non-nil, zero value otherwise. +func (g *GitRef) GetPeeledObjectID() string { + if g == nil || g.PeeledObjectID == nil { + return "" + } + return *g.PeeledObjectID +} + +// GetRepository returns the Repository field. +func (g *GitRef) GetRepository() *GitRepository { + if g == nil { + return nil + } + return g.Repository +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitRef) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetIsLocked returns the IsLocked field if it's non-nil, zero value otherwise. +func (g *GitRefUpdate) GetIsLocked() bool { + if g == nil || g.IsLocked == nil { + return false + } + return *g.IsLocked +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitRefUpdate) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetNewObjectID returns the NewObjectID field if it's non-nil, zero value otherwise. +func (g *GitRefUpdate) GetNewObjectID() string { + if g == nil || g.NewObjectID == nil { + return "" + } + return *g.NewObjectID +} + +// GetOldObjectID returns the OldObjectID field if it's non-nil, zero value otherwise. +func (g *GitRefUpdate) GetOldObjectID() string { + if g == nil || g.OldObjectID == nil { + return "" + } + return *g.OldObjectID +} + +// GetRepositoryID returns the RepositoryID field if it's non-nil, zero value otherwise. +func (g *GitRefUpdate) GetRepositoryID() string { + if g == nil || g.RepositoryID == nil { + return "" + } + return *g.RepositoryID +} + +// GetDefaultBranch returns the DefaultBranch field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetDefaultBranch() string { + if g == nil || g.DefaultBranch == nil { + return "" + } + return *g.DefaultBranch +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetID() string { + if g == nil || g.ID == nil { + return "" + } + return *g.ID +} + +// GetIsFork returns the IsFork field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetIsFork() bool { + if g == nil || g.IsFork == nil { + return false + } + return *g.IsFork +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetParentRepository returns the ParentRepository field. +func (g *GitRepository) GetParentRepository() *GitRepositoryRef { + if g == nil { + return nil + } + return g.ParentRepository +} + +// GetProject returns the Project field. +func (g *GitRepository) GetProject() *TeamProjectReference { + if g == nil { + return nil + } + return g.Project +} + +// GetRemoteURL returns the RemoteURL field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetRemoteURL() string { + if g == nil || g.RemoteURL == nil { + return "" + } + return *g.RemoteURL +} + +// GetSize returns the Size field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetSize() int { + if g == nil || g.Size == nil { + return 0 + } + return *g.Size +} + +// GetSSHURL returns the SSHURL field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetSSHURL() string { + if g == nil || g.SSHURL == nil { + return "" + } + return *g.SSHURL +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetWebURL returns the WebURL field if it's non-nil, zero value otherwise. +func (g *GitRepository) GetWebURL() string { + if g == nil || g.WebURL == nil { + return "" + } + return *g.WebURL +} + +// GetCollection returns the Collection field. +func (g *GitRepositoryRef) GetCollection() *TeamProjectCollectionReference { + if g == nil { + return nil + } + return g.Collection +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetID() string { + if g == nil || g.ID == nil { + return "" + } + return *g.ID +} + +// GetIsFork returns the IsFork field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetIsFork() bool { + if g == nil || g.IsFork == nil { + return false + } + return *g.IsFork +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetProject returns the Project field. +func (g *GitRepositoryRef) GetProject() *TeamProjectReference { + if g == nil { + return nil + } + return g.Project +} + +// GetRemoteURL returns the RemoteURL field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetRemoteURL() string { + if g == nil || g.RemoteURL == nil { + return "" + } + return *g.RemoteURL +} + +// GetSSHURL returns the SSHURL field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetSSHURL() string { + if g == nil || g.SSHURL == nil { + return "" + } + return *g.SSHURL +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GitRepositoryRef) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetContext returns the Context field. +func (g *GitStatus) GetContext() *GitStatusContext { + if g == nil { + return nil + } + return g.Context +} + +// GetCreatedBy returns the CreatedBy field. +func (g *GitStatus) GetCreatedBy() *IdentityRef { + if g == nil { + return nil + } + return g.CreatedBy +} + +// GetCreationDate returns the CreationDate field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetCreationDate() time.Time { + if g == nil || g.CreationDate == nil { + return time.Time{} + } + return *g.CreationDate +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetDescription() string { + if g == nil || g.Description == nil { + return "" + } + return *g.Description +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetID() int { + if g == nil || g.ID == nil { + return 0 + } + return *g.ID +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetLinks() map[string]Link { + if g == nil || g.Links == nil { + return map[string]Link{} + } + return *g.Links +} + +// GetState returns the State field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetState() string { + if g == nil || g.State == nil { + return "" + } + return *g.State +} + +// GetTargetURL returns the TargetURL field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetTargetURL() string { + if g == nil || g.TargetURL == nil { + return "" + } + return *g.TargetURL +} + +// GetUpdatedDate returns the UpdatedDate field if it's non-nil, zero value otherwise. +func (g *GitStatus) GetUpdatedDate() time.Time { + if g == nil || g.UpdatedDate == nil { + return time.Time{} + } + return *g.UpdatedDate +} + +// GetGenre returns the Genre field if it's non-nil, zero value otherwise. +func (g *GitStatusContext) GetGenre() string { + if g == nil || g.Genre == nil { + return "" + } + return *g.Genre +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitStatusContext) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitTemplate) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (g *GitTemplate) GetType() string { + if g == nil || g.Type == nil { + return "" + } + return *g.Type +} + +// GetDate returns the Date field if it's non-nil, zero value otherwise. +func (g *GitUserDate) GetDate() time.Time { + if g == nil || g.Date == nil { + return time.Time{} + } + return *g.Date +} + +// GetEmail returns the Email field if it's non-nil, zero value otherwise. +func (g *GitUserDate) GetEmail() string { + if g == nil || g.Email == nil { + return "" + } + return *g.Email +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (g *GitUserDate) GetName() string { + if g == nil || g.Name == nil { + return "" + } + return *g.Name +} + +// GetValue returns the Value field if it's non-nil, zero value otherwise. +func (g *GraphDescriptorResult) GetValue() string { + if g == nil || g.Value == nil { + return "" + } + return *g.Value +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetDescription() string { + if g == nil || g.Description == nil { + return "" + } + return *g.Description +} + +// GetIsCrossProject returns the IsCrossProject field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetIsCrossProject() bool { + if g == nil || g.IsCrossProject == nil { + return false + } + return *g.IsCrossProject +} + +// GetIsDeleted returns the IsDeleted field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetIsDeleted() bool { + if g == nil || g.IsDeleted == nil { + return false + } + return *g.IsDeleted +} + +// GetIsGlobalScope returns the IsGlobalScope field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetIsGlobalScope() bool { + if g == nil || g.IsGlobalScope == nil { + return false + } + return *g.IsGlobalScope +} + +// GetIsRestrictedVisible returns the IsRestrictedVisible field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetIsRestrictedVisible() bool { + if g == nil || g.IsRestrictedVisible == nil { + return false + } + return *g.IsRestrictedVisible +} + +// GetLocalScopeID returns the LocalScopeID field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetLocalScopeID() string { + if g == nil || g.LocalScopeID == nil { + return "" + } + return *g.LocalScopeID +} + +// GetScopeID returns the ScopeID field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetScopeID() string { + if g == nil || g.ScopeID == nil { + return "" + } + return *g.ScopeID +} + +// GetScopeName returns the ScopeName field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetScopeName() string { + if g == nil || g.ScopeName == nil { + return "" + } + return *g.ScopeName +} + +// GetScopeType returns the ScopeType field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetScopeType() string { + if g == nil || g.ScopeType == nil { + return "" + } + return *g.ScopeType +} + +// GetSecuringHostID returns the SecuringHostID field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetSecuringHostID() string { + if g == nil || g.SecuringHostID == nil { + return "" + } + return *g.SecuringHostID +} + +// GetSpecialType returns the SpecialType field if it's non-nil, zero value otherwise. +func (g *GraphGroup) GetSpecialType() string { + if g == nil || g.SpecialType == nil { + return "" + } + return *g.SpecialType +} + +// GetDomain returns the Domain field if it's non-nil, zero value otherwise. +func (g *GraphMember) GetDomain() string { + if g == nil || g.Domain == nil { + return "" + } + return *g.Domain +} + +// GetMailAddress returns the MailAddress field if it's non-nil, zero value otherwise. +func (g *GraphMember) GetMailAddress() string { + if g == nil || g.MailAddress == nil { + return "" + } + return *g.MailAddress +} + +// GetPrincipalName returns the PrincipalName field if it's non-nil, zero value otherwise. +func (g *GraphMember) GetPrincipalName() string { + if g == nil || g.PrincipalName == nil { + return "" + } + return *g.PrincipalName +} + +// GetLegacyDescriptor returns the LegacyDescriptor field if it's non-nil, zero value otherwise. +func (g *GraphSubject) GetLegacyDescriptor() string { + if g == nil || g.LegacyDescriptor == nil { + return "" + } + return *g.LegacyDescriptor +} + +// GetOrigin returns the Origin field if it's non-nil, zero value otherwise. +func (g *GraphSubject) GetOrigin() string { + if g == nil || g.Origin == nil { + return "" + } + return *g.Origin +} + +// GetOriginID returns the OriginID field if it's non-nil, zero value otherwise. +func (g *GraphSubject) GetOriginID() string { + if g == nil || g.OriginID == nil { + return "" + } + return *g.OriginID +} + +// GetSubjectKind returns the SubjectKind field if it's non-nil, zero value otherwise. +func (g *GraphSubject) GetSubjectKind() string { + if g == nil || g.SubjectKind == nil { + return "" + } + return *g.SubjectKind +} + +// GetDescriptor returns the Descriptor field if it's non-nil, zero value otherwise. +func (g *GraphSubjectBase) GetDescriptor() string { + if g == nil || g.Descriptor == nil { + return "" + } + return *g.Descriptor +} + +// GetDisplayName returns the DisplayName field if it's non-nil, zero value otherwise. +func (g *GraphSubjectBase) GetDisplayName() string { + if g == nil || g.DisplayName == nil { + return "" + } + return *g.DisplayName +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (g *GraphSubjectBase) GetURL() string { + if g == nil || g.URL == nil { + return "" + } + return *g.URL +} + +// GetIsDeletedInOrigin returns the IsDeletedInOrigin field if it's non-nil, zero value otherwise. +func (g *GraphUser) GetIsDeletedInOrigin() bool { + if g == nil || g.IsDeletedInOrigin == nil { + return false + } + return *g.IsDeletedInOrigin +} + +// GetMetadataUpdateDate returns the MetadataUpdateDate field if it's non-nil, zero value otherwise. +func (g *GraphUser) GetMetadataUpdateDate() time.Time { + if g == nil || g.MetadataUpdateDate == nil { + return time.Time{} + } + return *g.MetadataUpdateDate +} + +// GetMetaType returns the MetaType field if it's non-nil, zero value otherwise. +func (g *GraphUser) GetMetaType() string { + if g == nil || g.MetaType == nil { + return "" + } + return *g.MetaType +} + +// GetDescriptor returns the Descriptor field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetDescriptor() string { + if i == nil || i.Descriptor == nil { + return "" + } + return *i.Descriptor +} + +// GetDirectoryAlias returns the DirectoryAlias field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetDirectoryAlias() string { + if i == nil || i.DirectoryAlias == nil { + return "" + } + return *i.DirectoryAlias +} + +// GetDisplayName returns the DisplayName field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetDisplayName() string { + if i == nil || i.DisplayName == nil { + return "" + } + return *i.DisplayName +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetID() string { + if i == nil || i.ID == nil { + return "" + } + return *i.ID +} + +// GetImageURL returns the ImageURL field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetImageURL() string { + if i == nil || i.ImageURL == nil { + return "" + } + return *i.ImageURL +} + +// GetInactive returns the Inactive field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetInactive() bool { + if i == nil || i.Inactive == nil { + return false + } + return *i.Inactive +} + +// GetIsAadIdentity returns the IsAadIdentity field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetIsAadIdentity() bool { + if i == nil || i.IsAadIdentity == nil { + return false + } + return *i.IsAadIdentity +} + +// GetIsContainer returns the IsContainer field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetIsContainer() bool { + if i == nil || i.IsContainer == nil { + return false + } + return *i.IsContainer +} + +// GetIsDeletedInOrigin returns the IsDeletedInOrigin field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetIsDeletedInOrigin() bool { + if i == nil || i.IsDeletedInOrigin == nil { + return false + } + return *i.IsDeletedInOrigin +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetLinks() map[string]Link { + if i == nil || i.Links == nil { + return map[string]Link{} + } + return *i.Links +} + +// GetProfileURL returns the ProfileURL field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetProfileURL() string { + if i == nil || i.ProfileURL == nil { + return "" + } + return *i.ProfileURL +} + +// GetUniqueName returns the UniqueName field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetUniqueName() string { + if i == nil || i.UniqueName == nil { + return "" + } + return *i.UniqueName +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (i *IdentityRef) GetURL() string { + if i == nil || i.URL == nil { + return "" + } + return *i.URL +} + +// GetIsRequired returns the IsRequired field if it's non-nil, zero value otherwise. +func (i *IdentityRefWithVote) GetIsRequired() bool { + if i == nil || i.IsRequired == nil { + return false + } + return *i.IsRequired +} + +// GetReviewerURL returns the ReviewerURL field if it's non-nil, zero value otherwise. +func (i *IdentityRefWithVote) GetReviewerURL() string { + if i == nil || i.ReviewerURL == nil { + return "" + } + return *i.ReviewerURL +} + +// GetVote returns the Vote field if it's non-nil, zero value otherwise. +func (i *IdentityRefWithVote) GetVote() int { + if i == nil || i.Vote == nil { + return 0 + } + return *i.Vote +} + +// GetContent returns the Content field if it's non-nil, zero value otherwise. +func (i *ItemContent) GetContent() string { + if i == nil || i.Content == nil { + return "" + } + return *i.Content +} + +// GetContentType returns the ContentType field. +func (i *ItemContent) GetContentType() *ItemContentType { + if i == nil { + return nil + } + return i.ContentType +} + +// GetBase64Encoded returns the Base64Encoded field if it's non-nil, zero value otherwise. +func (i *ItemContentType) GetBase64Encoded() string { + if i == nil || i.Base64Encoded == nil { + return "" + } + return *i.Base64Encoded +} + +// GetRawText returns the RawText field if it's non-nil, zero value otherwise. +func (i *ItemContentType) GetRawText() string { + if i == nil || i.RawText == nil { + return "" + } + return *i.RawText +} + +// GetEndDate returns the EndDate field if it's non-nil, zero value otherwise. +func (i *Iteration) GetEndDate() string { + if i == nil || i.EndDate == nil { + return "" + } + return *i.EndDate +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (i *Iteration) GetID() string { + if i == nil || i.ID == nil { + return "" + } + return *i.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (i *Iteration) GetName() string { + if i == nil || i.Name == nil { + return "" + } + return *i.Name +} + +// GetPath returns the Path field if it's non-nil, zero value otherwise. +func (i *Iteration) GetPath() string { + if i == nil || i.Path == nil { + return "" + } + return *i.Path +} + +// GetStartDate returns the StartDate field if it's non-nil, zero value otherwise. +func (i *Iteration) GetStartDate() string { + if i == nil || i.StartDate == nil { + return "" + } + return *i.StartDate +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (i *Iteration) GetURL() string { + if i == nil || i.URL == nil { + return "" + } + return *i.URL +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (i *IterationWorkItems) GetLinks() map[string]Link { + if i == nil || i.Links == nil { + return map[string]Link{} + } + return *i.Links +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (i *IterationWorkItems) GetURL() string { + if i == nil || i.URL == nil { + return "" + } + return *i.URL +} + +// GetHref returns the Href field if it's non-nil, zero value otherwise. +func (l *Link) GetHref() string { + if l == nil || l.Href == nil { + return "" + } + return *l.Href +} + +// GetCount returns the Count field if it's non-nil, zero value otherwise. +func (l *ListBoardsResponse) GetCount() int { + if l == nil || l.Count == nil { + return 0 + } + return *l.Count +} + +// GetHTML returns the HTML field if it's non-nil, zero value otherwise. +func (m *Message) GetHTML() string { + if m == nil || m.HTML == nil { + return "" + } + return *m.HTML +} + +// GetMarkdown returns the Markdown field if it's non-nil, zero value otherwise. +func (m *Message) GetMarkdown() string { + if m == nil || m.Markdown == nil { + return "" + } + return *m.Markdown +} + +// GetText returns the Text field if it's non-nil, zero value otherwise. +func (m *Message) GetText() string { + if m == nil || m.Text == nil { + return "" + } + return *m.Text +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (p *Project) GetDescription() string { + if p == nil || p.Description == nil { + return "" + } + return *p.Description +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (p *Project) GetID() string { + if p == nil || p.ID == nil { + return "" + } + return *p.ID +} + +// GetLastUpdateTime returns the LastUpdateTime field if it's non-nil, zero value otherwise. +func (p *Project) GetLastUpdateTime() time.Time { + if p == nil || p.LastUpdateTime == nil { + return time.Time{} + } + return *p.LastUpdateTime +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (p *Project) GetName() string { + if p == nil || p.Name == nil { + return "" + } + return *p.Name +} + +// GetRevision returns the Revision field if it's non-nil, zero value otherwise. +func (p *Project) GetRevision() int { + if p == nil || p.Revision == nil { + return 0 + } + return *p.Revision +} + +// GetState returns the State field if it's non-nil, zero value otherwise. +func (p *Project) GetState() string { + if p == nil || p.State == nil { + return "" + } + return *p.State +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (p *Project) GetURL() string { + if p == nil || p.URL == nil { + return "" + } + return *p.URL +} + +// GetVisibility returns the Visibility field if it's non-nil, zero value otherwise. +func (p *Project) GetVisibility() string { + if p == nil || p.Visibility == nil { + return "" + } + return *p.Visibility +} + +// GetAccount returns the Account field. +func (r *ResourceContainers) GetAccount() *ResourceRef { + if r == nil { + return nil + } + return r.Account +} + +// GetCollection returns the Collection field. +func (r *ResourceContainers) GetCollection() *ResourceRef { + if r == nil { + return nil + } + return r.Collection +} + +// GetProject returns the Project field. +func (r *ResourceContainers) GetProject() *ResourceRef { + if r == nil { + return nil + } + return r.Project +} + +// GetBaseURL returns the BaseURL field if it's non-nil, zero value otherwise. +func (r *ResourceRef) GetBaseURL() string { + if r == nil || r.BaseURL == nil { + return "" + } + return *r.BaseURL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (r *ResourceRef) GetID() string { + if r == nil || r.ID == nil { + return "" + } + return *r.ID +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (r *ResourceRef) GetURL() string { + if r == nil || r.URL == nil { + return "" + } + return *r.URL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (t *TaskAgentPoolReference) GetID() int { + if t == nil || t.ID == nil { + return 0 + } + return *t.ID +} + +// GetIsHosted returns the IsHosted field if it's non-nil, zero value otherwise. +func (t *TaskAgentPoolReference) GetIsHosted() bool { + if t == nil || t.IsHosted == nil { + return false + } + return *t.IsHosted +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (t *TaskAgentPoolReference) GetName() string { + if t == nil || t.Name == nil { + return "" + } + return *t.Name +} + +// GetPlanID returns the PlanID field if it's non-nil, zero value otherwise. +func (t *TaskOrchestrationPlanReference) GetPlanID() string { + if t == nil || t.PlanID == nil { + return "" + } + return *t.PlanID +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (t *TaskOrchestrationPlanReference) GetType() int { + if t == nil || t.Type == nil { + return 0 + } + return *t.Type +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (t *Team) GetDescription() string { + if t == nil || t.Description == nil { + return "" + } + return *t.Description +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (t *Team) GetID() string { + if t == nil || t.ID == nil { + return "" + } + return *t.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (t *Team) GetName() string { + if t == nil || t.Name == nil { + return "" + } + return *t.Name +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (t *Team) GetURL() string { + if t == nil || t.URL == nil { + return "" + } + return *t.URL +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (t *TeamProjectCollectionReference) GetID() string { + if t == nil || t.ID == nil { + return "" + } + return *t.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (t *TeamProjectCollectionReference) GetName() string { + if t == nil || t.Name == nil { + return "" + } + return *t.Name +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (t *TeamProjectCollectionReference) GetURL() string { + if t == nil || t.URL == nil { + return "" + } + return *t.URL +} + +// GetAbbreviation returns the Abbreviation field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetAbbreviation() string { + if t == nil || t.Abbreviation == nil { + return "" + } + return *t.Abbreviation +} + +// GetDefaultTeamImageURL returns the DefaultTeamImageURL field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetDefaultTeamImageURL() string { + if t == nil || t.DefaultTeamImageURL == nil { + return "" + } + return *t.DefaultTeamImageURL +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetDescription() string { + if t == nil || t.Description == nil { + return "" + } + return *t.Description +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetID() string { + if t == nil || t.ID == nil { + return "" + } + return *t.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetName() string { + if t == nil || t.Name == nil { + return "" + } + return *t.Name +} + +// GetRevision returns the Revision field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetRevision() int { + if t == nil || t.Revision == nil { + return 0 + } + return *t.Revision +} + +// GetState returns the State field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetState() string { + if t == nil || t.State == nil { + return "" + } + return *t.State +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetURL() string { + if t == nil || t.URL == nil { + return "" + } + return *t.URL +} + +// GetVisibility returns the Visibility field if it's non-nil, zero value otherwise. +func (t *TeamProjectReference) GetVisibility() string { + if t == nil || t.Visibility == nil { + return "" + } + return *t.Visibility +} + +// GetMine returns the Mine field if it's non-nil, zero value otherwise. +func (t *TeamsListOptions) GetMine() bool { + if t == nil || t.Mine == nil { + return false + } + return *t.Mine +} + +// GetSkip returns the Skip field if it's non-nil, zero value otherwise. +func (t *TeamsListOptions) GetSkip() int { + if t == nil || t.Skip == nil { + return 0 + } + return *t.Skip +} + +// GetTop returns the Top field if it's non-nil, zero value otherwise. +func (t *TeamsListOptions) GetTop() int { + if t == nil || t.Top == nil { + return 0 + } + return *t.Top +} + +// GetCompletedDate returns the CompletedDate field if it's non-nil, zero value otherwise. +func (t *Test) GetCompletedDate() string { + if t == nil || t.CompletedDate == nil { + return "" + } + return *t.CompletedDate +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (t *Test) GetID() int { + if t == nil || t.ID == nil { + return 0 + } + return *t.ID +} + +// GetIsAutomated returns the IsAutomated field if it's non-nil, zero value otherwise. +func (t *Test) GetIsAutomated() bool { + if t == nil || t.IsAutomated == nil { + return false + } + return *t.IsAutomated +} + +// GetIteration returns the Iteration field if it's non-nil, zero value otherwise. +func (t *Test) GetIteration() string { + if t == nil || t.Iteration == nil { + return "" + } + return *t.Iteration +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (t *Test) GetName() string { + if t == nil || t.Name == nil { + return "" + } + return *t.Name +} + +// GetRevision returns the Revision field if it's non-nil, zero value otherwise. +func (t *Test) GetRevision() int { + if t == nil || t.Revision == nil { + return 0 + } + return *t.Revision +} + +// GetStartedDate returns the StartedDate field if it's non-nil, zero value otherwise. +func (t *Test) GetStartedDate() string { + if t == nil || t.StartedDate == nil { + return "" + } + return *t.StartedDate +} + +// GetState returns the State field if it's non-nil, zero value otherwise. +func (t *Test) GetState() string { + if t == nil || t.State == nil { + return "" + } + return *t.State +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (t *Test) GetURL() string { + if t == nil || t.URL == nil { + return "" + } + return *t.URL +} + +// GetBuildURI returns the BuildURI field if it's non-nil, zero value otherwise. +func (t *TestsListOptions) GetBuildURI() string { + if t == nil || t.BuildURI == nil { + return "" + } + return *t.BuildURI +} + +// GetCount returns the Count field if it's non-nil, zero value otherwise. +func (t *TestsListOptions) GetCount() int { + if t == nil || t.Count == nil { + return 0 + } + return *t.Count +} + +// GetCiMessage returns the CiMessage field if it's non-nil, zero value otherwise. +func (t *TriggerInfo) GetCiMessage() string { + if t == nil || t.CiMessage == nil { + return "" + } + return *t.CiMessage +} + +// GetCiSourceBranch returns the CiSourceBranch field if it's non-nil, zero value otherwise. +func (t *TriggerInfo) GetCiSourceBranch() string { + if t == nil || t.CiSourceBranch == nil { + return "" + } + return *t.CiSourceBranch +} + +// GetCiSourceSha returns the CiSourceSha field if it's non-nil, zero value otherwise. +func (t *TriggerInfo) GetCiSourceSha() string { + if t == nil || t.CiSourceSha == nil { + return "" + } + return *t.CiSourceSha +} + +// GetIsLocked returns the IsLocked field if it's non-nil, zero value otherwise. +func (u *UpdateRefsBody) GetIsLocked() bool { + if u == nil || u.IsLocked == nil { + return false + } + return *u.IsLocked +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (u *UpdateRefsBody) GetName() string { + if u == nil || u.Name == nil { + return "" + } + return *u.Name +} + +// GetNewObjectID returns the NewObjectID field if it's non-nil, zero value otherwise. +func (u *UpdateRefsBody) GetNewObjectID() string { + if u == nil || u.NewObjectID == nil { + return "" + } + return *u.NewObjectID +} + +// GetOldObjectID returns the OldObjectID field if it's non-nil, zero value otherwise. +func (u *UpdateRefsBody) GetOldObjectID() string { + if u == nil || u.OldObjectID == nil { + return "" + } + return *u.OldObjectID +} + +// GetRepositoryID returns the RepositoryID field if it's non-nil, zero value otherwise. +func (u *UpdateRefsBody) GetRepositoryID() string { + if u == nil || u.RepositoryID == nil { + return "" + } + return *u.RepositoryID +} + +// GetMessage returns the Message field if it's non-nil, zero value otherwise. +func (v *ValidationResult) GetMessage() string { + if v == nil || v.Message == nil { + return "" + } + return *v.Message +} + +// GetResult returns the Result field if it's non-nil, zero value otherwise. +func (v *ValidationResult) GetResult() string { + if v == nil || v.Result == nil { + return "" + } + return *v.Result +} + +// GetActive returns the Active field if it's non-nil, zero value otherwise. +func (w *WebAPITagDefinition) GetActive() bool { + if w == nil || w.Active == nil { + return false + } + return *w.Active +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (w *WebAPITagDefinition) GetID() string { + if w == nil || w.ID == nil { + return "" + } + return *w.ID +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (w *WebAPITagDefinition) GetName() string { + if w == nil || w.Name == nil { + return "" + } + return *w.Name +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WebAPITagDefinition) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetCommentVersionRef returns the CommentVersionRef field. +func (w *WorkItem) GetCommentVersionRef() *CommentVersionRef { + if w == nil { + return nil + } + return w.CommentVersionRef +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (w *WorkItem) GetID() int { + if w == nil || w.ID == nil { + return 0 + } + return *w.ID +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (w *WorkItem) GetLinks() map[string]Link { + if w == nil || w.Links == nil { + return map[string]Link{} + } + return *w.Links +} + +// GetRev returns the Rev field if it's non-nil, zero value otherwise. +func (w *WorkItem) GetRev() int { + if w == nil || w.Rev == nil { + return 0 + } + return *w.Rev +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItem) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetCreatedBy returns the CreatedBy field. +func (w *WorkItemComment) GetCreatedBy() *IdentityRef { + if w == nil { + return nil + } + return w.CreatedBy +} + +// GetCreatedDate returns the CreatedDate field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetCreatedDate() time.Time { + if w == nil || w.CreatedDate == nil { + return time.Time{} + } + return *w.CreatedDate +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetID() int { + if w == nil || w.ID == nil { + return 0 + } + return *w.ID +} + +// GetModifiedBy returns the ModifiedBy field. +func (w *WorkItemComment) GetModifiedBy() *IdentityRef { + if w == nil { + return nil + } + return w.ModifiedBy +} + +// GetModifiedDate returns the ModifiedDate field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetModifiedDate() time.Time { + if w == nil || w.ModifiedDate == nil { + return time.Time{} + } + return *w.ModifiedDate +} + +// GetText returns the Text field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetText() string { + if w == nil || w.Text == nil { + return "" + } + return *w.Text +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetVersion returns the Version field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetVersion() int { + if w == nil || w.Version == nil { + return 0 + } + return *w.Version +} + +// GetWorkItemID returns the WorkItemID field if it's non-nil, zero value otherwise. +func (w *WorkItemComment) GetWorkItemID() int { + if w == nil || w.WorkItemID == nil { + return 0 + } + return *w.WorkItemID +} + +// GetContinuationToken returns the ContinuationToken field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetContinuationToken() string { + if w == nil || w.ContinuationToken == nil { + return "" + } + return *w.ContinuationToken +} + +// GetCount returns the Count field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetCount() int { + if w == nil || w.Count == nil { + return 0 + } + return *w.Count +} + +// GetLinks returns the Links field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetLinks() map[string]Link { + if w == nil || w.Links == nil { + return map[string]Link{} + } + return *w.Links +} + +// GetNextPage returns the NextPage field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetNextPage() string { + if w == nil || w.NextPage == nil { + return "" + } + return *w.NextPage +} + +// GetTotalCount returns the TotalCount field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetTotalCount() int { + if w == nil || w.TotalCount == nil { + return 0 + } + return *w.TotalCount +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItemCommentList) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetRel returns the Rel field if it's non-nil, zero value otherwise. +func (w *WorkItemLink) GetRel() string { + if w == nil || w.Rel == nil { + return "" + } + return *w.Rel +} + +// GetSource returns the Source field. +func (w *WorkItemLink) GetSource() *WorkItemReference { + if w == nil { + return nil + } + return w.Source +} + +// GetTarget returns the Target field. +func (w *WorkItemLink) GetTarget() *WorkItemReference { + if w == nil { + return nil + } + return w.Target +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (w *WorkItemReference) GetID() int { + if w == nil || w.ID == nil { + return 0 + } + return *w.ID +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItemReference) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetRel returns the Rel field if it's non-nil, zero value otherwise. +func (w *WorkItemRelation) GetRel() string { + if w == nil || w.Rel == nil { + return "" + } + return *w.Rel +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItemRelation) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetFields returns the Fields field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetFields() map[string]WorkItemFieldUpdate { + if w == nil || w.Fields == nil { + return map[string]WorkItemFieldUpdate{} + } + return *w.Fields +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetID() int { + if w == nil || w.ID == nil { + return 0 + } + return *w.ID +} + +// GetRelations returns the Relations field. +func (w *WorkItemUpdate) GetRelations() *WorkItemRelationUpdates { + if w == nil { + return nil + } + return w.Relations +} + +// GetRev returns the Rev field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetRev() int { + if w == nil || w.Rev == nil { + return 0 + } + return *w.Rev +} + +// GetRevisedBy returns the RevisedBy field. +func (w *WorkItemUpdate) GetRevisedBy() *IdentityRef { + if w == nil { + return nil + } + return w.RevisedBy +} + +// GetRevisedDate returns the RevisedDate field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetRevisedDate() time.Time { + if w == nil || w.RevisedDate == nil { + return time.Time{} + } + return *w.RevisedDate +} + +// GetURL returns the URL field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetURL() string { + if w == nil || w.URL == nil { + return "" + } + return *w.URL +} + +// GetWorkItemID returns the WorkItemID field if it's non-nil, zero value otherwise. +func (w *WorkItemUpdate) GetWorkItemID() int { + if w == nil || w.WorkItemID == nil { + return 0 + } + return *w.WorkItemID +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops.go new file mode 100644 index 000000000..b62855de7 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/azuredevops.go @@ -0,0 +1,304 @@ +package azuredevops + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "reflect" + "strings" + + "github.com/google/go-querystring/query" +) + +const ( + // DefaultBaseURL is the default URI base for most Azure Devops REST API endpoints + DefaultBaseURL string = "https://dev.azure.com/" + // DefaultVsspsBaseURL is the default URI base for some Azure Devops REST API endpoints + DefaultVsspsBaseURL string = "https://vssps.dev.azure.com/" + // userAgent our HTTP client's user-agent + userAgent string = "go-azuredevops" +) + +// Client for interacting with the Azure DevOps API +type Client struct { + client *http.Client + + // BaseURL Comprised of baseURL and account + BaseURL url.URL + + VsspsBaseURL url.URL + + UserAgent string + + // Account Default tenant identifier + Account string + + // Services used to proxy to other API endpoints + Boards *BoardsService + BuildDefinitions *BuildDefinitionsService + Builds *BuildsService + DeliveryPlans *DeliveryPlansService + Favourites *FavouritesService + Git *GitService + Iterations *IterationsService + PullRequests *PullRequestsService + Teams *TeamsService + Tests *TestsService + Users *UsersService + WorkItems *WorkItemsService +} + +// NewClient returns a new Azure DevOps API client. If a nil httpClient is +// provided, http.DefaultClient will be used. To use API methods which require +// authentication, provide an http.Client that will perform the authentication +// for you (such as that provided by the golang.org/x/oauth2 library). +func NewClient(httpClient *http.Client) (*Client, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + c := &Client{} + baseURL, _ := url.Parse(DefaultBaseURL) + vsspsBaseURL, _ := url.Parse(DefaultVsspsBaseURL) + + c.client = httpClient + c.BaseURL = *baseURL + c.VsspsBaseURL = *vsspsBaseURL + c.UserAgent = userAgent + + c.Boards = &BoardsService{client: c} + c.BuildDefinitions = &BuildDefinitionsService{client: c} + c.Builds = &BuildsService{client: c} + c.DeliveryPlans = &DeliveryPlansService{client: c} + c.Favourites = &FavouritesService{client: c} + c.Git = &GitService{client: c} + c.Iterations = &IterationsService{client: c} + c.PullRequests = &PullRequestsService{client: c} + c.Teams = &TeamsService{client: c} + c.Tests = &TestsService{client: c} + c.Users = &UsersService{client: c} + c.WorkItems = &WorkItemsService{client: c} + + return c, nil +} + +// NewRequest creates an API request. A relative URL can be provided in urlStr, +// in which case it is resolved relative to the BaseURL of the Client. +// Relative URLs should always be specified without a preceding slash. If +// specified, the value pointed to by body is JSON encoded and included as the +// request body. +func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { + if !strings.HasSuffix(c.BaseURL.Path, "/") { + return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL.String()) + } + + u, err := c.BaseURL.Parse(urlStr) + if err != nil { + return nil, err + } + + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + return req, nil +} + +// Execute sends an API request and returns the API response. The API response is +// JSON decoded and stored in the value pointed to by r, or returned as an +// error if an API error has occurred. If r implements the io.Writer +// interface, the raw response body will be written to r, without attempting to +// first decode it. +// +// The provided ctx must be non-nil. If it is canceled or times out, +// ctx.Err() will be returned. +func (c *Client) Execute(ctx context.Context, req *http.Request, r interface{}) (*http.Response, error) { + req = req.WithContext(ctx) + debugReq(req) + resp, err := c.client.Do(req) + if err != nil { + // If we got an error, and the context has been canceled, + // the context's error is probably more useful. + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // If the error type is *url.Error, sanitize its URL before returning. + if e, ok := err.(*url.Error); ok { + if url, err := url.Parse(e.URL); err == nil { + e.URL = sanitizeURL(url).String() + return nil, e + } + } + + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("Request to %s responded with status %d", req.URL, resp.StatusCode) + } + + if r != nil { + if w, ok := r.(io.Writer); ok { + io.Copy(w, resp.Body) + } else { + decErr := json.NewDecoder(resp.Body).Decode(r) + if decErr == io.EOF { + decErr = nil // ignore EOF errors caused by empty response body + } + if decErr != nil { + err = decErr + } + } + } + + return resp, err +} + +// BasicAuthTransport is an http.RoundTripper that authenticates all requests +// using HTTP Basic Authentication with the provided username and password. It +// additionally supports users who have two-factor authentication enabled on +// their GitHub account. +type BasicAuthTransport struct { + Username string // Azure Devops username + Password string // Azure Devops password + OTP string // one-time password for users with two-factor auth enabled + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. +func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // To set extra headers, we must make a copy of the Request so + // that we don't modify the Request we were given. This is required by the + // specification of http.RoundTripper. + // + // Since we are going to modify only req.Header here, we only need a deep copy + // of req.Header. + req2 := new(http.Request) + *req2 = *req + req2.Header = make(http.Header, len(req.Header)) + for k, s := range req.Header { + req2.Header[k] = append([]string(nil), s...) + } + + //req2.SetBasicAuth(t.Username, t.Password) + req2.SetBasicAuth("", t.Password) + + return t.transport().RoundTrip(req2) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. +func (t *BasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *BasicAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// addOptions adds the parameters in opt as URL query parameters to s. opt +// must be a struct whose fields may contain "url" tags. +// From: https://github.com/google/go-github/blob/master/github/github.go +func addOptions(s string, opt interface{}) (string, error) { + v := reflect.ValueOf(opt) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qs, err := query.Values(opt) + if err != nil { + return s, err + } + + for k, v := range u.Query() { + qs[k] = v + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} + +// formatRef helper function for API calls that need a branch reference +// as an input parameter. Doesn't do much error checking. +// Examples: +// *ref = "mybranch" => *ref = "refs/heads/mybranch" +// *ref = "refs/heads/abranch" => *ref = "refs/heads/abranch" +func formatRef(ref *string) error { + matched, err := path.Match("refs/heads/*", *ref) + if matched && err == nil { + return nil + } else if err != nil { + return err + } else { + *ref = fmt.Sprintf("refs/heads/%s", *ref) + return nil + } +} + +// sanitizeURL redacts the client_secret parameter from the URL which may be +// exposed to the user. +func sanitizeURL(uri *url.URL) *url.URL { + if uri == nil { + return nil + } + params := uri.Query() + if len(params.Get("client_secret")) > 0 { + params.Set("client_secret", "REDACTED") + uri.RawQuery = params.Encode() + } + return uri +} + +// Bool is a helper routine that allocates a new bool value +// to store v and returns a pointer to it. +func Bool(v bool) *bool { return &v } + +// Int is a helper routine that allocates a new int value +// to store v and returns a pointer to it. +func Int(v int) *int { return &v } + +// Int64 is a helper routine that allocates a new int64 value +// to store v and returns a pointer to it. +func Int64(v int64) *int64 { return &v } + +// String is a helper routine that allocates a new string value +// to store v and returns a pointer to it. +func String(v string) *string { return &v } diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/boards.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/boards.go new file mode 100644 index 000000000..9f74ac7a5 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/boards.go @@ -0,0 +1,124 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// BoardsService handles communication with the boards methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/boards +type BoardsService struct { + client *Client +} + +// ListBoardsResponse describes the boards response +type ListBoardsResponse struct { + Count *int `json:"count,omitempty"` + BoardReferences []*BoardReference `json:"value,omitempty"` +} + +// Board describes a board +type Board struct { + BoardReference + Links *map[string]Link `json:"_links,omitempty"` + AllowedMappings *string `json:"allowedMappings,omitempty"` + CanEdit *bool `json:"canEdit,omitempty"` + Columns []*BoardColumn `json:"columns,omitempty"` + Fields *BoardFields `json:"fields,omitempty"` + IsValid *bool `json:"isvalid,omitempty"` + Revision *int `json:"revision,omitempty"` + Rows []*BoardRow `json:"rows,omitempty"` +} + +// BoardColumn describes a column on the board +type BoardColumn struct { + ColumnType *string `json:"columnType,omitempty"` + Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + IsSplit *bool `json:"isSplit,omitempty"` + ItemLimit *int `json:"itemLimit,omitempty"` + Name *string `json:"name,omitempty"` + StateMappings map[string]string `json:"stateMappings,omitempty"` +} + +// BoardColumnType describes a column on the board +type BoardColumnType int + +// BoardColumnType Enum values +const ( + Incoming BoardColumnType = iota + InProgress + Outgoing +) + +func (d BoardColumnType) String() string { + return [...]string{"Incoming", "InProgress", "Outgoing"}[d] +} + +// BoardFields describes a column on the board +type BoardFields struct { + ColumnField *FieldReference `json:"columnField,omitempty"` + DoneField *FieldReference `json:"doneField,omitempty"` + RowField *FieldReference `json:"rowField,omitempty"` +} + +// BoardReference Base object a board +type BoardReference struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"URL,omitempty"` +} + +// BoardRow describes a row on the board +type BoardRow struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +// FieldReference describes a row on the board +type FieldReference struct { + ReferenceName *string `json:"referenceName,omitempty"` + URL *string `json:"url,omitempty"` +} + +// List returns list of the boards +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/boards/list +func (s *BoardsService) List(ctx context.Context, owner, project, team string) ([]*BoardReference, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/%s/_apis/work/boards?api-version=5.1-preview.1", + owner, + project, + url.PathEscape(team), + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(ListBoardsResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.BoardReferences, resp, err +} + +// Get returns a single board utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/boards/get +func (s *BoardsService) Get(ctx context.Context, owner, project, team, id string) (*Board, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/%s/_apis/work/boards/%s?api-version=5.1-preview.1", + owner, + project, + url.PathEscape(team), + id, + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(Board) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_controller.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_controller.go new file mode 100644 index 000000000..a3688c450 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_controller.go @@ -0,0 +1,14 @@ +package azuredevops + +// BuildController represents a controller of the build service +type BuildController struct { + CreatedDate *string `json:"createdDate"` + Description *string `json:"description"` + Enabled *bool `json:"enabled"` + ID *int `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + UpdateDate *string `json:"updateDate"` + URI *string `json:"uri"` + URL *string `json:"url"` +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_definitions.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_definitions.go new file mode 100644 index 000000000..624258479 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/build_definitions.go @@ -0,0 +1,64 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" +) + +// BuildDefinitionsService handles communication with the build definitions methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/build/definitions +type BuildDefinitionsService struct { + client *Client +} + +// BuildDefinitionsListResponse describes the build definitions list response +type BuildDefinitionsListResponse struct { + Count int `json:"count"` + BuildDefinitions []*BuildDefinition `json:"value"` +} + +// BuildRepository represents a repository used by a build definition +type BuildRepository struct { + ID *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"url,omitempty"` + RootFolder *string `json:"root_folder"` + Properties map[string]interface{} `json:"properties"` + Clean *string `json:"clean"` + DefaultBranch *string `json:"default_branch"` + CheckoutSubmodules *bool `json:"checkout_submodules"` +} + +// BuildDefinition represents a build definition +type BuildDefinition struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Repository *BuildRepository `json:"repository,omitempty"` +} + +// BuildDefinitionsListOptions describes what the request to the API should look like +type BuildDefinitionsListOptions struct { + Path *string `url:"path,omitempty"` + IncludeAllProperties *bool `url:"includeAllProperties,omitempty"` +} + +// List returns a list of build definitions +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/build/definitions/list +func (s *BuildDefinitionsService) List(ctx context.Context, owner string, project string, opts *BuildDefinitionsListOptions) ([]*BuildDefinition, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/build/definitions?api-version=5.1-preview.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(BuildDefinitionsListResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.BuildDefinitions, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/builds.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/builds.go new file mode 100644 index 000000000..f4dbab05d --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/builds.go @@ -0,0 +1,203 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" +) + +// BuildsService handles communication with the builds methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/build/builds +type BuildsService struct { + client *Client +} + +// BuildsListResponse is the wrapper around the main response for the List of Builds +type BuildsListResponse struct { + Count int `json:"count"` + Builds []*Build `json:"value"` +} + +// TaskOrchestrationPlanReference The orchestration plan for the build. +type TaskOrchestrationPlanReference struct { + Type *int `json:"orchestrationType,omitempty"` + PlanID *string `json:"planId,omitempty"` +} + +// Build Represents a build. +type Build struct { + Definition *BuildDefinition `json:"definition,omitempty"` + Controller *BuildController `json:"controller,omitempty"` + LastChangedBy *IdentityRef `json:"lastChangedBy,omitempty"` + DeletedBy *IdentityRef `json:"deletedBy,omitempty"` + BuildNumber *string `json:"buildNumber,omitempty"` + FinishTime *string `json:"finishTime,omitempty"` + SourceBranch *string `json:"sourceBranch,omitempty"` + Repository *BuildRepository `json:"repository,omitempty"` + Demands []*BuildDemand `json:"demands,omitempty"` + Logs *BuildLogReference `json:"logs,omitempty"` + Project *TeamProjectReference `json:"project,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Priority *string `json:"priority,omitempty"` + OrchestrationPlan *TaskOrchestrationPlanReference `json:"orchestrationPlan,omitempty"` + Plans []*TaskOrchestrationPlanReference `json:"plans,omitempty"` + BuildNumberRevision *int `json:"buildNumberRevision,omitempty"` + Deleted *bool `json:"deleted,omitempty"` + DeletedDate *string `json:"deletedDate,omitempty"` + DeletedReason *string `json:"deletedReason,omitempty"` + ID *int `json:"id,omitempty"` + KeepForever *bool `json:"keepForever,omitempty"` + ChangedDate *string `json:"lastChangedDate,omitempty"` + Params *string `json:"parameters,omitempty"` + Quality *string `json:"quality,omitempty"` + Queue *AgentPoolQueue `json:"queue,omitempty"` + QueueOptions map[string]string `json:"queue_options,omitempty"` + QueuePosition *int `json:"queuePosition,omitempty"` + QueueTime *string `json:"queueTime,omitempty"` + RetainedByRelease *bool `json:"retainedByRelease,omitempty"` + Version *string `json:"sourceVersion,omitempty"` + StartTime *string `json:"startTime,omitempty"` + Status *string `json:"status,omitempty"` + Result *string `json:"result,omitempty"` + ValidationResults []*ValidationResult `json:"validationResult,omitempty"` + Tags []*string `json:"tags,omitempty"` + TriggerBuild *Build `json:"triggeredByBuild,omitempty"` + URI *string `json:"uri,omitempty"` + URL *string `json:"url,omitempty"` + TriggerInfo *TriggerInfo `json:"triggerInfo,omitempty"` +} + +// AgentPoolQueue The queue. This is only set if the definition type is Build. +type AgentPoolQueue struct { + Links *map[string]Link `json:"_links,omitempty"` + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"url,omitempty"` + Pool *TaskAgentPoolReference `json:"pool,omitempty"` +} + +// TaskAgentPoolReference Represents a reference to an agent pool. +type TaskAgentPoolReference struct { + ID *int `json:"id,omitempty"` + IsHosted *bool `json:"is_hosted,omitempty"` + Name *string `json:"name,omitempty"` +} + +// TriggerInfo Source provider-specific information about what triggered the build. +type TriggerInfo struct { + CiSourceBranch *string `json:"ci.sourceBranch,omitempty"` + CiSourceSha *string `json:"ci.sourceSha,omitempty"` + CiMessage *string `json:"ci.message,omitempty"` +} + +// ValidationResult Represents the result of validating a build request. +type ValidationResult struct { + Message *string `json:"message,omitempty"` + Result *string `json:"result,omitempty"` +} + +// BuildDemand Represents a demand used by a definition or build. +type BuildDemand struct { + Name *string `json:"name,omitempty"` + Value *string `json:"value,omitempty"` +} + +// BuildListOrder is enum type for build list order +type BuildListOrder string + +const ( + // FinishTimeAscending orders by finish build time asc + FinishTimeAscending BuildListOrder = "finishTimeAscending" + // FinishTimeDescending orders by finish build time desc + FinishTimeDescending BuildListOrder = "finishTimeDescending" + // QueueTimeAscending orders by build queue time asc + QueueTimeAscending BuildListOrder = "queueTimeAscending" + // QueueTimeDescending orders by build queue time desc + QueueTimeDescending BuildListOrder = "queueTimeDescending" + // StartTimeAscending orders by build start time asc + StartTimeAscending BuildListOrder = "startTimeAscending" + // StartTimeDescending orders by build start time desc + StartTimeDescending BuildListOrder = "startTimeDescending" +) + +// BuildsListOptions describes what the request to the API should look like +type BuildsListOptions struct { + Definitions *string `url:"definitions,omitempty"` + Branch *string `url:"branchName,omitempty"` + Count *int `url:"$top,omitempty"` + Repository *string `url:"repositoryId,omitempty"` + BuildIDs *string `url:"buildIds,omitempty"` + Order *string `url:"queryOrder,omitempty"` + Deleted *string `url:"deletedFilter,omitempty"` + MaxPerDefinition *string `url:"maxBuildsPerDefinition,omitempty"` + Token *string `url:"continuationToken,omitempty"` + Props *string `url:"properties,omitempty"` + Tags *string `url:"tagFilters,omitempty"` + Result *string `url:"resultFilter,omitempty"` + Status *string `url:"statusFilter,omitempty"` + Reason *string `url:"reasonFilter,omitempty"` + UserID *string `url:"requestedFor,omitempty"` + MaxTime *string `url:"maxTime,omitempty"` + MinTime *string `url:"minTime,omitempty"` + BuildNumber *string `url:"buildNumber,omitempty"` + Queues *string `url:"queues,omitempty"` + RepoType *string `url:"repositoryType,omitempty"` +} + +// BuildLogReference Information about the build logs. +type BuildLogReference struct { + ID *int `json:"id"` + Type *string `json:"type"` + URL *string `json:"url"` +} + +// List returns list of the builds +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/build/builds/list +func (s *BuildsService) List(ctx context.Context, owner string, project string, opts *BuildsListOptions) ([]*Build, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/build/builds?api-version=5.1-preview.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(BuildsListResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.Builds, resp, err +} + +// QueueBuildOptions describes what the request to the API should look like +type QueueBuildOptions struct { + IgnoreWarnings bool `url:"ignoreWarnings,omitempty"` + CheckInTicket string `url:"checkInTicket,omitempty"` +} + +// Queue inserts new build creation to queue +// Requires build ID number in build.definition +// Example body: +// {"definition": {"id": 1}, "sourceBranch": "refs/heads/master"} +// utilising https://docs.microsoft.com/en-us/rest/api/azure/devops/build/Builds/Queue +func (s *BuildsService) Queue(ctx context.Context, owner string, project string, build *Build, opts *QueueBuildOptions) (*Build, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/build/builds?api-version=5.1-preview.5", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("POST", URL, build) + if err != nil { + return nil, nil, err + } + r := new(Build) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/debug.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/debug.go new file mode 100644 index 000000000..e0b24a1e0 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/debug.go @@ -0,0 +1,20 @@ +// +build debug + +package azuredevops + +import ( + "log" + "net/http" + "net/http/httputil" +) + +// Idea from: +// https://dave.cheney.net/2014/09/28/using-build-to-switch-between-debug-and-release +func debug(fmt string, args ...interface{}) { + log.Printf(fmt, args...) +} + +func debugReq(req *http.Request) { + dump, err := httputil.DumpRequest(req, true) + debug(string(dump), err) +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/delivery_plans.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/delivery_plans.go new file mode 100644 index 000000000..9f857568b --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/delivery_plans.go @@ -0,0 +1,109 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// DeliveryPlansService handles communication with the deliverytimeline methods on the API +// utilising https://docs.microsoft.com/en-us/rest/api/vsts/work/deliverytimeline +type DeliveryPlansService struct { + client *Client +} + +// DeliveryPlansListResponse describes the delivery plans list response +type DeliveryPlansListResponse struct { + Count int `json:"count"` + DeliveryPlans []*DeliveryPlan `json:"value"` +} + +// DeliveryPlanTimeLine describes the delivery plan get response +type DeliveryPlanTimeLine struct { + StartDate *string `json:"startDate,omitempty"` + EndDate *string `json:"endDate,omitempty"` + ID *string `json:"id,omitempty"` + Revision *int `json:"revision,omitempty"` + Teams []*DeliveryTeam `json:"teams,omitempty"` +} + +// DeliveryPlan describes an plan +type DeliveryPlan struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Created *string `json:"createdDate,omitempty"` + URL *string `json:"url,omitempty"` +} + +// DeliveryTeam describes the teams in a specific plan +type DeliveryTeam struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Iterations []*Iteration `json:"iterations,omitempty"` +} + +const ( + // DeliveryPlanWorkItemIDKey is the key for which part of the workItems[] slice has the ID + DeliveryPlanWorkItemIDKey = 0 + // DeliveryPlanWorkItemIterationKey is the key for which part of the workItems[] slice has the Iteration + DeliveryPlanWorkItemIterationKey = 1 + // DeliveryPlanWorkItemTypeKey is the key for which part of the workItems[] slice has the Type + DeliveryPlanWorkItemTypeKey = 2 + // DeliveryPlanWorkItemNameKey is the key for which part of the workItems[] slice has the Name + DeliveryPlanWorkItemNameKey = 4 + // DeliveryPlanWorkItemStatusKey is the key for which part of the workItems[] slice has the Status + DeliveryPlanWorkItemStatusKey = 5 + // DeliveryPlanWorkItemTagKey is the key for which part of the workItems[] slice has the Tag + DeliveryPlanWorkItemTagKey = 6 +) + +// DeliveryPlansListOptions describes what the request to the API should look like +type DeliveryPlansListOptions struct { +} + +// List returns a list of delivery plans +func (s *DeliveryPlansService) List(ctx context.Context, owner string, project string, opts *DeliveryPlansListOptions) ([]*DeliveryPlan, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/work/plans?api-version=5.1-preview.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(DeliveryPlansListResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.DeliveryPlans, resp, err +} + +// GetTimeLine will fetch the details about a specific delivery plan +func (s *DeliveryPlansService) GetTimeLine(ctx context.Context, owner string, project string, ID string, startDate, endDate string) (*DeliveryPlanTimeLine, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/work/plans/%s/deliverytimeline?api-version=5.1-preview.1", + owner, + project, + ID, + ) + + if startDate == "" { + startDate = time.Now().Format("2006-01-02") + // The 65 date thing is arbitrary from the API + endDate = time.Now().AddDate(0, 0, 65).Format("2006-01-02") + } + + URL = fmt.Sprintf(URL+"&startDate=%s&endDate=%s", startDate, endDate) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(DeliveryPlanTimeLine) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/doc.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/doc.go new file mode 100644 index 000000000..c9a785850 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/doc.go @@ -0,0 +1,5 @@ +/* +Package azuredevops is a Go client library for accessing the Azure DevOps API. +Installation +*/ +package azuredevops diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/event_types.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/event_types.go new file mode 100644 index 000000000..cc218e3da --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/event_types.go @@ -0,0 +1,49 @@ +// Copyright 2016 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. + +// Adapted to handle a subset of Pull Request webooks from Azure Devops +// Azure Devops Events docs: https://docs.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops + +package azuredevops + +// ItemContent describes an item +type ItemContent struct { + Content *string `json:"content,omitempty"` + ContentType *ItemContentType `json:"contentType,omitempty"` +} + +// ItemContentType describes an item content type +type ItemContentType struct { + Base64Encoded *string `json:"base64Encoded,omitempty"` + RawText *string `json:"rawText,omitempty"` +} + +// Link A single item in a collection of Links. +type Link struct { + Href *string `json:"href,omitempty"` +} + +// ResourceContainers provides information related to the Resources in a payload +type ResourceContainers struct { + Collection *ResourceRef `json:"text,omitempty"` + Account *ResourceRef `json:"html,omitempty"` + Project *ResourceRef `json:"markdown,omitempty"` +} + +// ResourceRef Describes properties to identify a resource +type ResourceRef struct { + ID *string `json:"id,omitempty"` + BaseURL *string `json:"baseUrl,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WebAPITagDefinition The representation of a tag definition which is sent across +// the wire. +type WebAPITagDefinition struct { + Active *bool `json:"active,omitempty"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"url,omitempty"` +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/events.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/events.go new file mode 100644 index 000000000..3516d2e0e --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/events.go @@ -0,0 +1,99 @@ +// Copyright 2018 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 azuredevops + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// Message represents an Azure Devops webhook message property +type Message struct { + Text *string `json:"text,omitempty"` + HTML *string `json:"html,omitempty"` + Markdown *string `json:"markdown,omitempty"` +} + +// Event - Describes an Azure Devops webhook payload parent +// Delay parsing Resource using *json.RawMessage +// until we know EventType. The contents of Resource change +// depending on EventType. +// PayloadType is filled with an enum that describes the type of resource +// payload. +type Event struct { + SubscriptionID string `json:"subscriptionId,omitempty"` + NotificationID int `json:"notificationId,omitempty"` + ID string `json:"id,omitempty"` + EventType string `json:"eventType,omitempty"` + Message Message `json:"message,omitempty"` + DetailedMessage Message `json:"detailedMessage,omitempty"` + RawPayload json.RawMessage `json:"resource,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` + ResourceContainers ResourceContainers `json:"resourceContainers,omitempty"` + CreatedDate time.Time `json:"createdDate,omitempty"` + Resource interface{} + PayloadType PayloadType +} + +// PayloadType Used to describe the event area +type PayloadType int + +const ( + // PullRequestEvent Resource field is parsed as a pull request event + PullRequestEvent PayloadType = iota + // PushEvent Git push service event + PushEvent + // WorkItemCommentedEvent Resource field is parsed as a work item + // comment event + WorkItemCommentedEvent + // WorkItemUpdatedEvent Resource field is parsed as a work item + // updated event + WorkItemUpdatedEvent +) + +// ParsePayload parses the event payload. For recognized event types, +// it returns the webhook payload with a parsed struct in the +// Event.Resource field. +func (e *Event) ParsePayload() (payload interface{}, err error) { + switch e.EventType { + case "git.pullrequest.created": + e.PayloadType = PullRequestEvent + payload = &GitPullRequest{} + case "git.pullrequest.merged": + e.PayloadType = PullRequestEvent + payload = &GitPullRequest{} + case "git.pullrequest.updated": + e.PayloadType = PullRequestEvent + payload = &GitPullRequest{} + case "git.push": + e.PayloadType = PushEvent + payload = &GitPush{} + case "workitem.commented": + e.PayloadType = WorkItemCommentedEvent + payload = &WorkItem{} + case "workitem.updated": + e.PayloadType = WorkItemUpdatedEvent + payload = &WorkItemUpdate{} + default: + return payload, errors.New("Unknown EventType in webhook payload") + } + + if len(e.RawPayload) == 0 { + msg := "JSON zero byte resource field in payload" + fmt.Printf("%s: %#v \n\n", msg, e) + return nil, errors.New(msg) + } + + err = json.Unmarshal(e.RawPayload, &payload) + if err != nil { + fmt.Printf("JSON unmarshal err: %#v \n\n %#v \n\n", payload, err) + return payload, err + } + e.Resource = payload + return payload, nil +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/favourites.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/favourites.go new file mode 100644 index 000000000..99383b717 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/favourites.go @@ -0,0 +1,47 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" +) + +// FavouritesService handles communication with the favourites methods on the API +// So far it looks like this is undocumented, so this could change +type FavouritesService struct { + client *Client +} + +// FavouritesResponse describes the favourites response +type FavouritesResponse struct { + Count int `json:"count"` + Favourites []*Favourite `json:"value"` +} + +// Favourite describes what a favourite is +type Favourite struct { + ID *string `json:"id,omitempty"` + ArtifactName *string `json:"artifactName,omitempty"` + ArtifactType *string `json:"artifactType,omitempty"` + ArtifactID *string `json:"artifactId,omitempty"` +} + +// List returns a list of the favourite items from for the user +func (s *FavouritesService) List(ctx context.Context, owner, project string) ([]*Favourite, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/Favorite/Favorites?artifactType=%s", + owner, + project, + "Microsoft.TeamFoundation.Git.Repository", // @todo This needs fixing + ) + + u, _ := s.client.BaseURL.Parse(URL) + req, err := s.client.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, nil, err + } + r := new(FavouritesResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.Favourites, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/fields.json b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/fields.json new file mode 100644 index 000000000..05d627109 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/fields.json @@ -0,0 +1,449 @@ +{ + "value" : [ + { + "helpText" : "The iteration within which this task will be completed", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationPath", + "alwaysRequired" : false, + "referenceName" : "System.IterationPath", + "defaultValue" : null, + "name" : "Iteration Path" + }, + { + "name" : "Iteration ID", + "alwaysRequired" : true, + "referenceName" : "System.IterationId", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationId" + }, + { + "referenceName" : "System.ExternalLinkCount", + "alwaysRequired" : false, + "name" : "External Link Count", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.ExternalLinkCount" + }, + { + "referenceName" : "System.IterationLevel7", + "name" : "Iteration Level 7", + "alwaysRequired" : false, + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel7" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel6", + "defaultValue" : null, + "referenceName" : "System.IterationLevel6", + "alwaysRequired" : false, + "name" : "Iteration Level 6" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel5", + "referenceName" : "System.IterationLevel5", + "alwaysRequired" : false, + "name" : "Iteration Level 5", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel4", + "alwaysRequired" : false, + "name" : "Iteration Level 4", + "referenceName" : "System.IterationLevel4", + "defaultValue" : null + }, + { + "defaultValue" : null, + "name" : "Iteration Level 3", + "alwaysRequired" : false, + "referenceName" : "System.IterationLevel3", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel3" + }, + { + "referenceName" : "System.IterationLevel2", + "alwaysRequired" : false, + "name" : "Iteration Level 2", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel2" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.IterationLevel1", + "defaultValue" : null, + "referenceName" : "System.IterationLevel1", + "name" : "Iteration Level 1", + "alwaysRequired" : false + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel7", + "referenceName" : "System.AreaLevel7", + "name" : "Area Level 7", + "alwaysRequired" : false, + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel6", + "defaultValue" : null, + "referenceName" : "System.AreaLevel6", + "alwaysRequired" : false, + "name" : "Area Level 6" + }, + { + "name" : "Area Level 5", + "alwaysRequired" : false, + "referenceName" : "System.AreaLevel5", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel5" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel4", + "alwaysRequired" : false, + "name" : "Area Level 4", + "referenceName" : "System.AreaLevel4", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel3", + "defaultValue" : null, + "name" : "Area Level 3", + "alwaysRequired" : false, + "referenceName" : "System.AreaLevel3" + }, + { + "defaultValue" : null, + "referenceName" : "System.AreaLevel2", + "name" : "Area Level 2", + "alwaysRequired" : false, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel2" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaLevel1", + "defaultValue" : null, + "alwaysRequired" : false, + "name" : "Area Level 1", + "referenceName" : "System.AreaLevel1" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.TeamProject", + "name" : "Team Project", + "alwaysRequired" : false, + "referenceName" : "System.TeamProject", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.RemoteLinkCount", + "defaultValue" : null, + "alwaysRequired" : false, + "name" : "Remote Link Count", + "referenceName" : "System.RemoteLinkCount" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.CommentCount", + "defaultValue" : null, + "referenceName" : "System.CommentCount", + "alwaysRequired" : false, + "name" : "Comment Count" + }, + { + "referenceName" : "System.HyperLinkCount", + "alwaysRequired" : false, + "name" : "Hyperlink Count", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.HyperLinkCount" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AttachedFileCount", + "alwaysRequired" : false, + "name" : "Attached File Count", + "referenceName" : "System.AttachedFileCount", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.NodeName", + "referenceName" : "System.NodeName", + "alwaysRequired" : false, + "name" : "Node Name", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaPath", + "helpText" : "The area of the product to which this task contributes", + "referenceName" : "System.AreaPath", + "alwaysRequired" : false, + "name" : "Area Path", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.RevisedDate", + "defaultValue" : null, + "name" : "Revised Date", + "alwaysRequired" : false, + "referenceName" : "System.RevisedDate" + }, + { + "referenceName" : "System.ChangedDate", + "alwaysRequired" : false, + "name" : "Changed Date", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.ChangedDate" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Id", + "defaultValue" : null, + "referenceName" : "System.Id", + "alwaysRequired" : false, + "name" : "ID" + }, + { + "referenceName" : "System.AreaId", + "alwaysRequired" : true, + "name" : "Area ID", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AreaId" + }, + { + "defaultValue" : null, + "referenceName" : "System.AuthorizedAs", + "name" : "Authorized As", + "alwaysRequired" : false, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AuthorizedAs" + }, + { + "defaultValue" : null, + "name" : "Title", + "referenceName" : "System.Title", + "alwaysRequired" : true, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Title", + "helpText" : "Work required and how this will implement a User Story" + }, + { + "name" : "State", + "defaultValue" : "New", + "helpText" : "New = New work not yet activated; Active = work remains to be done; Closed = tested and checked in.", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.State", + "alwaysRequired" : true, + "referenceName" : "System.State" + }, + { + "defaultValue" : null, + "name" : "Authorized Date", + "alwaysRequired" : false, + "referenceName" : "System.AuthorizedDate", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AuthorizedDate" + }, + { + "referenceName" : "System.Watermark", + "name" : "Watermark", + "alwaysRequired" : false, + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Watermark" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Rev", + "defaultValue" : null, + "name" : "Rev", + "alwaysRequired" : false, + "referenceName" : "System.Rev" + }, + { + "alwaysRequired" : false, + "name" : "Changed By", + "referenceName" : "System.ChangedBy", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.ChangedBy" + }, + { + "defaultValue" : null, + "name" : "Reason", + "alwaysRequired" : false, + "referenceName" : "System.Reason", + "helpText" : "The reason why the task is in its current state", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Reason" + }, + { + "helpText" : "The person currently working on this task", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.AssignedTo", + "referenceName" : "System.AssignedTo", + "alwaysRequired" : false, + "name" : "Assigned To", + "defaultValue" : null + }, + { + "defaultValue" : null, + "name" : "Work Item Type", + "alwaysRequired" : false, + "referenceName" : "System.WorkItemType", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.WorkItemType" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.CreatedDate", + "defaultValue" : null, + "referenceName" : "System.CreatedDate", + "alwaysRequired" : false, + "name" : "Created Date" + }, + { + "defaultValue" : null, + "alwaysRequired" : false, + "name" : "Created By", + "referenceName" : "System.CreatedBy", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.CreatedBy" + }, + { + "helpText" : "What to do, pointers to resources and inputs, design notes, exit criteria", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Description", + "alwaysRequired" : false, + "referenceName" : "System.Description", + "defaultValue" : null, + "name" : "Description" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.History", + "helpText" : "Discussion thread plus automatic record of changes", + "referenceName" : "System.History", + "alwaysRequired" : false, + "name" : "History", + "defaultValue" : null + }, + { + "defaultValue" : null, + "name" : "Related Link Count", + "alwaysRequired" : false, + "referenceName" : "System.RelatedLinkCount", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.RelatedLinkCount" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.Tags", + "referenceName" : "System.Tags", + "name" : "Tags", + "alwaysRequired" : false, + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.BoardColumn", + "alwaysRequired" : false, + "name" : "Board Column", + "referenceName" : "System.BoardColumn", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.BoardColumnDone", + "defaultValue" : null, + "referenceName" : "System.BoardColumnDone", + "alwaysRequired" : false, + "name" : "Board Column Done" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/System.BoardLane", + "alwaysRequired" : false, + "name" : "Board Lane", + "referenceName" : "System.BoardLane", + "defaultValue" : null + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.Activity", + "helpText" : "Type of work involved", + "referenceName" : "Microsoft.VSTS.Common.Activity", + "alwaysRequired" : false, + "name" : "Activity", + "defaultValue" : null + }, + { + "defaultValue" : null, + "name" : "Remaining Work", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Scheduling.RemainingWork", + "helpText" : "An estimate of the number of units of work remaining to complete this task", + "referenceName" : "Microsoft.VSTS.Scheduling.RemainingWork", + "alwaysRequired" : false + }, + { + "alwaysRequired" : false, + "referenceName" : "Microsoft.VSTS.Scheduling.OriginalEstimate", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Scheduling.OriginalEstimate", + "helpText" : "Initial value for Remaining Work - set once, when work begins", + "name" : "Original Estimate", + "defaultValue" : null + }, + { + "referenceName" : "Microsoft.VSTS.Scheduling.CompletedWork", + "alwaysRequired" : false, + "helpText" : "The number of units of work that have been spent on this task", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Scheduling.CompletedWork", + "defaultValue" : null, + "name" : "Completed Work" + }, + { + "alwaysRequired" : false, + "referenceName" : "Microsoft.VSTS.Common.Priority", + "helpText" : "Importance to business", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.Priority", + "name" : "Priority", + "defaultValue" : "2" + }, + { + "defaultValue" : null, + "name" : "Stack Rank", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.StackRank", + "helpText" : "Work first on items with lower-valued stack rank. Set in triage.", + "referenceName" : "Microsoft.VSTS.Common.StackRank", + "alwaysRequired" : false + }, + { + "defaultValue" : null, + "name" : "Start Date", + "helpText" : "The date to start the task", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Scheduling.StartDate", + "alwaysRequired" : false, + "referenceName" : "Microsoft.VSTS.Scheduling.StartDate" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Scheduling.FinishDate", + "helpText" : "The date to finish the task", + "alwaysRequired" : false, + "referenceName" : "Microsoft.VSTS.Scheduling.FinishDate", + "defaultValue" : null, + "name" : "Finish Date" + }, + { + "defaultValue" : null, + "referenceName" : "Microsoft.VSTS.Common.StateChangeDate", + "alwaysRequired" : false, + "name" : "State Change Date", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.StateChangeDate" + }, + { + "alwaysRequired" : false, + "name" : "Activated Date", + "referenceName" : "Microsoft.VSTS.Common.ActivatedDate", + "defaultValue" : null, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.ActivatedDate" + }, + { + "defaultValue" : null, + "alwaysRequired" : false, + "name" : "Activated By", + "referenceName" : "Microsoft.VSTS.Common.ActivatedBy", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.ActivatedBy" + }, + { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.ClosedDate", + "referenceName" : "Microsoft.VSTS.Common.ClosedDate", + "alwaysRequired" : false, + "name" : "Closed Date", + "defaultValue" : null + }, + { + "defaultValue" : null, + "referenceName" : "Microsoft.VSTS.Common.ClosedBy", + "name" : "Closed By", + "alwaysRequired" : false, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Common.ClosedBy" + }, + { + "helpText" : "The build in which the bug was fixed", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/wit/fields/Microsoft.VSTS.Build.IntegrationBuild", + "referenceName" : "Microsoft.VSTS.Build.IntegrationBuild", + "alwaysRequired" : false, + "name" : "Integration Build", + "defaultValue" : null + } + ], + "count" : 61 +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/gen-accessors.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/gen-accessors.go new file mode 100644 index 000000000..fe92206fc --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/gen-accessors.go @@ -0,0 +1,332 @@ +// Copyright 2017 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. + +// +build ignore + +// gen-accessors generates accessor methods for structs with pointer fields. +// +// It is meant to be used by the go-github authors in conjunction with the +// go generate tool before sending a commit to GitHub. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "io/ioutil" + "log" + "os" + "sort" + "strings" + "text/template" +) + +const ( + fileSuffix = "-accessors.go" +) + +var ( + verbose = flag.Bool("v", false, "Print verbose log messages") + + sourceTmpl = template.Must(template.New("source").Parse(source)) + + // blacklistStructMethod lists "struct.method" combos to skip. + blacklistStructMethod = map[string]bool{ + "RepositoryContent.GetContent": true, + "Client.GetBaseURL": true, + "Client.GetUploadURL": true, + "ErrorResponse.GetResponse": true, + "RateLimitError.GetResponse": true, + "AbuseRateLimitError.GetResponse": true, + } + // blacklistStruct lists structs to skip. + blacklistStruct = map[string]bool{ + "Client": true, + } +) + +func logf(fmt string, args ...interface{}) { + if *verbose { + log.Printf(fmt, args...) + } +} + +func main() { + flag.Parse() + fset := token.NewFileSet() + + pkgs, err := parser.ParseDir(fset, ".", sourceFilter, 0) + if err != nil { + log.Fatal(err) + return + } + + for pkgName, pkg := range pkgs { + t := &templateData{ + filename: pkgName + fileSuffix, + Year: 2017, + Package: pkgName, + Imports: map[string]string{}, + } + for filename, f := range pkg.Files { + logf("Processing %v...", filename) + if err := t.processAST(f); err != nil { + log.Fatal(err) + } + } + if err := t.dump(); err != nil { + log.Fatal(err) + } + } + logf("Done.") +} + +func (t *templateData) processAST(f *ast.File) error { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + // Skip unexported identifiers. + if !ts.Name.IsExported() { + logf("Struct %v is unexported; skipping.", ts.Name) + continue + } + // Check if the struct is blacklisted. + if blacklistStruct[ts.Name.Name] { + logf("Struct %v is blacklisted; skipping.", ts.Name) + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + for _, field := range st.Fields.List { + se, ok := field.Type.(*ast.StarExpr) + if len(field.Names) == 0 || !ok { + continue + } + + fieldName := field.Names[0] + // Skip unexported identifiers. + if !fieldName.IsExported() { + logf("Field %v is unexported; skipping.", fieldName) + continue + } + // Check if "struct.method" is blacklisted. + if key := fmt.Sprintf("%v.Get%v", ts.Name, fieldName); blacklistStructMethod[key] { + logf("Method %v is blacklisted; skipping.", key) + continue + } + + switch x := se.X.(type) { + case *ast.ArrayType: + t.addArrayType(x, ts.Name.String(), fieldName.String()) + case *ast.Ident: + t.addIdent(x, ts.Name.String(), fieldName.String()) + case *ast.MapType: + t.addMapType(x, ts.Name.String(), fieldName.String()) + case *ast.SelectorExpr: + t.addSelectorExpr(x, ts.Name.String(), fieldName.String()) + default: + logf("processAST: type %q, field %q, unknown %T: %+v", ts.Name, fieldName, x, x) + } + } + } + } + return nil +} + +func sourceFilter(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") && !strings.HasSuffix(fi.Name(), fileSuffix) +} + +func (t *templateData) dump() error { + if len(t.Getters) == 0 { + logf("No getters for %v; skipping.", t.filename) + return nil + } + + // Sort getters by ReceiverType.FieldName. + sort.Sort(byName(t.Getters)) + + var buf bytes.Buffer + if err := sourceTmpl.Execute(&buf, t); err != nil { + return err + } + clean, err := format.Source(buf.Bytes()) + if err != nil { + return err + } + + logf("Writing %v...", t.filename) + return ioutil.WriteFile(t.filename, clean, 0644) +} + +func newGetter(receiverType, fieldName, fieldType, zeroValue string, namedStruct bool) *getter { + return &getter{ + sortVal: strings.ToLower(receiverType) + "." + strings.ToLower(fieldName), + ReceiverVar: strings.ToLower(receiverType[:1]), + ReceiverType: receiverType, + FieldName: fieldName, + FieldType: fieldType, + ZeroValue: zeroValue, + NamedStruct: namedStruct, + } +} + +func (t *templateData) addArrayType(x *ast.ArrayType, receiverType, fieldName string) { + var eltType string + switch elt := x.Elt.(type) { + case *ast.Ident: + eltType = elt.String() + default: + logf("addArrayType: type %q, field %q: unknown elt type: %T %+v; skipping.", receiverType, fieldName, elt, elt) + return + } + + t.Getters = append(t.Getters, newGetter(receiverType, fieldName, "[]"+eltType, "nil", false)) +} + +func (t *templateData) addIdent(x *ast.Ident, receiverType, fieldName string) { + var zeroValue string + var namedStruct = false + switch x.String() { + case "int", "int64": + zeroValue = "0" + case "string": + zeroValue = `""` + case "bool": + zeroValue = "false" + case "Timestamp": + zeroValue = "Timestamp{}" + default: + zeroValue = "nil" + namedStruct = true + } + + t.Getters = append(t.Getters, newGetter(receiverType, fieldName, x.String(), zeroValue, namedStruct)) +} + +func (t *templateData) addMapType(x *ast.MapType, receiverType, fieldName string) { + var keyType string + switch key := x.Key.(type) { + case *ast.Ident: + keyType = key.String() + default: + logf("addMapType: type %q, field %q: unknown key type: %T %+v; skipping.", receiverType, fieldName, key, key) + return + } + + var valueType string + switch value := x.Value.(type) { + case *ast.Ident: + valueType = value.String() + default: + logf("addMapType: type %q, field %q: unknown value type: %T %+v; skipping.", receiverType, fieldName, value, value) + return + } + + fieldType := fmt.Sprintf("map[%v]%v", keyType, valueType) + zeroValue := fmt.Sprintf("map[%v]%v{}", keyType, valueType) + t.Getters = append(t.Getters, newGetter(receiverType, fieldName, fieldType, zeroValue, false)) +} + +func (t *templateData) addSelectorExpr(x *ast.SelectorExpr, receiverType, fieldName string) { + if strings.ToLower(fieldName[:1]) == fieldName[:1] { // Non-exported field. + return + } + + var xX string + if xx, ok := x.X.(*ast.Ident); ok { + xX = xx.String() + } + + switch xX { + case "time", "json": + if xX == "json" { + t.Imports["encoding/json"] = "encoding/json" + } else { + t.Imports[xX] = xX + } + fieldType := fmt.Sprintf("%v.%v", xX, x.Sel.Name) + zeroValue := fmt.Sprintf("%v.%v{}", xX, x.Sel.Name) + if xX == "time" && x.Sel.Name == "Duration" { + zeroValue = "0" + } + t.Getters = append(t.Getters, newGetter(receiverType, fieldName, fieldType, zeroValue, false)) + default: + logf("addSelectorExpr: xX %q, type %q, field %q: unknown x=%+v; skipping.", xX, receiverType, fieldName, x) + } +} + +type templateData struct { + filename string + Year int + Package string + Imports map[string]string + Getters []*getter +} + +type getter struct { + sortVal string // Lower-case version of "ReceiverType.FieldName". + ReceiverVar string // The one-letter variable name to match the ReceiverType. + ReceiverType string + FieldName string + FieldType string + ZeroValue string + NamedStruct bool // Getter for named struct. +} + +type byName []*getter + +func (b byName) Len() int { return len(b) } +func (b byName) Less(i, j int) bool { return b[i].sortVal < b[j].sortVal } +func (b byName) Swap(i, j int) { b[i], b[j] = b[j], b[i] } + +const source = `// Copyright {{.Year}} 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. + +// Code generated by gen-accessors; DO NOT EDIT. + +package {{.Package}} +{{with .Imports}} +import ( + {{- range . -}} + "{{.}}" + {{end -}} +) +{{end}} +{{range .Getters}} +{{if .NamedStruct}} +// Get{{.FieldName}} returns the {{.FieldName}} field. +func ({{.ReceiverVar}} *{{.ReceiverType}}) Get{{.FieldName}}() *{{.FieldType}} { + if {{.ReceiverVar}} == nil { + return {{.ZeroValue}} + } + return {{.ReceiverVar}}.{{.FieldName}} +} +{{else}} +// Get{{.FieldName}} returns the {{.FieldName}} field if it's non-nil, zero value otherwise. +func ({{.ReceiverVar}} *{{.ReceiverType}}) Get{{.FieldName}}() {{.FieldType}} { + if {{.ReceiverVar}} == nil || {{.ReceiverVar}}.{{.FieldName}} == nil { + return {{.ZeroValue}} + } + return *{{.ReceiverVar}}.{{.FieldName}} +} +{{end}} +{{end}} +` diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/git.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/git.go new file mode 100644 index 000000000..c118ae8f3 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/git.go @@ -0,0 +1,481 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +// VersionControlChangeType enum declaration +type VersionControlChangeType int + +// VersionControlChangeType valid enum values +const ( + None VersionControlChangeType = iota + Add + Edit + Encoding + Rename + Delete + Undelete + Branch + Merge + Lock + Rollback + SourceRename + TargetRename + Property + All +) + +func (d VersionControlChangeType) String() string { + return [...]string{"none", "add", "edit", "encoding", "rename", "delete", "undelete", "branch", "merge", "lock", "rollback", "sourceRename", "targetRename", "property", "all"}[d] +} + +// GitObjectType enum declaration +type GitObjectType int + +// GitObjectType enum declaration +const ( + Bad GitObjectType = iota + Commit + Tree + Blob + Tag + Ext2 + OfsDelta + RefDelta +) + +func (d GitObjectType) String() string { + return [...]string{"bad", "commit", "tree", "blob", "tag", "ext2", "ofsDelta", "refDelta"}[d] +} + +// GitService handles communication with the git methods on the API +// See: https://docs.microsoft.com/en-us/rest/api/vsts/git/ +type GitService struct { + client *Client +} + +// FileContentMetadata Describes files referenced by a GitItem +type FileContentMetadata struct { + ContentType *string `json:"contentType,omitempty"` + Encoding *int `json:"encoding,omitempty"` + Extension *string `json:"extension,omitempty"` + FileName *string `json:"fileName,omitempty"` + IsBinary *bool `json:"isBinary,omitempty"` + IsImage *bool `json:"isImage,omitempty"` + VSLink *string `json:"vsLink,omitempty"` +} + +// GitRefsResponse describes the git list refs response +type GitRefsResponse struct { + Count int `json:"count"` + GitRefs []*GitRef `json:"value"` +} + +// GitRefsUpdateResponse describes the git list refs update response +type GitRefsUpdateResponse struct { + Count int `json:"count"` + GitRefsUpdate []*GitRefUpdate `json:"value"` +} + +// UpdateRefsBody Request body for UpdateRefs() +type UpdateRefsBody struct { + IsLocked *bool `json:"isLocked,omitempty"` + Name *string `json:"name,omitempty"` + NewObjectID *string `json:"newObjectId,omitempty"` + OldObjectID *string `json:"oldObjectId,omitempty"` + RepositoryID *string `json:"repositoryId,omitempty"` +} + +// GitStatusesResponse describes the git statuses response +type GitStatusesResponse struct { + Count int `json:"count"` + GitStatuses []*GitStatus `json:"value"` +} + +// GitChange describes file path and content changes +type GitChange struct { + ChangeID *int `json:"changeId,omitempty"` + ChangeType *string `json:"changeType,omitempty"` + Item *GitItem `json:"item,omitempty"` + NewContent *ItemContent `json:"newContent,omitempty"` + NewContentTemplate *GitTemplate `json:"newContentTemplate,omitempty"` + OriginalPath *string `json:"originalPath,omitempty"` + SourceServerItem *string `json:"sourceServerItem,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GitCommitChanges is a list of GitCommitRefs and count of all changes describes in +// the response from the API +type GitCommitChanges struct { + ChangeCounts *map[string]int `json:"changeCounts,omitempty"` + Changes []*GitChange `json:"changes,omitempty"` +} + +// GitCommitRef describes a single git commit reference +type GitCommitRef struct { + Links *map[string]Link `json:"_links,omitempty"` + CommitID *string `json:"commitId,omitempty"` + Author *GitUserDate `json:"author,omitempty"` + Committer *GitUserDate `json:"committer,omitempty"` + Comment *string `json:"comment,omitempty"` + CommentTruncated *bool `json:"commentTruncated,omitempty"` + URL *string `json:"url,omitempty"` + ChangeCounts *map[string]int `json:"changeCounts,omitempty"` + Changes *GitChange `json:"changes,omitempty"` + Parents []*string `json:"parents,omitempty"` + Push *GitPushRef `json:"push,omitempty"` + RemoteURL *string `json:"remoteUrl,omitempty"` + Statuses []*GitStatus `json:"statuses,omitempty"` + WorkItems *ResourceRef `json:"workItems,omitempty"` +} + +// GitRef provides information about a git/fork ref. +type GitRef struct { + Links *map[string]Link `json:"_links,omitempty"` + Creator *IdentityRef `json:"creator,omitempty"` + IsLocked *bool `json:"isLocked,omitempty"` + IsLockedBy *IdentityRef `json:"isLockedBy,omitempty"` + Name *string `json:"name,omitempty"` + ObjectID *string `json:"objectId,omitempty"` + PeeledObjectID *string `json:"peeledObjectId,omitempty"` + Repository *GitRepository `json:"repository,omitempty"` + Statuses []*GitStatus `json:"statuses,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GitItem describes a single git item +type GitItem struct { + Links *map[string]Link `json:"_links,omitempty"` + CommitID *string `json:"commitId,omitempty"` + Content *string `json:"content,omitempty"` + ContentMetadata *FileContentMetadata `json:"contentMetadata,omitempty"` + GitObjectType *string `json:"gitObjectType,omitempty"` + IsFolder *bool `json:"isFolder,omitempty"` + IsSymLink *bool `json:"isSymLink,omitempty"` + LatestProcessedChange *GitCommitRef `json:"latestProcessedChange,omitempty"` + ObjectID *string `json:"objectId,omitempty"` + OriginalObjectID *string `json:"originalObjectId,omitempty"` + Path *string `json:"path,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GitPullRequest represents all the data associated with a pull request. +type GitPullRequest struct { + Links *map[string]Link `json:"_links,omitempty"` + ArtifactID *string `json:"artifactId,omitempty"` + AutoCompleteSetBy *IdentityRef `json:"autoCompleteSetBy,omitempty"` + ClosedBy *IdentityRef `json:"closedBy,omitempty"` + ClosedDate *time.Time `json:"closedDate,omitempty"` + CodeReviewID *int `json:"codeReviewId,omitempty"` + Commits []*GitCommitRef `json:"commits,omitempty"` + CompletionOptions *GitPullRequestCompletionOptions `json:"completionOptions,omitempty"` + CompletionQueueTime *time.Time `json:"completionQueueTime, omitempty"` + CreatedBy *IdentityRef `json:"createdBy,omitempty"` + CreationDate *time.Time `json:"creationDate,omitempty"` + Description *string `json:"description,omitempty"` + ForkSource *GitRef `json:"forkSource,omitempty"` + IsDraft *bool `json:"isDraft,omitempty"` + Labels []*WebAPITagDefinition `json:"labels,omitempty"` + LastMergeCommit *GitCommitRef `json:"lastMergeCommit,omitempty"` + LastMergeSourceCommit *GitCommitRef `json:"lastMergeSourceCommit,omitempty"` + LastMergeTargetCommit *GitCommitRef `json:"lastMergeTargetCommit,omitempty"` + MergeFailureMessage *string `json:"mergeFailureMessage,omitempty"` + MergeFailureType *string `json:"mergeFailureType,omitempty"` + MergeID *string `json:"mergeId,omitempty"` + MergeOptions *GitPullRequestMergeOptions `json:"mergeOptions,omitempty"` + MergeStatus *string `json:"mergeStatus,omitempty"` + PullRequestID *int `json:"pullRequestId,omitempty"` + Repository *GitRepository `json:"repository,omitempty"` + Reviewers []*IdentityRefWithVote `json:"reviewers,omitempty"` + RemoteURL *string `json:"remoteUrl,omitempty"` + SourceRefName *string `json:"sourceRefName,omitempty"` + Status *string `json:"status,omitempty"` + SupportsIterations *bool `json:"supportsIterations,omitempty"` + TargetRefName *string `json:"targetRefName,omitempty"` + Title *string `json:"title,omitempty"` + URL *string `json:"url,omitempty"` + WorkItemRefs []*ResourceRef `json:"workItemRefs,omitempty"` +} + +// GitPullRequestCompletionOptions describes preferences about how the pull +// request should be completed. +// SquashMerge is deprecated. You should explicity set the value of MergeStrategy. If +// MergeStrategy is set to any value, the SquashMerge value will be ignored. If +// MergeStrategy is not set, the merge strategy will be no-fast-forward if this flag is false, or squash if true. +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/update?view=azure-devops-rest-5.1#pullrequeststatus +type GitPullRequestCompletionOptions struct { + BypassPolicy *bool `json:"bypassPolicy,omitempty"` + BypassReason *string `json:"bypassReason,omitempty"` + DeleteSourceBranch *bool `json:"deleteSourceBranch,omitempty"` + MergeCommitMessage *string `json:"mergeCommitMessage,omitempty"` + MergeStrategy *string `json:"mergeStrategy,omitempty"` + SquashMerge *bool `json:"squashMerge,omitempty"` + TransitionWorkItems *bool `json:"transitionWorkItems,omitempty"` + TriggeredByAutoComplete *bool `json:"triggeredByAutoComplete,omitempty"` +} + +// GitPullRequestMergeOptions describes the options which are used when a pull +// request merge is created. +type GitPullRequestMergeOptions struct { + DetectRenameFalsePositives *bool `json:"detectRenameFalsePositives,omitempty"` + DisableRenames *bool `json:"disableRenames,omitempty"` +} + +// GitPullRequestMergeStrategy specifies the strategy used to merge the pull request +// during completion. +type GitPullRequestMergeStrategy int + +// GitPullRequestMergeStrategy enum values +const ( + NoFastForward GitPullRequestMergeStrategy = iota + Rebase + SebaseMerge + Squash +) + +func (d GitPullRequestMergeStrategy) String() string { + return [...]string{"noFastForward", "rebase", "rebaseMerge", "squash"}[d] +} + +// GitPush describes a code push request event. +type GitPush struct { + Links *map[string]Link `json:"_links,omitempty"` + Commits []*GitCommitRef `json:"commits,omitempty"` + Date *time.Time `json:"date,omitempty"` + PushID *int `json:"pushId,omitempty"` + PushedBy *IdentityRef `json:"pushedBy,omitempty"` + RefUpdates []*GitRefUpdate `json:"refUpdates,omitempty"` + Repository *GitRepository `json:"repository,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GitPushRef Describes a push request +type GitPushRef struct { + Commits []*GitCommitRef `json:"commits,omitempty"` + RefUpdates []*GitRefUpdate `json:"refUpdates,omitempty"` + Repository *GitRepository `json:"repository,omitempty"` +} + +// GitRefUpdate Describes a ref update +type GitRefUpdate struct { + IsLocked *bool `json:"isLocked,omitempty"` + Name *string `json:"name,omitempty"` + NewObjectID *string `json:"newObjectId,omitempty"` + OldObjectID *string `json:"oldObjectId,omitempty"` + RepositoryID *string `json:"repositoryId,omitempty"` +} + +// GitRepository describes an Azure Devops Git repository. +type GitRepository struct { + Links *map[string]Link `json:"_links,omitempty"` + DefaultBranch *string `json:"defaultBranch,omitempty"` + ID *string `json:"id,omitempty"` + IsFork *bool `json:"isFork,omitempty"` + Name *string `json:"name,omitempty"` + ParentRepository *GitRepositoryRef `json:"parentRepository,omitempty"` + Project *TeamProjectReference `json:"project,omitempty"` + RemoteURL *string `json:"remoteUrl,omitempty"` + Size *int `json:"size,omitempty"` + SSHURL *string `json:"sshUrl,omitempty"` + URL *string `json:"url,omitempty"` + ValidRemoteURLs []*string `json:"validRemoteUrls,omitempty"` + WebURL *string `json:"webUrl,omitempty"` +} + +// GitRepositoryRef describes a repository ref +type GitRepositoryRef struct { + Collection *TeamProjectCollectionReference `json:"collection,omitempty"` + ID *string `json:"id,omitempty"` + IsFork *bool `json:"isFork,omitempty"` + Name *string `json:"name,omitempty"` + Project *TeamProjectReference `json:"project,omitempty"` + RemoteURL *string `json:"remoteUrl,omitempty"` + SSHURL *string `json:"sshUrl,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GitStatusState contains the metadata of a service/extension posting a status. +type GitStatusState int + +// GitStatusState enum values +const ( + GitNotSet GitStatusState = iota + GitPending + GitSucceeded + GitFailed + GitError + GitNotApplicable +) + +func (d GitStatusState) String() string { + return [...]string{"notSet", "pending", "succeeded", "failed", "error", "notApplicable"}[d] +} + +// GitStatus describes a git status entity +type GitStatus struct { + Links *map[string]Link `json:"_links,omitempty"` + Context *GitStatusContext `json:"context,omitempty"` + CreatedBy *IdentityRef `json:"createdBy,omitempty"` + CreationDate *time.Time `json:"creationDate,omitempty"` + Description *string `json:"description,omitempty"` + ID *int `json:"id,omitempty"` + State *string `json:"state,omitempty"` + TargetURL *string `json:"targetUrl,omitempty"` + UpdatedDate *time.Time `json:"updatedDate,omitempty"` +} + +// GitPullRequestStatus This class contains the metadata of a service/extension +// posting pull request status. Status can be associated with a pull request or +// an iteration. +type GitPullRequestStatus struct { + GitStatus + IterationID int `json:"iterationId,omitempty"` + Properties *time.Time `json:"properties,omitempty"` +} + +// GitRefListOptions describes what the request to the API should look like +type GitRefListOptions struct { + Filter string `url:"filter,omitempty"` + IncludeStatuses bool `url:"includeStatuses,omitempty"` + LatestStatusesOnly bool `url:"latestStatusesOnly,omitempty"` +} + +// GitStatusContext Status context that uniquely identifies the status. +type GitStatusContext struct { + Genre *string `json:"genre,omitempty"` + Name *string `json:"name,omitempty"` +} + +// GitTemplate describes a git template +type GitTemplate struct { + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` +} + +// GitUserDate User info and date for Git operations. +type GitUserDate struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Date *time.Time `json:"date,omitempty"` +} + +// UpdateRefs returns a list of the references for a git repo +func (s *GitService) UpdateRefs(ctx context.Context, owner, project, repo, refType string, opts *GitRefListOptions) ([]*GitRef, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/git/repositories/%s/refs/%s?api-version=5.1-preview.1", + owner, + project, + repo, + refType, + ) + + URL, err := addOptions(URL, opts) + + body := &UpdateRefsBody{ + IsLocked: Bool(false), + Name: String("refs/heads/vsts-api-sample/answer-woman-flame"), + NewObjectID: String(""), + OldObjectID: String(""), + RepositoryID: String(repo), + } + + req, err := s.client.NewRequest("POST", URL, body) + if err != nil { + return nil, nil, err + } + r := new(GitRefsResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.GitRefs, resp, err +} + +// ListRefs returns a list of the references for a git repo +func (s *GitService) ListRefs(ctx context.Context, owner, project, repo, refType string, opts *GitRefListOptions) ([]*GitRef, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/git/repositories/%s/refs/%s?api-version=5.1-preview.1", + owner, + project, + repo, + refType, + ) + + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(GitRefsResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.GitRefs, resp, err +} + +// GetRepository Return a single GitRepository +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/repositories/get%20repository?view=azure-devops-rest-5.1 +func (s *GitService) GetRepository(ctx context.Context, owner, project, repoName string) (*GitRepository, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/git/repositories/%s?api-version=5.1-preview.1", + owner, + project, + repoName, + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(GitRepository) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err + +} + +// GetChanges Return a single GitRepository +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20changes?view=azure-devops-rest-5.1 +func (s *GitService) GetChanges(ctx context.Context, owner, project, repoName, commitID string) (*GitCommitChanges, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/git/repositories/%s/commits/%s/changes?api-version=5.1-preview.1", + owner, + project, + repoName, + commitID, + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(GitCommitChanges) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// CreateStatus creates a new status for a repository at the specified +// reference. Ref can be a SHA, a branch name, or a tag name. +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/statuses/create?view=azure-devops-rest-5.0 +func (s *GitService) CreateStatus(ctx context.Context, owner, project, repoName, ref string, status GitStatus) (*GitStatus, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/git/repositories/%s/commits/%s/statuses?api-version=5.1-preview.1", + owner, + project, + repoName, + url.QueryEscape(ref), + ) + + req, err := s.client.NewRequest("POST", URL, status) + if err != nil { + return nil, nil, err + } + r := new(GitStatus) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/identity_ref.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/identity_ref.go new file mode 100644 index 000000000..14fd976a1 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/identity_ref.go @@ -0,0 +1,27 @@ +package azuredevops + +// IdentityRef describes an Azure Devops identity +type IdentityRef struct { + Links *map[string]Link `json:"_links,omitempty"` + Descriptor *string `json:"descriptor,omitempty"` + DirectoryAlias *string `json:"directoryAlias,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + ID *string `json:"id,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` + Inactive *bool `json:"inactive,omitempty"` + IsAadIdentity *bool `json:"isAadIdentity,omitempty"` + IsContainer *bool `json:"isContainer,omitempty"` + IsDeletedInOrigin *bool `json:"isDeletedInOrigin,omitempty"` + ProfileURL *string `json:"profileUrl,omitempty"` + URL *string `json:"url,omitempty"` + UniqueName *string `json:"uniqueName,omitempty"` +} + +// IdentityRefWithVote Identity information including a vote on a pull request. +type IdentityRefWithVote struct { + IdentityRef + IsRequired *bool `json:"isRequired,omitempty"` + ReviewerURL *string `json:"reviewerUrl,omitempty"` + Vote *int `json:"vote,omitempty"` + VotedFor []*IdentityRefWithVote `json:"votedFor,omitempty"` +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/iterations.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/iterations.go new file mode 100644 index 000000000..001f0be47 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/iterations.go @@ -0,0 +1,69 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// IterationsService handles communication with the work items methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/iterations +type IterationsService struct { + client *Client +} + +// IterationsResponse describes the iterations response +type IterationsResponse struct { + Count int `json:"count,omitempty"` + Iterations []*Iteration `json:"value,omitempty"` +} + +// Iteration describes an iteration +type Iteration struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + URL *string `json:"url,omitempty"` + StartDate *string `json:"startDate,omitempty"` + EndDate *string `json:"finishDate,omitempty"` + WorkItems [][]interface{} `json:"workItems,omitempty"` +} + +// List returns list of the iterations available to the user +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/iterations/list +func (s *IterationsService) List(ctx context.Context, owner, project, team string) ([]*Iteration, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/%s/_apis/work/teamsettings/iterations?api-version=5.1-preview.1", + owner, + project, + url.PathEscape(team), + ) + + request, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(IterationsResponse) + resp, err := s.client.Execute(ctx, request, &r) + + return r.Iterations, resp, err +} + +// GetByName will search the iterations for the account and project +// and return a single iteration if the names match +func (s *IterationsService) GetByName(ctx context.Context, owner, project, team string, name string) (*Iteration, *http.Response, error) { + iterations, resp, err := s.List(ctx, owner, project, team) + if err != nil { + return nil, nil, err + } + + for index := 0; index < len(iterations); index++ { + if name == *iterations[index].Name { + iteration := iterations[index] + return iteration, resp, nil + } + } + + return nil, nil, nil +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/messages.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/messages.go new file mode 100644 index 000000000..a523237c9 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/messages.go @@ -0,0 +1,161 @@ +// Copyright 2016 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. + +// This file provides functions for validating payloads from GitHub Webhooks. +// GitHub API docs: https://developer.github.com/webhooks/securing/#validating-payloads-from-github + +// Adapted for Azure Devops + +package azuredevops + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" +) + +const ( + activityIDHeader = "X-VSS-ActivityId" + subscriptionIDHeader = "X-VSS-SubscriptionId" + // requestIDHeader is the Azure Devops header key used to pass the unique ID for the webhook event. + requestIDHeader = "Request-Id" +) + +var ( + // eventTypeMapping maps webhooks types to their corresponding go-azuredevops + // resource struct types. + eventTypeMapping = map[string]string{ + "git.pullrequest.created": "PullRequestEvent", + "git.pullrequest.merged": "PullRequestEvent", + "git.pullrequest.updated": "PullRequestEvent", + "git.push": "PushEvent", + "workitem.commented": "WorkItemCommentedEvent", + "workitem.updated": "WorkItemUpdatedEvent", + } +) + +// GetActivityID returns the value of the activityIDHeader webhook header. +// +// Haven't found vendor documentation yet. This could be a GUID that identifies +// the webhook request ID. A different GUID is also present in the body of +// webhook requests. +func GetActivityID(r *http.Request) string { + return r.Header.Get(activityIDHeader) +} + +// GetRequestID returns the value of the requestIDHeader webhook header. +// +// Haven't found vendor documentation yet. This could be a GUID that identifies +// the webhook request ID. A different GUID is also present in the body of +// webhook requests. +func GetRequestID(r *http.Request) string { + return r.Header.Get(requestIDHeader) +} + +// GetSubscriptionID returns the value of the subscriptionIDHeader webhook header. +// +// Haven't found vendor documentation yet. This could be a GUID that identifies +// the webhook event type and settings in the Azure Devops tenant +func GetSubscriptionID(r *http.Request) string { + return r.Header.Get(subscriptionIDHeader) +} + +// ValidatePayload validates an incoming Azure Devops Webhook event request +// and returns the (JSON) payload. +// The Content-Type header of the payload must be "application/json" or +// an error is returned. A charset may be included with the content type. +// user is the supplied username for Basic authentication +// pass is the supplied password for Basic authentication +// If your webhook does not contain a username or password, you can pass nil or an empty slice. +// This is intended for local development purposes only as all webhooks should ideally +// set up a secret token. +// It is up to the caller to process failed validation and return a proper 401 response +// to the user, such as: +// +// w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) +// w.WriteHeader(401) +// w.Write([]byte("Unauthorized.\n")) +// +// +// Example usage: +// +// func (s *Event) ServeHTTP(w http.ResponseWriter, r *http.Request) { +// payload, err := azuredevops.ValidatePayload(r, s.user, s.pass) +// if err != nil { ... } +// // Process payload... +// } +// +func ValidatePayload(r *http.Request, user, pass []byte) (payload []byte, err error) { + var body []byte // Raw body that GitHub uses to calculate the signature. + + switch ct := r.Header.Get("Content-Type"); ct { + case "application/json": + var err error + if body, err = ioutil.ReadAll(r.Body); err != nil { + return nil, err + } + + // If the content type is application/json, + // the JSON payload is just the original body. + payload = body + case "application/json; charset=utf-8": + var err error + if body, err = ioutil.ReadAll(r.Body); err != nil { + return nil, err + } + + // If the content type is application/json, + // the JSON payload is just the original body. + payload = body + default: + return nil, fmt.Errorf("Webhook request has unsupported Content-Type %q", ct) + } + + // Only validate the authentication if a username and password exist. This is + // intended for local development only and all webhooks should ideally set up + // a Basic authentication username and password. + username, password, ok := r.BasicAuth() + + if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 { + return nil, errors.New("ValidatePayload authentication failed") + } + //Authorization: Basic + return payload, nil +} + +// ParseWebHook parses the event payload into a corresponding struct. +// An error will be returned for unrecognized event types. +// +// Example usage: +// +// func (s *EventMonitor) ServeHTTP(w http.ResponseWriter, r *http.Request) { +// payload, err := azuredevops.ValidatePayload(r, s.user, s.pass) +// if err != nil { ... } +// event, err := azuredevops.ParseWebHook(payload) +// if err != nil { ... } +// switch event.PayloadType { +// case azuredevops.WorkItemEvent: +// processWorkItemEvent(&event) +// case azuredevops.PullRequestEvent: +// processPullRequestEvent(&event) +// ... +// } +// } +// +// https://docs.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops +func ParseWebHook(payload []byte) (*Event, error) { + event := new(Event) // returns pointer + err := json.Unmarshal(payload, event) + if err != nil { + return nil, err + } + + _, err = event.ParsePayload() + + return event, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull-two-links.json b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull-two-links.json new file mode 100644 index 000000000..07b573a44 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull-two-links.json @@ -0,0 +1,103 @@ +{ + "creationDate" : "2019-04-29T01:34:58.4966678Z", + "supportsIterations" : true, + "lastMergeSourceCommit" : { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/e593a0a382fcd631c0bfc8b705f223f4d60652ed", + "commitId" : "e593a0a382fcd631c0bfc8b705f223f4d60652ed" + }, + "codeReviewId" : 13, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13", + "repository" : { + "webUrl" : "https://dev.azure.com/linkconsulting/Devops/_git/atlantis-example", + "project" : { + "state" : "wellFormed", + "name" : "Devops", + "visibility" : "public", + "revision" : 30, + "lastUpdateTime" : "2019-04-20T17:55:15.023Z", + "url" : "https://dev.azure.com/linkconsulting/_apis/projects/ee81749d-1a0f-4787-a694-3e55b7c5539e", + "id" : "ee81749d-1a0f-4787-a694-3e55b7c5539e" + }, + "name" : "atlantis-example", + "size" : 4569, + "sshUrl" : "git@ssh.dev.azure.com:v3/linkconsulting/Devops/atlantis-example", + "id" : "eb1d4a87-d47f-45e1-8187-1aad297a8229", + "remoteUrl" : "https://linkconsulting@dev.azure.com/linkconsulting/Devops/_git/atlantis-example", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229" + }, + "mergeId" : "601a469c-1f89-4666-a9b0-546ba3b12a7b", + "createdBy" : { + "descriptor" : "msa.ODI5ODdmODYtZGI4Yy03OGNlLThkYWItOWE2YmYyOTgwNjE3", + "imageUrl" : "https://dev.azure.com/linkconsulting/_api/_common/identityImage?id=82987f86-db8c-68ce-8dab-9a6bf2980617", + "url" : "https://spsprodcus1.vssps.visualstudio.com/Aa14fc47a-7d1a-40a9-925b-3ea0274d900a/_apis/Identities/82987f86-db8c-68ce-8dab-9a6bf2980617", + "_links" : { + "avatar" : { + "href" : "https://dev.azure.com/linkconsulting/_apis/GraphProfile/MemberAvatars/msa.ODI5ODdmODYtZGI4Yy03OGNlLThkYWItOWE2YmYyOTgwNjE3" + } + }, + "displayName" : "David McPike", + "uniqueName" : "davidmcpike@gmail.com", + "id" : "82987f86-db8c-68ce-8dab-9a6bf2980617" + }, + "title" : "Updated README.md", + "_links" : { + "createdBy" : { + "href" : "https://spsprodcus1.vssps.visualstudio.com/Aa14fc47a-7d1a-40a9-925b-3ea0274d900a/_apis/Identities/82987f86-db8c-68ce-8dab-9a6bf2980617" + }, + "sourceCommit" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/e593a0a382fcd631c0bfc8b705f223f4d60652ed" + }, + "targetBranch" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/refs/heads/master" + }, + "targetCommit" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/309764bc5de38bfc46e5f7eece49d236546ee33f" + }, + "sourceBranch" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/refs/heads/test-branch-2" + }, + "statuses" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/statuses" + }, + "self" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13" + }, + "iterations" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/iterations" + }, + "workItems" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/workitems" + }, + "repository" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229" + } + }, + "mergeStatus" : "succeeded", + "status" : "active", + "targetRefName" : "refs/heads/master", + "description" : "Updated README.md", + "isDraft" : false, + "lastMergeTargetCommit" : { + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/309764bc5de38bfc46e5f7eece49d236546ee33f", + "commitId" : "309764bc5de38bfc46e5f7eece49d236546ee33f" + }, + "artifactId" : "vstfs:///Git/PullRequestId/ee81749d-1a0f-4787-a694-3e55b7c5539e%2feb1d4a87-d47f-45e1-8187-1aad297a8229%2f13", + "sourceRefName" : "refs/heads/test-branch-2", + "reviewers" : [], + "lastMergeCommit" : { + "author" : { + "date" : "2019-04-29T15:45:54Z", + "email" : "davidmcpike@gmail.com", + "name" : "David McPike" + }, + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/0e70a405a4125e6ec6a7a424e0878498f3dda83a", + "committer" : { + "date" : "2019-04-29T15:45:54Z", + "name" : "David McPike", + "email" : "davidmcpike@gmail.com" + }, + "commitId" : "0e70a405a4125e6ec6a7a424e0878498f3dda83a", + "comment" : "Merge pull request 13 from test-branch-2 into master" + }, + "pullRequestId" : 13 +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull.json b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull.json new file mode 100644 index 000000000..5da8bbb5f --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull.json @@ -0,0 +1,103 @@ +{ + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13", + "createdBy" : { + "imageUrl" : "https://dev.azure.com/linkconsulting/_api/_common/identityImage?id=82987f86-db8c-68ce-8dab-9a6bf2980617", + "_links" : { + "avatar" : { + "href" : "https://dev.azure.com/linkconsulting/_apis/GraphProfile/MemberAvatars/msa.ODI5ODdmODYtZGI4Yy03OGNlLThkYWItOWE2YmYyOTgwNjE3" + } + }, + "descriptor" : "msa.ODI5ODdmODYtZGI4Yy03OGNlLThkYWItOWE2YmYyOTgwNjE3", + "uniqueName" : "davidmcpike@gmail.com", + "displayName" : "David McPike", + "id" : "82987f86-db8c-68ce-8dab-9a6bf2980617", + "url" : "https://spsprodcus1.vssps.visualstudio.com/Aa14fc47a-7d1a-40a9-925b-3ea0274d900a/_apis/Identities/82987f86-db8c-68ce-8dab-9a6bf2980617" + }, + "pullRequestId" : 13, + "supportsIterations" : true, + "codeReviewId" : 13, + "reviewers" : [], + "creationDate" : "2019-04-29T01:34:58.4966678Z", + "lastMergeCommit" : { + "author" : { + "email" : "davidmcpike@gmail.com", + "name" : "David McPike", + "date" : "2019-04-29T15:45:54Z" + }, + "committer" : { + "date" : "2019-04-29T15:45:54Z", + "email" : "davidmcpike@gmail.com", + "name" : "David McPike" + }, + "commitId" : "0e70a405a4125e6ec6a7a424e0878498f3dda83a", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/0e70a405a4125e6ec6a7a424e0878498f3dda83a", + "comment" : "Merge pull request 13 from test-branch-2 into master" + }, + "artifactId" : "vstfs:///Git/PullRequestId/ee81749d-1a0f-4787-a694-3e55b7c5539e%2feb1d4a87-d47f-45e1-8187-1aad297a8229%2f13", + "status" : "active", + "mergeId" : "601a469c-1f89-4666-a9b0-546ba3b12a7b", + "mergeStatus" : "succeeded", + "repository" : { + "remoteUrl" : "https://linkconsulting@dev.azure.com/linkconsulting/Devops/_git/atlantis-example", + "id" : "eb1d4a87-d47f-45e1-8187-1aad297a8229", + "name" : "atlantis-example", + "webUrl" : "https://dev.azure.com/linkconsulting/Devops/_git/atlantis-example", + "sshUrl" : "git@ssh.dev.azure.com:v3/linkconsulting/Devops/atlantis-example", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229", + "size" : 4569, + "project" : { + "revision" : 30, + "url" : "https://dev.azure.com/linkconsulting/_apis/projects/ee81749d-1a0f-4787-a694-3e55b7c5539e", + "name" : "Devops", + "visibility" : "public", + "lastUpdateTime" : "2019-04-20T17:55:15.023Z", + "id" : "ee81749d-1a0f-4787-a694-3e55b7c5539e", + "state" : "wellFormed" + } + }, + "sourceRefName" : "refs/heads/test-branch-2", + "title" : "Updated README.md", + "description" : "Updated README.md", + "targetRefName" : "refs/heads/master", + "isDraft" : false, + "_links" : { + "workItems" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/workitems" + }, + "targetCommit" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/309764bc5de38bfc46e5f7eece49d236546ee33f" + }, + "sourceBranch" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/refs/heads/test-branch-2" + }, + "self" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13" + }, + "sourceCommit" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/e593a0a382fcd631c0bfc8b705f223f4d60652ed" + }, + "targetBranch" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/refs/heads/master" + }, + "repository" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229" + }, + "createdBy" : { + "href" : "https://spsprodcus1.vssps.visualstudio.com/Aa14fc47a-7d1a-40a9-925b-3ea0274d900a/_apis/Identities/82987f86-db8c-68ce-8dab-9a6bf2980617" + }, + "statuses" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/statuses" + }, + "iterations" : { + "href" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/pullRequests/13/iterations" + } + }, + "lastMergeTargetCommit" : { + "commitId" : "309764bc5de38bfc46e5f7eece49d236546ee33f", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/309764bc5de38bfc46e5f7eece49d236546ee33f" + }, + "lastMergeSourceCommit" : { + "commitId" : "e593a0a382fcd631c0bfc8b705f223f4d60652ed", + "url" : "https://dev.azure.com/linkconsulting/ee81749d-1a0f-4787-a694-3e55b7c5539e/_apis/git/repositories/eb1d4a87-d47f-45e1-8187-1aad297a8229/commits/e593a0a382fcd631c0bfc8b705f223f4d60652ed" + } +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull_requests.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull_requests.go new file mode 100644 index 000000000..66eeeb2d6 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/pull_requests.go @@ -0,0 +1,366 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +// Vote identifiers +const ( + VoteApproved = 10 + VoteApprovedWithSuggestions = 5 + VoteNone = 0 + VoteWaitingForAuthor = -5 + VoteRejected = -10 +) + +// CommentType enum declaration +type CommentType int + +// CommentType enum declaration +const ( + // The comment type is not known. + CommentTypeUnknown CommentType = iota + // This is a regular user comment. + CommentTypeText + // The comment comes as a result of a code change. + CommentTypeCodeChange + // The comment represents a system message. + CommentTypeSystem +) + +func (d CommentType) String() string { + return [...]string{"unknown", "text", "codechange", "system"}[d] +} + +// CommentThreadStatus enum declaration +type CommentThreadStatus int + +// CommentThreadStatus enum declaration +const ( + StatusUnknown CommentThreadStatus = iota + StatusActive + Fixed + WontFix + Closed + ByDesign + Pending +) + +func (d CommentThreadStatus) String() string { + return [...]string{"unknown", "active", "fixed", "wontfix", "closed", "byDesign", "pending"}[d] +} + +// PullRequestAsyncStatus The current status of a pull request merge. +type PullRequestAsyncStatus int + +// PullRequestAsyncStatus enum values +const ( + MergeConflicts PullRequestAsyncStatus = iota + MergeFailure + MergeNotSet + MergeQueued + MergeRejectedByPolicy + MergeSucceeded +) + +func (d PullRequestAsyncStatus) String() string { + return [...]string{"conflicts", "failure", "notSet", "queued", "rejectedByPolicy", "succeeded"}[d] +} + +// PullRequestMergeFailureType The specific type of merge request failure +type PullRequestMergeFailureType int + +// PullRequestMergeFailureType enum values +const ( + NoFailure PullRequestMergeFailureType = iota + UnknownFailure + CaseSensitive + ObjectTooLarge +) + +func (d PullRequestMergeFailureType) String() string { + return [...]string{"none", "unknown", "caseSensitive", "objectTooLarge"}[d] +} + +// PullRequestStatus The current status of a pull request merge. +type PullRequestStatus int + +// PullRequestStatus enum values +const ( + PullAbandoned PullRequestStatus = iota + PullActive + PullIncludeAll + PullCompleted + PullNotSet +) + +func (d PullRequestStatus) String() string { + return [...]string{"abandoned", "active", "all", "completed", "notSet"}[d] +} + +// PullRequestsService handles communication with the pull requests methods on the API +// utilising https://docs.microsoft.com/en-us/rest/api/vsts/git/pull%20requests +type PullRequestsService struct { + client *Client +} + +// PullRequestsCommitsResponse describes a pull requests commits response +type PullRequestsCommitsResponse struct { + Count int `json:"count"` + GitCommitRefs []*GitCommitRef `json:"value"` +} + +// PullRequestGetOptions describes what the request to the API should look like +type PullRequestGetOptions struct { + IncludeCommits bool `url:"includeCommits,omitempty"` + IncludeWorkItemRefs bool `url:"includeWorkItemRefs,omitempty"` + Project string `url:"project,omitempty"` + Organization string `url:"organization,omitempty"` + RepositoryID string `url:"repositoryId,omitempty"` + // maxCommentLength Not used. + MaxCommentLength int `url:"maxCommentLength,omitempty"` + PullRequestID int `url:"pullRequestId,omitempty"` + // $skip Not used. + Skip int `url:"$skip,omitempty"` + // $top Not used. + Top int `url:"$top,omitempty"` +} + +// PullRequestsListResponse describes a pull requests list response +type PullRequestsListResponse struct { + Count int `json:"count"` + GitPullRequests []*GitPullRequest `json:"value"` +} + +// PullRequestsListCommitsResponse describes a pull requests list commits response +type PullRequestsListCommitsResponse struct { + Count int `json:"count"` + GitCommitRefs []*GitCommitRef `json:"value"` +} + +// PullRequestListOptions describes what the request to the API should look like +type PullRequestListOptions struct { + CreatorID string `url:"searchCriteria.creatorId,omitempty"` + IncludeLinks string `url:"searchCriteria.includeLinks,omitempty"` + Project string `url:"project,omitempty"` + RepositoryID string `url:"searchCriteria.repositoryId,omitempty"` + ReviewerID string `url:"searchCriteria.reviewerId,omitempty"` + Skip string `url:"$skip,omitempty"` + SourceRefName string `url:"searchCriteria.sourceRefName,omitempty"` + SourceRepositoryID string `url:"searchCriteria.sourceRepositoryId,omitempty"` + Status string `url:"searchCriteria.status,omitempty"` + TargetRefName string `url:"searchCriteria.targetRefName,omitempty"` + Top string `url:"$top,omitempty"` +} + +// List returns list of pull requests in the specified Team Project with optional +// filters +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20requests%20by%20project +func (s *PullRequestsService) List(ctx context.Context, owner, project string, opts *PullRequestListOptions) ([]*GitPullRequest, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/pullrequests?api-version=5.1-preview.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + r := new(PullRequestsListResponse) + resp, err := s.client.Execute(ctx, req, r) + if err != nil { + return nil, nil, err + } + + return r.GitPullRequests, resp, err +} + +// Get returns a single pull request +// utilising https://docs.microsoft.com/en-us/rest/api/vsts/git/pull%20requests/get%20pull%20requests%20by%20project +func (s *PullRequestsService) Get(ctx context.Context, owner, project string, pullNum int, opts *PullRequestListOptions) (*GitPullRequest, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/pullrequests/%d?api-version=5.1-preview.1", + owner, + project, + pullNum, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(GitPullRequest) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// GetWithRepo returns a single pull request with additional information +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20request?view=azure-devops-rest-5.1 +func (s *PullRequestsService) GetWithRepo(ctx context.Context, owner, project, repo string, pullNum int, opts *PullRequestGetOptions) (*GitPullRequest, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/repositories/%s/pullrequests/%d?api-version=5.1-preview.1", + owner, + project, + repo, + pullNum, + ) + + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(GitPullRequest) + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// Merge Completes a pull request +// pull may be nil +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/update?view=azure-devops-rest-5.1 +func (s *PullRequestsService) Merge(ctx context.Context, owner, project string, repoName string, pullNum int, pull *GitPullRequest, completionOpts GitPullRequestCompletionOptions, id IdentityRef) (*GitPullRequest, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/repositories/%s/pullrequests/%d?api-version=5.1-preview.1", + owner, + project, + repoName, + pullNum, + ) + + /* If pull not nil, prepare for merge + // otherwise create an empty GitPullRequest{} + if pull != nil { + } + */ + + // Construct request body from supplied parameters + body := &GitPullRequest{} + body.AutoCompleteSetBy = &id + body.CompletionOptions = &completionOpts + + // Now we're ready to make our API call to merge the pull request. + request, err := s.client.NewRequest("PATCH", URL, body) + if err != nil { + return nil, nil, err + } + r := new(GitPullRequest) + resp, err := s.client.Execute(ctx, request, r) + + return r, resp, err +} + +// Create Creates a pull request +// Required fields in the GitPullRequest{} are: +// * Title +// * Description +// * SourceRefName +// * TargetRefName +// +// SourceRefName can be either the full ref name "refs/heads/branchname" or +// just "branchname". The latter will be converted before submission. +// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/create?view=azure-devops-rest-5.1 +func (s *PullRequestsService) Create(ctx context.Context, owner, project string, repoName string, pull *GitPullRequest) (*GitPullRequest, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/repositories/%s/pullrequests?api-version=5.1-preview.1", + owner, + project, + repoName, + ) + + if pull.GetTitle() == "" || pull.GetDescription() == "" || + pull.GetSourceRefName() == "" || pull.GetTargetRefName() == "" { + return nil, nil, errors.New("PullRequests.Create: Missing required field") + } + + formatRef(pull.SourceRefName) + formatRef(pull.TargetRefName) + + // Now we're ready to make our API call to create the pull request. + request, err := s.client.NewRequest("POST", URL, pull) + if err != nil { + return nil, nil, err + } + r := new(GitPullRequest) + resp, err := s.client.Execute(ctx, request, r) + + return r, resp, err +} + +// Comment Represents a comment which is one of potentially many in a comment thread. +type Comment struct { + Links *map[string]Link `json:"_links,omitempty"` + Author *IdentityRef `json:"author,omitempty"` + CommentType *string `json:"commentType,omitempty"` + Content *string `json:"content,omitempty"` + ID *int `json:"id,omitempty"` + IsDeleted *bool `json:"isDeleted,omitempty"` + LastContentUpdatedDate *time.Time `json:"lastContentUpdatedDate,omitempty"` + LastUpdatedDate *time.Time `json:"lastUpdatedDate,omitempty"` + ParentCommentID *int `json:"parentCommentId,omitempty"` + PublishedDate *time.Time `json:"publishedDate,omitempty"` + UsersLiked []*IdentityRef `json:"usersLiked,omitempty"` +} + +// CommentPosition describes a comment position +type CommentPosition struct { + Line *int `json:"line,omitempty"` + Offset *int `json:"offset,omitempty"` +} + +// GitPullRequestCommentThread Represents a comment thread of a pull request. +// A thread contains meta data about the file it was left on along with one or +// more comments (an initial comment and the subsequent replies). +type GitPullRequestCommentThread struct { + Links *map[string]Link `json:"_links,omitempty"` + Comments []*Comment `json:"comments,omitempty"` + ID *int `json:"id,omitempty"` + Identities []*IdentityRef `json:"identities,omitempty"` + IsDeleted *bool `json:"isDeleted,omitempty"` + LastUpdatedDate *time.Time `json:"lastUpdatedDate,omitempty"` + Properties []*int `json:"properties,omitempty"` + PublishedDate *time.Time `json:"publishedDate,omitempty"` + Status *string `json:"status,omitempty"` + PullRequestThreadContext *GitPullRequestCommentThreadContext `json:"pullRequestThreadContext,omitempty"` +} + +// GitPullRequestCommentThreadContext Comment thread context contains details about what +// diffs were being viewed at the time of thread creation and whether or not the thread +// has been tracked from that original diff. +type GitPullRequestCommentThreadContext struct { + FilePath *string `json:"filePath,omitempty"` + LeftFileEnd *CommentPosition `json:"leftFileEnd,omitempty"` + LeftFileStart *CommentPosition `json:"leftFileStart,omitempty"` + RightFileEnd *CommentPosition `json:"rightFileEnd,omitempty"` + RightFileStart *CommentPosition `json:"rightFileStart,omitempty"` +} + +// ListCommits lists the commits in a pull request. +// Azure Devops API docs: https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20commits/get%20pull%20request%20commits +// +func (s *PullRequestsService) ListCommits(ctx context.Context, owner, project, repo string, pullNum int) ([]*GitCommitRef, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/git/repositories/%s/pullrequests/%d/commits?api-version=5.1-preview.1", + owner, + project, + repo, + pullNum, + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + r := new(PullRequestsListCommitsResponse) + resp, err := s.client.Execute(ctx, req, r) + if err != nil { + return nil, nil, err + } + + return r.GitCommitRefs, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/release.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/release.go new file mode 100644 index 000000000..c21571c8d --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/release.go @@ -0,0 +1,11 @@ +// +build !debug + +package azuredevops + +import "net/http" + +// Idea from: +// https://dave.cheney.net/2014/09/28/using-build-to-switch-between-debug-and-release +func debug(fmt string, args ...interface{}) {} + +func debugReq(*http.Request) {} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/teams.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/teams.go new file mode 100644 index 000000000..d41702903 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/teams.go @@ -0,0 +1,89 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// TeamsService handles communication with the teams methods on the API +// utilising https://docs.microsoft.com/en-us/rest/api/vsts/core/teams/get%20all%20teams +type TeamsService struct { + client *Client +} + +// Project Describes a project +type Project struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + URL *string `json:"url,omitempty"` + State *string `json:"state,omitempty"` + Revision *int `json:"revision,omitempty"` + Visibility *string `json:"visibility,omitempty"` + LastUpdateTime *time.Time `json:"lastUpdateTime,omitempty"` +} + +// Team describes what a team looks like +type Team struct { + ID *string `url:"id,omitempty"` + Name *string `url:"name,omitempty"` + URL *string `url:"url,omitempty"` + Description *string `url:"description,omitempty"` +} + +// TeamsListOptions describes what the request to the API should look like +type TeamsListOptions struct { + Mine *bool `url:"$mine,omitempty"` + Top *int `url:"$top,omitempty"` + Skip *int `url:"$skip,omitempty"` +} + +// TeamsListResponse Requests that may return multiple entities use this format +type TeamsListResponse struct { + Count int `json:"count,omitempty"` + Teams []*Team `json:"value,omitempty"` +} + +// TeamProjectCollectionReference Reference object for a TeamProjectCollection. +type TeamProjectCollectionReference struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"url,omitempty"` +} + +// TeamProjectReference Represents a shallow reference to a TeamProject. +type TeamProjectReference struct { + Abbreviation *string `json:"abbreviation,omitempty"` + DefaultTeamImageURL *string `json:"defaultTeamImageUrl,omitempty"` + Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Revision *int `json:"revision,omitempty"` + State *string `json:"state,omitempty"` + URL *string `json:"url,omitempty"` + Visibility *string `json:"visibility,omitempty"` + LastUpdateTime *time.Time `json:"lastUpdateTime,omitempty"` +} + +// List returns list of the teams +// https://docs.microsoft.com/en-us/rest/api/azure/devops/core/teams/get%20teams +// GET https://dev.azure.com/{organization}/_apis/projects/{projectId}/teams?api-version=5.1-preview.2 +func (s *TeamsService) List(ctx context.Context, owner, project string, opts *TeamsListOptions) ([]*Team, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/teams?api-version=5.1-preview.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + u, _ := s.client.BaseURL.Parse(URL) + req, err := s.client.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, nil, err + } + r := new(TeamsListResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.Teams, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/tests.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/tests.go new file mode 100644 index 000000000..ac9a096b8 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/tests.go @@ -0,0 +1,160 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// TestsService handles communication with the Tests methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/test +type TestsService struct { + client *Client +} + +// TestListResponse is the wrapper around the main response for the List of Tests +type TestListResponse struct { + Count int `json:"count,omitempty"` + Tests []*Test `json:"value,omitempty"` +} + +// Test represents a test +type Test struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + URL *string `json:"url,omitempty"` + IsAutomated *bool `json:"isAutomated,omitempty"` + Iteration *string `json:"iteration,omitempty"` + Owner *struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName,omitempty"` + } `json:"owner,omitempty"` + StartedDate *string `json:"startedDate,omitempty"` + CompletedDate *string `json:"completedDate,omitempty"` + State *string `json:"state,omitempty"` + Plan *struct { + ID string `json:"id,omitempty"` + } `json:"plan,omitempty"` + Revision *int `json:"revision,omitempty"` +} + +// TestsListOptions describes what the request to the API should look like +type TestsListOptions struct { + Count *int `url:"$top,omitempty"` + BuildURI *string `url:"buildUri,omitempty"` +} + +// List returns list of the tests +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/test/runs/list +func (s *TestsService) List(ctx context.Context, owner, project string, opts *TestsListOptions) ([]*Test, *http.Response, error) { + URL := fmt.Sprintf("%s/%s/_apis/test/runs?api-version=4.1", + owner, + project, + ) + URL, err := addOptions(URL, opts) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := new(TestListResponse) + resp, err := s.client.Execute(ctx, req, r) + + return r.Tests, resp, err +} + +// TestResultsListResponse is the wrapper around the main response for the List of Tests +type TestResultsListResponse struct { + Results []TestResult `json:"value"` +} + +// TestResult represents a test result +type TestResult struct { + ID int `json:"id"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"project"` + StartedDate time.Time `json:"startedDate"` + CompletedDate time.Time `json:"completedDate"` + DurationInMs float64 `json:"durationInMs"` + Outcome string `json:"outcome"` + Revision int `json:"revision"` + RunBy struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` + URL string `json:"url"` + ImageURL string `json:"imageUrl"` + } `json:"runBy"` + State string `json:"state"` + TestCase struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"testCase"` + TestRun struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"testRun"` + LastUpdatedDate time.Time `json:"lastUpdatedDate"` + LastUpdatedBy struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` + URL string `json:"url"` + ImageURL string `json:"imageUrl"` + } `json:"lastUpdatedBy"` + Priority int `json:"priority"` + ComputerName string `json:"computerName"` + Build struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"build"` + CreatedDate time.Time `json:"createdDate"` + URL string `json:"url"` + FailureType string `json:"failureType"` + AutomatedTestStorage string `json:"automatedTestStorage"` + AutomatedTestType string `json:"automatedTestType"` + AutomatedTestTypeID string `json:"automatedTestTypeId"` + AutomatedTestID string `json:"automatedTestId"` + Area struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"area"` + TestCaseTitle string `json:"testCaseTitle"` + CustomFields []interface{} `json:"customFields"` + AutomatedTestName string `json:"automatedTestName"` + StackTrace string `json:"stackTrace"` +} + +// TestResultsListOptions describes what the request to the API should look like +type TestResultsListOptions struct { + Count int `url:"$top,omitempty"` + RunID string `url:"runId,omitempty"` +} + +// ResultsList returns list of the test results +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/test/runs/list +func (s *TestsService) ResultsList(ctx context.Context, owner, project string, opts *TestResultsListOptions) ([]TestResult, error) { + URL := fmt.Sprintf("%s/%s/_apis/test/Runs/%s/results?api-version=4.1", + owner, + project, + opts.RunID, + ) + opts.RunID = "" + URL, err := addOptions(URL, opts) + + request, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, err + } + var response TestResultsListResponse + _, err = s.client.Execute(ctx, request, &response) + + return response.Results, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/users.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/users.go new file mode 100644 index 000000000..788ec3582 --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/users.go @@ -0,0 +1,189 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// UsersService handles communication with the Graph.Users methods on the API +// utilising https://docs.microsoft.com/en-us/rest/api/azure/devops/graph/users/get +type UsersService struct { + client *Client +} + +// GraphGroup is the parent struct describing a Microsoft Graph group for Azure Devops +type GraphGroup struct { + GraphMember + /** + * A short phrase to help human readers disambiguate groups with similar names + */ + Description *string `json:"description,omitempty"` + IsCrossProject *bool `json:"isCrossProject,omitempty"` + IsDeleted *bool `json:"isDeleted,omitempty"` + IsGlobalScope *bool `json:"isGlobalScope,omitempty"` + IsRestrictedVisible *bool `json:"isRestrictedVisible,omitempty"` + LocalScopeID *string `json:"localScopeId,omitempty"` + ScopeID *string `json:"scopeId,omitempty"` + ScopeName *string `json:"scopeName,omitempty"` + ScopeType *string `json:"scopeType,omitempty"` + SecuringHostID *string `json:"securingHostId,omitempty"` + SpecialType *string `json:"specialType,omitempty"` +} + +// GraphMember is a child of the GraphUser struct +type GraphMember struct { + GraphSubject + /** + * This represents the name of the container of origin for a graph member. + * (For MSA this is "Windows Live ID", for AD the name of the domain, for + * AAD the tenantID of the directory, for VSTS groups the ScopeId, etc) + */ + Domain *string `json:"domain,omitempty"` + /** + * The email address of record for a given graph member. This may be + * different than the principal name. + */ + MailAddress *string `json:"mailAddress,omitempty"` + /** + * This is the PrincipalName of this graph member from the source + * provider. The source provider may change this field over time and it + * is not guaranteed to be immutable for the life of the graph member by + * VSTS. + */ + PrincipalName *string `json:"principalName,omitempty"` +} + +// GraphSubjectBase Base struct for other graph entities +type GraphSubjectBase struct { + /* + * This field contains zero or more interesting links about the graph + * subject. These links may be invoked to obtain additional relationships + * or more detailed information about this graph subject. + */ + Links map[string]Link `json:"_links,omitempty"` + /** + * The descriptor is the primary way to reference the graph subject while + * the system is running. This field will uniquely identify the same + * graph subject across both Accounts and Organizations. + */ + Descriptor *string `json:"descriptor,omitempty"` + /** + * This is the non-unique display name of the graph subject. To change + * this field, you must alter its value in the source provider. + */ + DisplayName *string `json:"displayName,omitempty"` + /** + * This url is the full route to the source resource of this graph subject. + */ + URL *string `json:"url,omitempty"` +} + +// GraphSubject A graph subject entity +type GraphSubject struct { + GraphSubjectBase + /** + * [Internal Use Only] The legacy descriptor is here in case you need to + * access old version IMS using identity descriptor. + */ + LegacyDescriptor *string `json:"legacyDescriptor,omitempty"` + /** + * The type of source provider for the origin identifier (ex:AD, AAD, MSA) + */ + Origin *string `json:"origin,omitempty"` + /** + * The unique identifier from the system of origin. Typically a sid, object + * id or Guid. Linking and unlinking operations can cause this value to + * change for a user because the user is not backed by a different provider + * and has a different unique id in the new provider. + */ + OriginID *string `json:"originId,omitempty"` + /** + * This field identifies the type of the graph subject (ex: Group, Scope, User). + */ + SubjectKind *string `json:"subjectKind,omitempty"` +} + +// GraphUser is the parent struct describing a Microsoft Graph user for Azure Devops +type GraphUser struct { + GraphMember + IsDeletedInOrigin *bool `json:"isDeletedOrigin,omitempty"` + MetadataUpdateDate *time.Time `json:"metadataUpdateDate,omitempty"` + /** + * The meta type of the user in the origin, such as "member", "guest", + * etc. See UserMetaType for the set of possible values. + */ + MetaType *string `json:"metaType,omitempty"` +} + +// GraphUsersListResponse describes what a response from the Users.List() +// API should look like +type GraphUsersListResponse struct { + Count int `json:"count"` + GraphUsers []*GraphUser `json:"value"` +} + +// Get returns information about a single user in an org +// https://docs.microsoft.com/en-us/rest/api/azure/devops/graph/users/get +func (s *UsersService) Get(ctx context.Context, owner, descriptor string) (*GraphUser, *http.Response, error) { + URL := fmt.Sprintf("%s%s/_apis/graph/users/%s?api-version=5.1-preview.1", + s.client.VsspsBaseURL.String(), + owner, + descriptor, + ) + + request, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + var r GraphUser + resp, err := s.client.Execute(ctx, request, &r) + + return &r, resp, err +} + +// List returns a list of users in an org +// utilising https://docs.microsoft.com/en-us/rest/api/azure/devops/graph/users/list +func (s *UsersService) List(ctx context.Context, owner string) ([]*GraphUser, *http.Response, error) { + URL := fmt.Sprintf("%s%s/_apis/graph/users?api-version=5.1-preview.1", + s.client.VsspsBaseURL.String(), + owner, + ) + + request, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + var r GraphUsersListResponse + resp, err := s.client.Execute(ctx, request, &r) + + return r.GraphUsers, resp, err +} + +// GraphDescriptorResult Returns user descriptor and links related to the +// request +type GraphDescriptorResult struct { + Links map[string]Link `json:"_links,omitempty"` + Value *string `json:"value,omitempty"` +} + +// GetDescriptors returns descriptors for one or more users based on filter +// criteria +// https://docs.microsoft.com/en-us/rest/api/azure/devops/graph/descriptors/get?view=azure-devops-rest-5.1 +func (s *UsersService) GetDescriptors(ctx context.Context, owner, storageKey string) (*GraphDescriptorResult, *http.Response, error) { + URL := fmt.Sprintf("%s%s/_apis/graph/descriptors/%s?api-version=5.1-preview.1", + s.client.VsspsBaseURL.String(), + owner, + storageKey, + ) + + request, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + r := &GraphDescriptorResult{} + resp, err := s.client.Execute(ctx, request, r) + + return r, resp, err +} diff --git a/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/work_items.go b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/work_items.go new file mode 100644 index 000000000..57220474b --- /dev/null +++ b/vendor/github.com/mcdafydd/go-azuredevops/azuredevops/work_items.go @@ -0,0 +1,260 @@ +package azuredevops + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// WorkItemsService handles communication with the work items methods on the API +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/wit/work%20items +type WorkItemsService struct { + client *Client +} + +// IterationWorkItems Represents work items in an iteration backlog +type IterationWorkItems struct { + Links *map[string]Link `json:"_links,omitempty"` + WorkItemRelations []*WorkItemLink `json:"workItemRelations"` + URL *string `json:"url,omitempty"` +} + +// WorkItemComment Describes a response to CreateComment +type WorkItemComment struct { + CreatedBy *IdentityRef `json:"createdBy,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` + ID *int `json:"id,omitempty"` + ModifiedBy *IdentityRef `json:"modifiedBy,omitempty"` + ModifiedDate *time.Time `json:"modifiedDate,omitempty"` + Text *string `json:"text,omitempty"` + URL *string `json:"url,omitempty"` + Version *int `json:"version,omitempty"` + WorkItemID *int `json:"workItemId,omitempty"` +} + +// WorkItemCommentList Represents a list of work item comments. +type WorkItemCommentList struct { + Links *map[string]Link `json:"_links,omitempty"` + Comments []*WorkItemComment `json:"comments,omitempty"` + ContinuationToken *string `json:"continuationToken,omitempty"` + Count *int `json:"count,omitempty"` + NextPage *string `json:"nextPage,omitempty"` + TotalCount *int `json:"totalCount,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WorkItemCommentListOptions URI parameters for ListComments +// Valid Expand strings are: +// all, mentions, none, reactions, renderedText, renderedTextOnly +type WorkItemCommentListOptions struct { + IDs []int `url:"ids,omitempty"` + IncludeDeleted bool `url:"includeDeleted,omitempty"` + Expand string `url:"$expand,omitempty"` +} + +// WorkItemLink A link between two work items. +type WorkItemLink struct { + Rel *string `json:"rel,omitempty"` + Source *WorkItemReference `json:"source,omitempty"` + Target *WorkItemReference `json:"target,omitempty"` +} + +// WorkItemListResponse describes the list response for work items +type WorkItemListResponse struct { + Count int `json:"count,omitempty"` + WorkItems []*WorkItem `json:"value,omitempty"` +} + +// WorkItem describes an individual work item in TFS +type WorkItem struct { + Links *map[string]Link `json:"_links,omitempty"` + CommentVersionRef *CommentVersionRef `json:"commentVersionRef,omitempty"` + Fields *map[string]interface{} `json:"fields,omitempty"` + ID *int `json:"id,omitempty"` + Relations []*WorkItemRelation `json:"relations,omitempty"` + Rev *int `json:"rev,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WorkItemFieldUpdate Describes an update to a work item field. +type WorkItemFieldUpdate struct { + NewValue interface{} `json:"newValue,omitempty"` + OldValue interface{} `json:"oldValue,omitempty"` +} + +// WorkItemRelationUpdates Describes updates to a work item's relations. +type WorkItemRelationUpdates struct { + Added []*WorkItemRelation `json:"added,omitempty"` + Removed []*WorkItemRelation `json:"removed,omitempty"` + Updated []*WorkItemRelation `json:"updated,omitempty"` +} + +// CommentVersionRef refers to the specific version of a comment +type CommentVersionRef struct { + CommentID *int `json:"commentId,omitempty"` + Version *int `json:"version,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WorkItemReference Contains reference to a work item. +type WorkItemReference struct { + ID *int `json:"id,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WorkItemRelation describes an intermediary between iterations and work items +type WorkItemRelation struct { + Attributes *map[string]interface{} `json:"attributes,omitempty"` + Rel *string `json:"rel,omitempty"` + URL *string `json:"url,omitempty"` +} + +// WorkItemUpdate Describes an update to a work item. +type WorkItemUpdate struct { + Links *map[string]interface{} `json:"attributes,omitempty"` + Fields *map[string]WorkItemFieldUpdate `json:"fields,omitempty"` + ID *int `json:"id,omitempty"` + Relations *WorkItemRelationUpdates `json:"relations,omitempty"` + Rev *int `json:"rev,omitempty"` + RevisedBy *IdentityRef `json:"revisedBy,omitempty"` + RevisedDate *time.Time `json:"revisedDate,omitempty"` + WorkItemID *int `json:"workItemId,omitempty"` + URL *string `json:"url,omitempty"` +} + +// GetForIteration will get a list of work items based on an iteration name +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/wit/work%20items/list +func (s *WorkItemsService) GetForIteration(ctx context.Context, owner, project, team string, iteration Iteration) ([]*WorkItem, *http.Response, error) { + iterationWorkItems, resp, err := s.GetIdsForIteration(ctx, owner, project, team, iteration) + if err != nil { + return nil, resp, err + } + + var workIds []string + for index := 0; index < len(iterationWorkItems.WorkItemRelations); index++ { + relationship := (iterationWorkItems.WorkItemRelations)[index] + workIds = append(workIds, strconv.Itoa(*relationship.Target.ID)) + } + + // https://docs.microsoft.com/en-us/rest/api/vsts/wit/work%20item%20types%20field/list + fields := []string{ + "System.Id", "System.Title", "System.State", "System.WorkItemType", + "Microsoft.VSTS.Scheduling.StoryPoints", "System.BoardColumn", + "System.CreatedBy", "System.AssignedTo", "System.Tags", + } + + // Now we want to pad out the fields for the work items + URL := fmt.Sprintf( + "%s/%s/_apis/wit/workitems?ids=%s&fields=%s&api-version=5.1-preview.1", + owner, + project, + strings.Join(workIds, ","), + strings.Join(fields, ","), + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + r := new(WorkItemListResponse) + resp, err = s.client.Execute(ctx, req, r) + + return r.WorkItems, resp, err +} + +// GetIdsForIteration will return an array of ids for a given iteration +// utilising https://docs.microsoft.com/en-gb/rest/api/vsts/work/iterations/get%20iteration%20work%20items +func (s *WorkItemsService) GetIdsForIteration(ctx context.Context, owner, project, team string, iteration Iteration) (*IterationWorkItems, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/%s/_apis/work/teamsettings/iterations/%s/workitems?api-version=5.1-preview.1", + owner, + project, + url.PathEscape(team), + *iteration.ID, + ) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + r := new(IterationWorkItems) + + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// ListComments Lists all comments on a work item +// https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/comments/get%20comment?view=azure-devops-rest-5.1#comment +func (s *WorkItemsService) ListComments(ctx context.Context, owner, project string, workItemID int, opts *WorkItemCommentListOptions) (*WorkItemCommentList, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/wit/workItems/%d/comments?api-version=5.1-preview.3", + owner, + project, + workItemID, + ) + + URL, err := addOptions(URL, opts) + + r := new(WorkItemCommentList) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// GetComment Gets a work item comment +// https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/comments/get%20comments%20batch?view=azure-devops-rest-5.1#commentlist +func (s *WorkItemsService) GetComment(ctx context.Context, owner, project string, workItemID, commentID int, opts *WorkItemCommentListOptions) (*WorkItemComment, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/wit/workItems/%d/comments/%d?api-version=5.1-preview.3", + owner, + project, + workItemID, + commentID, + ) + + r := new(WorkItemComment) + + req, err := s.client.NewRequest("GET", URL, nil) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} + +// CreateComment Posts a comment to a work item +// https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add +func (s *WorkItemsService) CreateComment(ctx context.Context, owner, project string, workItemID int, comment *WorkItemComment) (*WorkItemComment, *http.Response, error) { + URL := fmt.Sprintf( + "%s/%s/_apis/wit/workItems/%d/comments?api-version=5.1-preview.3", + owner, + project, + workItemID, + ) + + r := new(WorkItemComment) + + req, err := s.client.NewRequest("POST", URL, comment) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Execute(ctx, req, r) + + return r, resp, err +} diff --git a/vendor/github.com/microcosm-cc/bluemonday/.coveralls.yml b/vendor/github.com/microcosm-cc/bluemonday/.coveralls.yml new file mode 100644 index 000000000..e0c87602f --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/.coveralls.yml @@ -0,0 +1 @@ +repo_token: x2wlA1x0X8CK45ybWpZRCVRB4g7vtkhaw diff --git a/vendor/github.com/microcosm-cc/bluemonday/.travis.yml b/vendor/github.com/microcosm-cc/bluemonday/.travis.yml new file mode 100644 index 000000000..4f6664619 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/.travis.yml @@ -0,0 +1,22 @@ +language: go +go: + - 1.1.x + - 1.2.x + - 1.3.x + - 1.4.x + - 1.5.x + - 1.6.x + - 1.7.x + - 1.8.x + - 1.9.x + - 1.10.x + - 1.11.x + - tip +matrix: + allow_failures: + - go: tip + fast_finish: true +install: + - go get . +script: + - go test -v ./... diff --git a/vendor/github.com/microcosm-cc/bluemonday/CONTRIBUTING.md b/vendor/github.com/microcosm-cc/bluemonday/CONTRIBUTING.md new file mode 100644 index 000000000..d2b12302f --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to bluemonday + +Third-party patches are essential for keeping bluemonday secure and offering the features developers want. However there are a few guidelines that we need contributors to follow so that we can maintain the quality of work that developers who use bluemonday expect. + +## Getting Started + +* Make sure you have a [Github account](https://github.com/signup/free) + +## Guidelines + +1. Do not vendor dependencies. As a security package, were we to vendor dependencies the projects that then vendor bluemonday may not receive the latest security updates to the dependencies. By not vendoring dependencies the project that implements bluemonday will vendor the latest version of any dependent packages. Vendoring is a project problem, not a package problem. bluemonday will be tested against the latest version of dependencies periodically and during any PR/merge. + +## Submitting an Issue + +* Submit a ticket for your issue, assuming one does not already exist +* Clearly describe the issue including the steps to reproduce (with sample input and output) if it is a bug + +If you are reporting a security flaw, you may expect that we will provide the code to fix it for you. Otherwise you may want to submit a pull request to ensure the resolution is applied sooner rather than later: + +* Fork the repository on Github +* Issue a pull request containing code to resolve the issue + +## Submitting a Pull Request + +* Submit a ticket for your issue, assuming one does not already exist +* Describe the reason for the pull request and if applicable show some example inputs and outputs to demonstrate what the patch does +* Fork the repository on Github +* Before submitting the pull request you should + 1. Include tests for your patch, 1 test should encapsulate the entire patch and should refer to the Github issue + 1. If you have added new exposed/public functionality, you should ensure it is documented appropriately + 1. If you have added new exposed/public functionality, you should consider demonstrating how to use it within one of the helpers or shipped policies if appropriate or within a test if modifying a helper or policy is not appropriate + 1. Run all of the tests `go test -v ./...` or `make test` and ensure all tests pass + 1. Run gofmt `gofmt -w ./$*` or `make fmt` + 1. Run vet `go tool vet *.go` or `make vet` and resolve any issues + 1. Install golint using `go get -u github.com/golang/lint/golint` and run vet `golint *.go` or `make lint` and resolve every warning +* When submitting the pull request you should + 1. Note the issue(s) it resolves, i.e. `Closes #6` in the pull request comment to close issue #6 when the pull request is accepted + +Once you have submitted a pull request, we *may* merge it without changes. If we have any comments or feedback, or need you to make changes to your pull request we will update the Github pull request or the associated issue. We expect responses from you within two weeks, and we may close the pull request is there is no activity. + +### Contributor Licence Agreement + +We haven't gone for the formal "Sign a Contributor Licence Agreement" thing that projects like [puppet](https://cla.puppetlabs.com/), [Mojito](https://developer.yahoo.com/cocktails/mojito/cla/) and companies like [Google](http://code.google.com/legal/individual-cla-v1.0.html) are using. + +But we do need to know that we can accept and merge your contributions, so for now the act of contributing a pull request should be considered equivalent to agreeing to a contributor licence agreement, specifically: + +You accept that the act of submitting code to the bluemonday project is to grant a copyright licence to the project that is perpetual, worldwide, non-exclusive, no-charge, royalty free and irrevocable. + +You accept that all who comply with the licence of the project (BSD 3-clause) are permitted to use your contributions to the project. + +You accept, and by submitting code do declare, that you have the legal right to grant such a licence to the project and that each of the contributions is your own original creation. diff --git a/vendor/github.com/microcosm-cc/bluemonday/CREDITS.md b/vendor/github.com/microcosm-cc/bluemonday/CREDITS.md new file mode 100644 index 000000000..b98873f3c --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/CREDITS.md @@ -0,0 +1,6 @@ +1. Andrew Krasichkov @buglloc https://github.com/buglloc +1. John Graham-Cumming http://jgc.org/ +1. Mike Samuel mikesamuel@gmail.com +1. Dmitri Shuralyov shurcooL@gmail.com +1. https://github.com/opennota +1. https://github.com/Gufran \ No newline at end of file diff --git a/vendor/github.com/microcosm-cc/bluemonday/LICENSE.md b/vendor/github.com/microcosm-cc/bluemonday/LICENSE.md new file mode 100644 index 000000000..f822458ed --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/LICENSE.md @@ -0,0 +1,28 @@ +Copyright (c) 2014, David Kitchen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the organisation (Microcosm) nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/microcosm-cc/bluemonday/Makefile b/vendor/github.com/microcosm-cc/bluemonday/Makefile new file mode 100644 index 000000000..b15dc74f3 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/Makefile @@ -0,0 +1,42 @@ +# Targets: +# +# all: Builds the code locally after testing +# +# fmt: Formats the source files +# build: Builds the code locally +# vet: Vets the code +# lint: Runs lint over the code (you do not need to fix everything) +# test: Runs the tests +# cover: Gives you the URL to a nice test coverage report +# +# install: Builds, tests and installs the code locally + +.PHONY: all fmt build vet lint test cover install + +# The first target is always the default action if `make` is called without +# args we build and install into $GOPATH so that it can just be run + +all: fmt vet test install + +fmt: + @gofmt -s -w ./$* + +build: + @go build + +vet: + @go vet *.go + +lint: + @golint *.go + +test: + @go test -v ./... + +cover: COVERAGE_FILE := coverage.out +cover: + @go test -coverprofile=$(COVERAGE_FILE) && \ + cover -html=$(COVERAGE_FILE) && rm $(COVERAGE_FILE) + +install: + @go install ./... diff --git a/vendor/github.com/microcosm-cc/bluemonday/README.md b/vendor/github.com/microcosm-cc/bluemonday/README.md new file mode 100644 index 000000000..ce679c10b --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/README.md @@ -0,0 +1,350 @@ +# bluemonday [![Build Status](https://travis-ci.org/microcosm-cc/bluemonday.svg?branch=master)](https://travis-ci.org/microcosm-cc/bluemonday) [![GoDoc](https://godoc.org/github.com/microcosm-cc/bluemonday?status.png)](https://godoc.org/github.com/microcosm-cc/bluemonday) [![Sourcegraph](https://sourcegraph.com/github.com/microcosm-cc/bluemonday/-/badge.svg)](https://sourcegraph.com/github.com/microcosm-cc/bluemonday?badge) + +bluemonday is a HTML sanitizer implemented in Go. It is fast and highly configurable. + +bluemonday takes untrusted user generated content as an input, and will return HTML that has been sanitised against a whitelist of approved HTML elements and attributes so that you can safely include the content in your web page. + +If you accept user generated content, and your server uses Go, you **need** bluemonday. + +The default policy for user generated content (`bluemonday.UGCPolicy().Sanitize()`) turns this: +```html +Hello World +``` + +Into a harmless: +```html +Hello World +``` + +And it turns this: +```html +XSS +``` + +Into this: +```html +XSS +``` + +Whilst still allowing this: +```html + + + +``` + +To pass through mostly unaltered (it gained a rel="nofollow" which is a good thing for user generated content): +```html + + + +``` + +It protects sites from [XSS](http://en.wikipedia.org/wiki/Cross-site_scripting) attacks. There are many [vectors for an XSS attack](https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet) and the best way to mitigate the risk is to sanitize user input against a known safe list of HTML elements and attributes. + +You should **always** run bluemonday **after** any other processing. + +If you use [blackfriday](https://github.com/russross/blackfriday) or [Pandoc](http://johnmacfarlane.net/pandoc/) then bluemonday should be run after these steps. This ensures that no insecure HTML is introduced later in your process. + +bluemonday is heavily inspired by both the [OWASP Java HTML Sanitizer](https://code.google.com/p/owasp-java-html-sanitizer/) and the [HTML Purifier](http://htmlpurifier.org/). + +## Technical Summary + +Whitelist based, you need to either build a policy describing the HTML elements and attributes to permit (and the `regexp` patterns of attributes), or use one of the supplied policies representing good defaults. + +The policy containing the whitelist is applied using a fast non-validating, forward only, token-based parser implemented in the [Go net/html library](https://godoc.org/golang.org/x/net/html) by the core Go team. + +We expect to be supplied with well-formatted HTML (closing elements for every applicable open element, nested correctly) and so we do not focus on repairing badly nested or incomplete HTML. We focus on simply ensuring that whatever elements do exist are described in the policy whitelist and that attributes and links are safe for use on your web page. [GIGO](http://en.wikipedia.org/wiki/Garbage_in,_garbage_out) does apply and if you feed it bad HTML bluemonday is not tasked with figuring out how to make it good again. + +### Supported Go Versions + +bluemonday is tested against Go 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, and tip. + +We do not support Go 1.0 as we depend on `golang.org/x/net/html` which includes a reference to `io.ErrNoProgress` which did not exist in Go 1.0. + +## Is it production ready? + +*Yes* + +We are using bluemonday in production having migrated from the widely used and heavily field tested OWASP Java HTML Sanitizer. + +We are passing our extensive test suite (including AntiSamy tests as well as tests for any issues raised). Check for any [unresolved issues](https://github.com/microcosm-cc/bluemonday/issues?page=1&state=open) to see whether anything may be a blocker for you. + +We invite pull requests and issues to help us ensure we are offering comprehensive protection against various attacks via user generated content. + +## Usage + +Install in your `${GOPATH}` using `go get -u github.com/microcosm-cc/bluemonday` + +Then call it: +```go +package main + +import ( + "fmt" + + "github.com/microcosm-cc/bluemonday" +) + +func main() { + // Do this once for each unique policy, and use the policy for the life of the program + // Policy creation/editing is not safe to use in multiple goroutines + p := bluemonday.UGCPolicy() + + // The policy can then be used to sanitize lots of input and it is safe to use the policy in multiple goroutines + html := p.Sanitize( + `Google`, + ) + + // Output: + // Google + fmt.Println(html) +} +``` + +We offer three ways to call Sanitize: +```go +p.Sanitize(string) string +p.SanitizeBytes([]byte) []byte +p.SanitizeReader(io.Reader) bytes.Buffer +``` + +If you are obsessed about performance, `p.SanitizeReader(r).Bytes()` will return a `[]byte` without performing any unnecessary casting of the inputs or outputs. Though the difference is so negligible you should never need to care. + +You can build your own policies: +```go +package main + +import ( + "fmt" + + "github.com/microcosm-cc/bluemonday" +) + +func main() { + p := bluemonday.NewPolicy() + + // Require URLs to be parseable by net/url.Parse and either: + // mailto: http:// or https:// + p.AllowStandardURLs() + + // We only allow

    and + p.AllowAttrs("href").OnElements("a") + p.AllowElements("p") + + html := p.Sanitize( + `Google`, + ) + + // Output: + // Google + fmt.Println(html) +} +``` + +We ship two default policies: + +1. `bluemonday.StrictPolicy()` which can be thought of as equivalent to stripping all HTML elements and their attributes as it has nothing on its whitelist. An example usage scenario would be blog post titles where HTML tags are not expected at all and if they are then the elements *and* the content of the elements should be stripped. This is a *very* strict policy. +2. `bluemonday.UGCPolicy()` which allows a broad selection of HTML elements and attributes that are safe for user generated content. Note that this policy does *not* whitelist iframes, object, embed, styles, script, etc. An example usage scenario would be blog post bodies where a variety of formatting is expected along with the potential for TABLEs and IMGs. + +## Policy Building + +The essence of building a policy is to determine which HTML elements and attributes are considered safe for your scenario. OWASP provide an [XSS prevention cheat sheet](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet) to help explain the risks, but essentially: + +1. Avoid anything other than the standard HTML elements +1. Avoid `script`, `style`, `iframe`, `object`, `embed`, `base` elements that allow code to be executed by the client or third party content to be included that can execute code +1. Avoid anything other than plain HTML attributes with values matched to a regexp + +Basically, you should be able to describe what HTML is fine for your scenario. If you do not have confidence that you can describe your policy please consider using one of the shipped policies such as `bluemonday.UGCPolicy()`. + +To create a new policy: +```go +p := bluemonday.NewPolicy() +``` + +To add elements to a policy either add just the elements: +```go +p.AllowElements("b", "strong") +``` + +Or add elements as a virtue of adding an attribute: +```go +// Not the recommended pattern, see the recommendation on using .Matching() below +p.AllowAttrs("nowrap").OnElements("td", "th") +``` + +Attributes can either be added to all elements: +```go +p.AllowAttrs("dir").Matching(regexp.MustCompile("(?i)rtl|ltr")).Globally() +``` + +Or attributes can be added to specific elements: +```go +// Not the recommended pattern, see the recommendation on using .Matching() below +p.AllowAttrs("value").OnElements("li") +``` + +It is **always** recommended that an attribute be made to match a pattern. XSS in HTML attributes is very easy otherwise: +```go +// \p{L} matches unicode letters, \p{N} matches unicode numbers +p.AllowAttrs("title").Matching(regexp.MustCompile(`[\p{L}\p{N}\s\-_',:\[\]!\./\\\(\)&]*`)).Globally() +``` + +You can stop at any time and call .Sanitize(): +```go +// string htmlIn passed in from a HTTP POST +htmlOut := p.Sanitize(htmlIn) +``` + +And you can take any existing policy and extend it: +```go +p := bluemonday.UGCPolicy() +p.AllowElements("fieldset", "select", "option") +``` + +### Links + +Links are difficult beasts to sanitise safely and also one of the biggest attack vectors for malicious content. + +It is possible to do this: +```go +p.AllowAttrs("href").Matching(regexp.MustCompile(`(?i)mailto|https?`)).OnElements("a") +``` + +But that will not protect you as the regular expression is insufficient in this case to have prevented a malformed value doing something unexpected. + +We provide some additional global options for safely working with links. + +`RequireParseableURLs` will ensure that URLs are parseable by Go's `net/url` package: +```go +p.RequireParseableURLs(true) +``` + +If you have enabled parseable URLs then the following option will `AllowRelativeURLs`. By default this is disabled (bluemonday is a whitelist tool... you need to explicitly tell us to permit things) and when disabled it will prevent all local and scheme relative URLs (i.e. `href="localpage.html"`, `href="../home.html"` and even `href="//www.google.com"` are relative): +```go +p.AllowRelativeURLs(true) +``` + +If you have enabled parseable URLs then you can whitelist the schemes (commonly called protocol when thinking of `http` and `https`) that are permitted. Bear in mind that allowing relative URLs in the above option will allow for a blank scheme: +```go +p.AllowURLSchemes("mailto", "http", "https") +``` + +Regardless of whether you have enabled parseable URLs, you can force all URLs to have a rel="nofollow" attribute. This will be added if it does not exist, but only when the `href` is valid: +```go +// This applies to "a" "area" "link" elements that have a "href" attribute +p.RequireNoFollowOnLinks(true) +``` + +We provide a convenience method that applies all of the above, but you will still need to whitelist the linkable elements for the URL rules to be applied to: +```go +p.AllowStandardURLs() +p.AllowAttrs("cite").OnElements("blockquote", "q") +p.AllowAttrs("href").OnElements("a", "area") +p.AllowAttrs("src").OnElements("img") +``` + +An additional complexity regarding links is the data URI as defined in [RFC2397](http://tools.ietf.org/html/rfc2397). The data URI allows for images to be served inline using this format: + +```html + +``` + +We have provided a helper to verify the mimetype followed by base64 content of data URIs links: + +```go +p.AllowDataURIImages() +``` + +That helper will enable GIF, JPEG, PNG and WEBP images. + +It should be noted that there is a potential [security](http://palizine.plynt.com/issues/2010Oct/bypass-xss-filters/) [risk](https://capec.mitre.org/data/definitions/244.html) with the use of data URI links. You should only enable data URI links if you already trust the content. + +We also have some features to help deal with user generated content: +```go +p.AddTargetBlankToFullyQualifiedLinks(true) +``` + +This will ensure that anchor `` links that are fully qualified (the href destination includes a host name) will get `target="_blank"` added to them. + +Additionally any link that has `target="_blank"` after the policy has been applied will also have the `rel` attribute adjusted to add `noopener`. This means a link may start like `` and will end up as ``. It is important to note that the addition of `noopener` is a security feature and not an issue. There is an unfortunate feature to browsers that a browser window opened as a result of `target="_blank"` can still control the opener (your web page) and this protects against that. The background to this can be found here: [https://dev.to/ben/the-targetblank-vulnerability-by-example](https://dev.to/ben/the-targetblank-vulnerability-by-example) + +### Policy Building Helpers + +We also bundle some helpers to simplify policy building: +```go + +// Permits the "dir", "id", "lang", "title" attributes globally +p.AllowStandardAttributes() + +// Permits the "img" element and its standard attributes +p.AllowImages() + +// Permits ordered and unordered lists, and also definition lists +p.AllowLists() + +// Permits HTML tables and all applicable elements and non-styling attributes +p.AllowTables() +``` + +### Invalid Instructions + +The following are invalid: +```go +// This does not say where the attributes are allowed, you need to add +// .Globally() or .OnElements(...) +// This will be ignored without error. +p.AllowAttrs("value") + +// This does not say where the attributes are allowed, you need to add +// .Globally() or .OnElements(...) +// This will be ignored without error. +p.AllowAttrs( + "type", +).Matching( + regexp.MustCompile("(?i)^(circle|disc|square|a|A|i|I|1)$"), +) +``` + +Both examples exhibit the same issue, they declare attributes but do not then specify whether they are whitelisted globally or only on specific elements (and which elements). Attributes belong to one or more elements, and the policy needs to declare this. + +## Limitations + +We are not yet including any tools to help whitelist and sanitize CSS. Which means that unless you wish to do the heavy lifting in a single regular expression (inadvisable), **you should not allow the "style" attribute anywhere**. + +It is not the job of bluemonday to fix your bad HTML, it is merely the job of bluemonday to prevent malicious HTML getting through. If you have mismatched HTML elements, or non-conforming nesting of elements, those will remain. But if you have well-structured HTML bluemonday will not break it. + +## TODO + +* Add support for CSS sanitisation to allow some CSS properties based on a whitelist, possibly using the [Gorilla CSS3 scanner](http://www.gorillatoolkit.org/pkg/css/scanner) - PRs welcome so long as testing covers XSS and demonstrates safety first +* Investigate whether devs want to blacklist elements and attributes. This would allow devs to take an existing policy (such as the `bluemonday.UGCPolicy()` ) that encapsulates 90% of what they're looking for but does more than they need, and to remove the extra things they do not want to make it 100% what they want +* Investigate whether devs want a validating HTML mode, in which the HTML elements are not just transformed into a balanced tree (every start tag has a closing tag at the correct depth) but also that elements and character data appear only in their allowed context (i.e. that a `table` element isn't a descendent of a `caption`, that `colgroup`, `thead`, `tbody`, `tfoot` and `tr` are permitted, and that character data is not permitted) + +## Development + +If you have cloned this repo you will probably need the dependency: + +`go get golang.org/x/net/html` + +Gophers can use their familiar tools: + +`go build` + +`go test` + +I personally use a Makefile as it spares typing the same args over and over whilst providing consistency for those of us who jump from language to language and enjoy just typing `make` in a project directory and watch magic happen. + +`make` will build, vet, test and install the library. + +`make clean` will remove the library from a *single* `${GOPATH}/pkg` directory tree + +`make test` will run the tests + +`make cover` will run the tests and *open a browser window* with the coverage report + +`make lint` will run golint (install via `go get github.com/golang/lint/golint`) + +## Long term goals + +1. Open the code to adversarial peer review similar to the [Attack Review Ground Rules](https://code.google.com/p/owasp-java-html-sanitizer/wiki/AttackReviewGroundRules) +1. Raise funds and pay for an external security review diff --git a/vendor/github.com/microcosm-cc/bluemonday/doc.go b/vendor/github.com/microcosm-cc/bluemonday/doc.go new file mode 100644 index 000000000..71dab6089 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/doc.go @@ -0,0 +1,104 @@ +// Copyright (c) 2014, David Kitchen +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the organisation (Microcosm) nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/* +Package bluemonday provides a way of describing a whitelist of HTML elements +and attributes as a policy, and for that policy to be applied to untrusted +strings from users that may contain markup. All elements and attributes not on +the whitelist will be stripped. + +The default bluemonday.UGCPolicy().Sanitize() turns this: + + Hello World + +Into the more harmless: + + Hello World + +And it turns this: + + XSS + +Into this: + + XSS + +Whilst still allowing this: + + + + + +To pass through mostly unaltered (it gained a rel="nofollow"): + + + + + +The primary purpose of bluemonday is to take potentially unsafe user generated +content (from things like Markdown, HTML WYSIWYG tools, etc) and make it safe +for you to put on your website. + +It protects sites against XSS (http://en.wikipedia.org/wiki/Cross-site_scripting) +and other malicious content that a user interface may deliver. There are many +vectors for an XSS attack (https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet) +and the safest thing to do is to sanitize user input against a known safe list +of HTML elements and attributes. + +Note: You should always run bluemonday after any other processing. + +If you use blackfriday (https://github.com/russross/blackfriday) or +Pandoc (http://johnmacfarlane.net/pandoc/) then bluemonday should be run after +these steps. This ensures that no insecure HTML is introduced later in your +process. + +bluemonday is heavily inspired by both the OWASP Java HTML Sanitizer +(https://code.google.com/p/owasp-java-html-sanitizer/) and the HTML Purifier +(http://htmlpurifier.org/). + +We ship two default policies, one is bluemonday.StrictPolicy() and can be +thought of as equivalent to stripping all HTML elements and their attributes as +it has nothing on its whitelist. + +The other is bluemonday.UGCPolicy() and allows a broad selection of HTML +elements and attributes that are safe for user generated content. Note that +this policy does not whitelist iframes, object, embed, styles, script, etc. + +The essence of building a policy is to determine which HTML elements and +attributes are considered safe for your scenario. OWASP provide an XSS +prevention cheat sheet ( https://www.google.com/search?q=xss+prevention+cheat+sheet ) +to help explain the risks, but essentially: + + 1. Avoid whitelisting anything other than plain HTML elements + 2. Avoid whitelisting `script`, `style`, `iframe`, `object`, `embed`, `base` + elements + 3. Avoid whitelisting anything other than plain HTML elements with simple + values that you can match to a regexp +*/ +package bluemonday diff --git a/vendor/github.com/microcosm-cc/bluemonday/go.mod b/vendor/github.com/microcosm-cc/bluemonday/go.mod new file mode 100644 index 000000000..fa8453c5f --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/go.mod @@ -0,0 +1,5 @@ +module github.com/microcosm-cc/bluemonday + +go 1.9 + +require golang.org/x/net v0.0.0-20181220203305-927f97764cc3 diff --git a/vendor/github.com/microcosm-cc/bluemonday/go.sum b/vendor/github.com/microcosm-cc/bluemonday/go.sum new file mode 100644 index 000000000..bee241d1c --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/vendor/github.com/microcosm-cc/bluemonday/helpers.go b/vendor/github.com/microcosm-cc/bluemonday/helpers.go new file mode 100644 index 000000000..dfa5868da --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/helpers.go @@ -0,0 +1,297 @@ +// Copyright (c) 2014, David Kitchen +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the organisation (Microcosm) nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package bluemonday + +import ( + "encoding/base64" + "net/url" + "regexp" +) + +// A selection of regular expressions that can be used as .Matching() rules on +// HTML attributes. +var ( + // CellAlign handles the `align` attribute + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#attr-align + CellAlign = regexp.MustCompile(`(?i)^(center|justify|left|right|char)$`) + + // CellVerticalAlign handles the `valign` attribute + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#attr-valign + CellVerticalAlign = regexp.MustCompile(`(?i)^(baseline|bottom|middle|top)$`) + + // Direction handles the `dir` attribute + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/bdo#attr-dir + Direction = regexp.MustCompile(`(?i)^(rtl|ltr)$`) + + // ImageAlign handles the `align` attribute on the `image` tag + // http://www.w3.org/MarkUp/Test/Img/imgtest.html + ImageAlign = regexp.MustCompile( + `(?i)^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$`, + ) + + // Integer describes whole positive integers (including 0) used in places + // like td.colspan + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#attr-colspan + Integer = regexp.MustCompile(`^[0-9]+$`) + + // ISO8601 according to the W3 group is only a subset of the ISO8601 + // standard: http://www.w3.org/TR/NOTE-datetime + // + // Used in places like time.datetime + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time#attr-datetime + // + // Matches patterns: + // Year: + // YYYY (eg 1997) + // Year and month: + // YYYY-MM (eg 1997-07) + // Complete date: + // YYYY-MM-DD (eg 1997-07-16) + // Complete date plus hours and minutes: + // YYYY-MM-DDThh:mmTZD (eg 1997-07-16T19:20+01:00) + // Complete date plus hours, minutes and seconds: + // YYYY-MM-DDThh:mm:ssTZD (eg 1997-07-16T19:20:30+01:00) + // Complete date plus hours, minutes, seconds and a decimal fraction of a + // second + // YYYY-MM-DDThh:mm:ss.sTZD (eg 1997-07-16T19:20:30.45+01:00) + ISO8601 = regexp.MustCompile( + `^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})` + + `?Z?([\+-][0-9]{2}:[0-9]{2})?)?)?)?$`, + ) + + // ListType encapsulates the common value as well as the latest spec + // values for lists + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ol#attr-type + ListType = regexp.MustCompile(`(?i)^(circle|disc|square|a|A|i|I|1)$`) + + // SpaceSeparatedTokens is used in places like `a.rel` and the common attribute + // `class` which both contain space delimited lists of data tokens + // http://www.w3.org/TR/html-markup/datatypes.html#common.data.tokens-def + // Regexp: \p{L} matches unicode letters, \p{N} matches unicode numbers + SpaceSeparatedTokens = regexp.MustCompile(`^([\s\p{L}\p{N}_-]+)$`) + + // Number is a double value used on HTML5 meter and progress elements + // http://www.whatwg.org/specs/web-apps/current-work/multipage/the-button-element.html#the-meter-element + Number = regexp.MustCompile(`^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$`) + + // NumberOrPercent is used predominantly as units of measurement in width + // and height attributes + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-height + NumberOrPercent = regexp.MustCompile(`^[0-9]+[%]?$`) + + // Paragraph of text in an attribute such as *.'title', img.alt, etc + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes#attr-title + // Note that we are not allowing chars that could close tags like '>' + Paragraph = regexp.MustCompile(`^[\p{L}\p{N}\s\-_',\[\]!\./\\\(\)]*$`) + + // dataURIImagePrefix is used by AllowDataURIImages to define the acceptable + // prefix of data URIs that contain common web image formats. + // + // This is not exported as it's not useful by itself, and only has value + // within the AllowDataURIImages func + dataURIImagePrefix = regexp.MustCompile( + `^image/(gif|jpeg|png|webp);base64,`, + ) +) + +// AllowStandardURLs is a convenience function that will enable rel="nofollow" +// on "a", "area" and "link" (if you have allowed those elements) and will +// ensure that the URL values are parseable and either relative or belong to the +// "mailto", "http", or "https" schemes +func (p *Policy) AllowStandardURLs() { + // URLs must be parseable by net/url.Parse() + p.RequireParseableURLs(true) + + // !url.IsAbs() is permitted + p.AllowRelativeURLs(true) + + // Most common URL schemes only + p.AllowURLSchemes("mailto", "http", "https") + + // For all anchors we will add rel="nofollow" if it does not already exist + // This applies to "a" "area" "link" + p.RequireNoFollowOnLinks(true) +} + +// AllowStandardAttributes will enable "id", "title" and the language specific +// attributes "dir" and "lang" on all elements that are whitelisted +func (p *Policy) AllowStandardAttributes() { + // "dir" "lang" are permitted as both language attributes affect charsets + // and direction of text. + p.AllowAttrs("dir").Matching(Direction).Globally() + p.AllowAttrs( + "lang", + ).Matching(regexp.MustCompile(`[a-zA-Z]{2,20}`)).Globally() + + // "id" is permitted. This is pretty much as some HTML elements require this + // to work well ("dfn" is an example of a "id" being value) + // This does create a risk that JavaScript and CSS within your web page + // might identify the wrong elements. Ensure that you select things + // accurately + p.AllowAttrs("id").Matching( + regexp.MustCompile(`[a-zA-Z0-9\:\-_\.]+`), + ).Globally() + + // "title" is permitted as it improves accessibility. + p.AllowAttrs("title").Matching(Paragraph).Globally() +} + +// AllowStyling presently enables the class attribute globally. +// +// Note: When bluemonday ships a CSS parser and we can safely sanitise that, +// this will also allow sanitized styling of elements via the style attribute. +func (p *Policy) AllowStyling() { + + // "class" is permitted globally + p.AllowAttrs("class").Matching(SpaceSeparatedTokens).Globally() +} + +// AllowImages enables the img element and some popular attributes. It will also +// ensure that URL values are parseable. This helper does not enable data URI +// images, for that you should also use the AllowDataURIImages() helper. +func (p *Policy) AllowImages() { + + // "img" is permitted + p.AllowAttrs("align").Matching(ImageAlign).OnElements("img") + p.AllowAttrs("alt").Matching(Paragraph).OnElements("img") + p.AllowAttrs("height", "width").Matching(NumberOrPercent).OnElements("img") + + // Standard URLs enabled + p.AllowStandardURLs() + p.AllowAttrs("src").OnElements("img") +} + +// AllowDataURIImages permits the use of inline images defined in RFC2397 +// http://tools.ietf.org/html/rfc2397 +// http://en.wikipedia.org/wiki/Data_URI_scheme +// +// Images must have a mimetype matching: +// image/gif +// image/jpeg +// image/png +// image/webp +// +// NOTE: There is a potential security risk to allowing data URIs and you should +// only permit them on content you already trust. +// http://palizine.plynt.com/issues/2010Oct/bypass-xss-filters/ +// https://capec.mitre.org/data/definitions/244.html +func (p *Policy) AllowDataURIImages() { + + // URLs must be parseable by net/url.Parse() + p.RequireParseableURLs(true) + + // Supply a function to validate images contained within data URI + p.AllowURLSchemeWithCustomPolicy( + "data", + func(url *url.URL) (allowUrl bool) { + if url.RawQuery != "" || url.Fragment != "" { + return false + } + + matched := dataURIImagePrefix.FindString(url.Opaque) + if matched == "" { + return false + } + + _, err := base64.StdEncoding.DecodeString(url.Opaque[len(matched):]) + if err != nil { + return false + } + + return true + }, + ) +} + +// AllowLists will enabled ordered and unordered lists, as well as definition +// lists +func (p *Policy) AllowLists() { + // "ol" "ul" are permitted + p.AllowAttrs("type").Matching(ListType).OnElements("ol", "ul") + + // "li" is permitted + p.AllowAttrs("type").Matching(ListType).OnElements("li") + p.AllowAttrs("value").Matching(Integer).OnElements("li") + + // "dl" "dt" "dd" are permitted + p.AllowElements("dl", "dt", "dd") +} + +// AllowTables will enable a rich set of elements and attributes to describe +// HTML tables +func (p *Policy) AllowTables() { + + // "table" is permitted + p.AllowAttrs("height", "width").Matching(NumberOrPercent).OnElements("table") + p.AllowAttrs("summary").Matching(Paragraph).OnElements("table") + + // "caption" is permitted + p.AllowElements("caption") + + // "col" "colgroup" are permitted + p.AllowAttrs("align").Matching(CellAlign).OnElements("col", "colgroup") + p.AllowAttrs("height", "width").Matching( + NumberOrPercent, + ).OnElements("col", "colgroup") + p.AllowAttrs("span").Matching(Integer).OnElements("colgroup", "col") + p.AllowAttrs("valign").Matching( + CellVerticalAlign, + ).OnElements("col", "colgroup") + + // "thead" "tr" are permitted + p.AllowAttrs("align").Matching(CellAlign).OnElements("thead", "tr") + p.AllowAttrs("valign").Matching(CellVerticalAlign).OnElements("thead", "tr") + + // "td" "th" are permitted + p.AllowAttrs("abbr").Matching(Paragraph).OnElements("td", "th") + p.AllowAttrs("align").Matching(CellAlign).OnElements("td", "th") + p.AllowAttrs("colspan", "rowspan").Matching(Integer).OnElements("td", "th") + p.AllowAttrs("headers").Matching( + SpaceSeparatedTokens, + ).OnElements("td", "th") + p.AllowAttrs("height", "width").Matching( + NumberOrPercent, + ).OnElements("td", "th") + p.AllowAttrs( + "scope", + ).Matching( + regexp.MustCompile(`(?i)(?:row|col)(?:group)?`), + ).OnElements("td", "th") + p.AllowAttrs("valign").Matching(CellVerticalAlign).OnElements("td", "th") + p.AllowAttrs("nowrap").Matching( + regexp.MustCompile(`(?i)|nowrap`), + ).OnElements("td", "th") + + // "tbody" "tfoot" + p.AllowAttrs("align").Matching(CellAlign).OnElements("tbody", "tfoot") + p.AllowAttrs("valign").Matching( + CellVerticalAlign, + ).OnElements("tbody", "tfoot") +} diff --git a/vendor/github.com/microcosm-cc/bluemonday/policies.go b/vendor/github.com/microcosm-cc/bluemonday/policies.go new file mode 100644 index 000000000..570bba886 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/policies.go @@ -0,0 +1,253 @@ +// Copyright (c) 2014, David Kitchen +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the organisation (Microcosm) nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package bluemonday + +import ( + "regexp" +) + +// StrictPolicy returns an empty policy, which will effectively strip all HTML +// elements and their attributes from a document. +func StrictPolicy() *Policy { + return NewPolicy() +} + +// StripTagsPolicy is DEPRECATED. Use StrictPolicy instead. +func StripTagsPolicy() *Policy { + return StrictPolicy() +} + +// UGCPolicy returns a policy aimed at user generated content that is a result +// of HTML WYSIWYG tools and Markdown conversions. +// +// This is expected to be a fairly rich document where as much markup as +// possible should be retained. Markdown permits raw HTML so we are basically +// providing a policy to sanitise HTML5 documents safely but with the +// least intrusion on the formatting expectations of the user. +func UGCPolicy() *Policy { + + p := NewPolicy() + + /////////////////////// + // Global attributes // + /////////////////////// + + // "class" is not permitted as we are not allowing users to style their own + // content + + p.AllowStandardAttributes() + + ////////////////////////////// + // Global URL format policy // + ////////////////////////////// + + p.AllowStandardURLs() + + //////////////////////////////// + // Declarations and structure // + //////////////////////////////// + + // "xml" "xslt" "DOCTYPE" "html" "head" are not permitted as we are + // expecting user generated content to be a fragment of HTML and not a full + // document. + + ////////////////////////// + // Sectioning root tags // + ////////////////////////// + + // "article" and "aside" are permitted and takes no attributes + p.AllowElements("article", "aside") + + // "body" is not permitted as we are expecting user generated content to be a fragment + // of HTML and not a full document. + + // "details" is permitted, including the "open" attribute which can either + // be blank or the value "open". + p.AllowAttrs( + "open", + ).Matching(regexp.MustCompile(`(?i)^(|open)$`)).OnElements("details") + + // "fieldset" is not permitted as we are not allowing forms to be created. + + // "figure" is permitted and takes no attributes + p.AllowElements("figure") + + // "nav" is not permitted as it is assumed that the site (and not the user) + // has defined navigation elements + + // "section" is permitted and takes no attributes + p.AllowElements("section") + + // "summary" is permitted and takes no attributes + p.AllowElements("summary") + + ////////////////////////// + // Headings and footers // + ////////////////////////// + + // "footer" is not permitted as we expect user content to be a fragment and + // not structural to this extent + + // "h1" through "h6" are permitted and take no attributes + p.AllowElements("h1", "h2", "h3", "h4", "h5", "h6") + + // "header" is not permitted as we expect user content to be a fragment and + // not structural to this extent + + // "hgroup" is permitted and takes no attributes + p.AllowElements("hgroup") + + ///////////////////////////////////// + // Content grouping and separating // + ///////////////////////////////////// + + // "blockquote" is permitted, including the "cite" attribute which must be + // a standard URL. + p.AllowAttrs("cite").OnElements("blockquote") + + // "br" "div" "hr" "p" "span" "wbr" are permitted and take no attributes + p.AllowElements("br", "div", "hr", "p", "span", "wbr") + + /////////// + // Links // + /////////// + + // "a" is permitted + p.AllowAttrs("href").OnElements("a") + + // "area" is permitted along with the attributes that map image maps work + p.AllowAttrs("name").Matching( + regexp.MustCompile(`^([\p{L}\p{N}_-]+)$`), + ).OnElements("map") + p.AllowAttrs("alt").Matching(Paragraph).OnElements("area") + p.AllowAttrs("coords").Matching( + regexp.MustCompile(`^([0-9]+,)+[0-9]+$`), + ).OnElements("area") + p.AllowAttrs("href").OnElements("area") + p.AllowAttrs("rel").Matching(SpaceSeparatedTokens).OnElements("area") + p.AllowAttrs("shape").Matching( + regexp.MustCompile(`(?i)^(default|circle|rect|poly)$`), + ).OnElements("area") + p.AllowAttrs("usemap").Matching( + regexp.MustCompile(`(?i)^#[\p{L}\p{N}_-]+$`), + ).OnElements("img") + + // "link" is not permitted + + ///////////////////// + // Phrase elements // + ///////////////////// + + // The following are all inline phrasing elements + p.AllowElements("abbr", "acronym", "cite", "code", "dfn", "em", + "figcaption", "mark", "s", "samp", "strong", "sub", "sup", "var") + + // "q" is permitted and "cite" is a URL and handled by URL policies + p.AllowAttrs("cite").OnElements("q") + + // "time" is permitted + p.AllowAttrs("datetime").Matching(ISO8601).OnElements("time") + + //////////////////// + // Style elements // + //////////////////// + + // block and inline elements that impart no semantic meaning but style the + // document + p.AllowElements("b", "i", "pre", "small", "strike", "tt", "u") + + // "style" is not permitted as we are not yet sanitising CSS and it is an + // XSS attack vector + + ////////////////////// + // HTML5 Formatting // + ////////////////////// + + // "bdi" "bdo" are permitted + p.AllowAttrs("dir").Matching(Direction).OnElements("bdi", "bdo") + + // "rp" "rt" "ruby" are permitted + p.AllowElements("rp", "rt", "ruby") + + /////////////////////////// + // HTML5 Change tracking // + /////////////////////////// + + // "del" "ins" are permitted + p.AllowAttrs("cite").Matching(Paragraph).OnElements("del", "ins") + p.AllowAttrs("datetime").Matching(ISO8601).OnElements("del", "ins") + + /////////// + // Lists // + /////////// + + p.AllowLists() + + //////////// + // Tables // + //////////// + + p.AllowTables() + + /////////// + // Forms // + /////////// + + // By and large, forms are not permitted. However there are some form + // elements that can be used to present data, and we do permit those + // + // "button" "fieldset" "input" "keygen" "label" "output" "select" "datalist" + // "textarea" "optgroup" "option" are all not permitted + + // "meter" is permitted + p.AllowAttrs( + "value", + "min", + "max", + "low", + "high", + "optimum", + ).Matching(Number).OnElements("meter") + + // "progress" is permitted + p.AllowAttrs("value", "max").Matching(Number).OnElements("progress") + + ////////////////////// + // Embedded content // + ////////////////////// + + // Vast majority not permitted + // "audio" "canvas" "embed" "iframe" "object" "param" "source" "svg" "track" + // "video" are all not permitted + + p.AllowImages() + + return p +} diff --git a/vendor/github.com/microcosm-cc/bluemonday/policy.go b/vendor/github.com/microcosm-cc/bluemonday/policy.go new file mode 100644 index 000000000..f61d98f59 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/policy.go @@ -0,0 +1,552 @@ +// Copyright (c) 2014, David Kitchen +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the organisation (Microcosm) nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package bluemonday + +import ( + "net/url" + "regexp" + "strings" +) + +// Policy encapsulates the whitelist of HTML elements and attributes that will +// be applied to the sanitised HTML. +// +// You should use bluemonday.NewPolicy() to create a blank policy as the +// unexported fields contain maps that need to be initialized. +type Policy struct { + + // Declares whether the maps have been initialized, used as a cheap check to + // ensure that those using Policy{} directly won't cause nil pointer + // exceptions + initialized bool + + // If true then we add spaces when stripping tags, specifically the closing + // tag is replaced by a space character. + addSpaces bool + + // When true, add rel="nofollow" to HTML anchors + requireNoFollow bool + + // When true, add rel="nofollow" to HTML anchors + // Will add for href="http://foo" + // Will skip for href="/foo" or href="foo" + requireNoFollowFullyQualifiedLinks bool + + // When true add target="_blank" to fully qualified links + // Will add for href="http://foo" + // Will skip for href="/foo" or href="foo" + addTargetBlankToFullyQualifiedLinks bool + + // When true, URLs must be parseable by "net/url" url.Parse() + requireParseableURLs bool + + // When true, u, _ := url.Parse("url"); !u.IsAbs() is permitted + allowRelativeURLs bool + + // When true, allow data attributes. + allowDataAttributes bool + + // map[htmlElementName]map[htmlAttributeName]attrPolicy + elsAndAttrs map[string]map[string]attrPolicy + + // map[htmlAttributeName]attrPolicy + globalAttrs map[string]attrPolicy + + // If urlPolicy is nil, all URLs with matching schema are allowed. + // Otherwise, only the URLs with matching schema and urlPolicy(url) + // returning true are allowed. + allowURLSchemes map[string]urlPolicy + + // If an element has had all attributes removed as a result of a policy + // being applied, then the element would be removed from the output. + // + // However some elements are valid and have strong layout meaning without + // any attributes, i.e. . To prevent those being removed we maintain + // a list of elements that are allowed to have no attributes and that will + // be maintained in the output HTML. + setOfElementsAllowedWithoutAttrs map[string]struct{} + + setOfElementsToSkipContent map[string]struct{} +} + +type attrPolicy struct { + + // optional pattern to match, when not nil the regexp needs to match + // otherwise the attribute is removed + regexp *regexp.Regexp +} + +type attrPolicyBuilder struct { + p *Policy + + attrNames []string + regexp *regexp.Regexp + allowEmpty bool +} + +type urlPolicy func(url *url.URL) (allowUrl bool) + +// init initializes the maps if this has not been done already +func (p *Policy) init() { + if !p.initialized { + p.elsAndAttrs = make(map[string]map[string]attrPolicy) + p.globalAttrs = make(map[string]attrPolicy) + p.allowURLSchemes = make(map[string]urlPolicy) + p.setOfElementsAllowedWithoutAttrs = make(map[string]struct{}) + p.setOfElementsToSkipContent = make(map[string]struct{}) + p.initialized = true + } +} + +// NewPolicy returns a blank policy with nothing whitelisted or permitted. This +// is the recommended way to start building a policy and you should now use +// AllowAttrs() and/or AllowElements() to construct the whitelist of HTML +// elements and attributes. +func NewPolicy() *Policy { + + p := Policy{} + + p.addDefaultElementsWithoutAttrs() + p.addDefaultSkipElementContent() + + return &p +} + +// AllowAttrs takes a range of HTML attribute names and returns an +// attribute policy builder that allows you to specify the pattern and scope of +// the whitelisted attribute. +// +// The attribute policy is only added to the core policy when either Globally() +// or OnElements(...) are called. +func (p *Policy) AllowAttrs(attrNames ...string) *attrPolicyBuilder { + + p.init() + + abp := attrPolicyBuilder{ + p: p, + allowEmpty: false, + } + + for _, attrName := range attrNames { + abp.attrNames = append(abp.attrNames, strings.ToLower(attrName)) + } + + return &abp +} + +// AllowDataAttributes whitelists all data attributes. We can't specify the name +// of each attribute exactly as they are customized. +// +// NOTE: These values are not sanitized and applications that evaluate or process +// them without checking and verification of the input may be at risk if this option +// is enabled. This is a 'caveat emptor' option and the person enabling this option +// needs to fully understand the potential impact with regards to whatever application +// will be consuming the sanitized HTML afterwards, i.e. if you know you put a link in a +// data attribute and use that to automatically load some new window then you're giving +// the author of a HTML fragment the means to open a malicious destination automatically. +// Use with care! +func (p *Policy) AllowDataAttributes() { + p.allowDataAttributes = true +} + +// AllowNoAttrs says that attributes on element are optional. +// +// The attribute policy is only added to the core policy when OnElements(...) +// are called. +func (p *Policy) AllowNoAttrs() *attrPolicyBuilder { + + p.init() + + abp := attrPolicyBuilder{ + p: p, + allowEmpty: true, + } + return &abp +} + +// AllowNoAttrs says that attributes on element are optional. +// +// The attribute policy is only added to the core policy when OnElements(...) +// are called. +func (abp *attrPolicyBuilder) AllowNoAttrs() *attrPolicyBuilder { + + abp.allowEmpty = true + + return abp +} + +// Matching allows a regular expression to be applied to a nascent attribute +// policy, and returns the attribute policy. Calling this more than once will +// replace the existing regexp. +func (abp *attrPolicyBuilder) Matching(regex *regexp.Regexp) *attrPolicyBuilder { + + abp.regexp = regex + + return abp +} + +// OnElements will bind an attribute policy to a given range of HTML elements +// and return the updated policy +func (abp *attrPolicyBuilder) OnElements(elements ...string) *Policy { + + for _, element := range elements { + element = strings.ToLower(element) + + for _, attr := range abp.attrNames { + + if _, ok := abp.p.elsAndAttrs[element]; !ok { + abp.p.elsAndAttrs[element] = make(map[string]attrPolicy) + } + + ap := attrPolicy{} + if abp.regexp != nil { + ap.regexp = abp.regexp + } + + abp.p.elsAndAttrs[element][attr] = ap + } + + if abp.allowEmpty { + abp.p.setOfElementsAllowedWithoutAttrs[element] = struct{}{} + + if _, ok := abp.p.elsAndAttrs[element]; !ok { + abp.p.elsAndAttrs[element] = make(map[string]attrPolicy) + } + } + } + + return abp.p +} + +// Globally will bind an attribute policy to all HTML elements and return the +// updated policy +func (abp *attrPolicyBuilder) Globally() *Policy { + + for _, attr := range abp.attrNames { + if _, ok := abp.p.globalAttrs[attr]; !ok { + abp.p.globalAttrs[attr] = attrPolicy{} + } + + ap := attrPolicy{} + if abp.regexp != nil { + ap.regexp = abp.regexp + } + + abp.p.globalAttrs[attr] = ap + } + + return abp.p +} + +// AllowElements will append HTML elements to the whitelist without applying an +// attribute policy to those elements (the elements are permitted +// sans-attributes) +func (p *Policy) AllowElements(names ...string) *Policy { + p.init() + + for _, element := range names { + element = strings.ToLower(element) + + if _, ok := p.elsAndAttrs[element]; !ok { + p.elsAndAttrs[element] = make(map[string]attrPolicy) + } + } + + return p +} + +// RequireNoFollowOnLinks will result in all tags having a rel="nofollow" +// added to them if one does not already exist +// +// Note: This requires p.RequireParseableURLs(true) and will enable it. +func (p *Policy) RequireNoFollowOnLinks(require bool) *Policy { + + p.requireNoFollow = require + p.requireParseableURLs = true + + return p +} + +// RequireNoFollowOnFullyQualifiedLinks will result in all tags that point +// to a non-local destination (i.e. starts with a protocol and has a host) +// having a rel="nofollow" added to them if one does not already exist +// +// Note: This requires p.RequireParseableURLs(true) and will enable it. +func (p *Policy) RequireNoFollowOnFullyQualifiedLinks(require bool) *Policy { + + p.requireNoFollowFullyQualifiedLinks = require + p.requireParseableURLs = true + + return p +} + +// AddTargetBlankToFullyQualifiedLinks will result in all tags that point +// to a non-local destination (i.e. starts with a protocol and has a host) +// having a target="_blank" added to them if one does not already exist +// +// Note: This requires p.RequireParseableURLs(true) and will enable it. +func (p *Policy) AddTargetBlankToFullyQualifiedLinks(require bool) *Policy { + + p.addTargetBlankToFullyQualifiedLinks = require + p.requireParseableURLs = true + + return p +} + +// RequireParseableURLs will result in all URLs requiring that they be parseable +// by "net/url" url.Parse() +// This applies to: +// - a.href +// - area.href +// - blockquote.cite +// - img.src +// - link.href +// - script.src +func (p *Policy) RequireParseableURLs(require bool) *Policy { + + p.requireParseableURLs = require + + return p +} + +// AllowRelativeURLs enables RequireParseableURLs and then permits URLs that +// are parseable, have no schema information and url.IsAbs() returns false +// This permits local URLs +func (p *Policy) AllowRelativeURLs(require bool) *Policy { + + p.RequireParseableURLs(true) + p.allowRelativeURLs = require + + return p +} + +// AllowURLSchemes will append URL schemes to the whitelist +// Example: p.AllowURLSchemes("mailto", "http", "https") +func (p *Policy) AllowURLSchemes(schemes ...string) *Policy { + p.init() + + p.RequireParseableURLs(true) + + for _, scheme := range schemes { + scheme = strings.ToLower(scheme) + + // Allow all URLs with matching scheme. + p.allowURLSchemes[scheme] = nil + } + + return p +} + +// AllowURLSchemeWithCustomPolicy will append URL schemes with +// a custom URL policy to the whitelist. +// Only the URLs with matching schema and urlPolicy(url) +// returning true will be allowed. +func (p *Policy) AllowURLSchemeWithCustomPolicy( + scheme string, + urlPolicy func(url *url.URL) (allowUrl bool), +) *Policy { + + p.init() + + p.RequireParseableURLs(true) + + scheme = strings.ToLower(scheme) + + p.allowURLSchemes[scheme] = urlPolicy + + return p +} + +// AddSpaceWhenStrippingTag states whether to add a single space " " when +// removing tags that are not whitelisted by the policy. +// +// This is useful if you expect to strip tags in dense markup and may lose the +// value of whitespace. +// +// For example: "

    Hello

    World

    "" would be sanitized to "HelloWorld" +// with the default value of false, but you may wish to sanitize this to +// " Hello World " by setting AddSpaceWhenStrippingTag to true as this would +// retain the intent of the text. +func (p *Policy) AddSpaceWhenStrippingTag(allow bool) *Policy { + + p.addSpaces = allow + + return p +} + +// SkipElementsContent adds the HTML elements whose tags is needed to be removed +// with its content. +func (p *Policy) SkipElementsContent(names ...string) *Policy { + + p.init() + + for _, element := range names { + element = strings.ToLower(element) + + if _, ok := p.setOfElementsToSkipContent[element]; !ok { + p.setOfElementsToSkipContent[element] = struct{}{} + } + } + + return p +} + +// AllowElementsContent marks the HTML elements whose content should be +// retained after removing the tag. +func (p *Policy) AllowElementsContent(names ...string) *Policy { + + p.init() + + for _, element := range names { + delete(p.setOfElementsToSkipContent, strings.ToLower(element)) + } + + return p +} + +// addDefaultElementsWithoutAttrs adds the HTML elements that we know are valid +// without any attributes to an internal map. +// i.e. we know that
    is valid, but isn't valid as the "dir" attr +// is mandatory +func (p *Policy) addDefaultElementsWithoutAttrs() { + p.init() + + p.setOfElementsAllowedWithoutAttrs["abbr"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["acronym"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["address"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["article"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["aside"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["audio"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["b"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["bdi"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["blockquote"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["body"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["br"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["button"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["canvas"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["caption"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["center"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["cite"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["code"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["col"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["colgroup"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["datalist"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["dd"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["del"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["details"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["dfn"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["div"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["dl"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["dt"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["em"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["fieldset"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["figcaption"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["figure"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["footer"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h1"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h2"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h3"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h4"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h5"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["h6"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["head"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["header"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["hgroup"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["hr"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["html"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["i"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["ins"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["kbd"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["li"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["mark"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["marquee"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["nav"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["ol"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["optgroup"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["option"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["p"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["pre"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["q"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["rp"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["rt"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["ruby"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["s"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["samp"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["script"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["section"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["select"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["small"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["span"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["strike"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["strong"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["style"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["sub"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["summary"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["sup"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["svg"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["table"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["tbody"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["td"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["textarea"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["tfoot"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["th"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["thead"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["title"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["time"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["tr"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["tt"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["u"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["ul"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["var"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["video"] = struct{}{} + p.setOfElementsAllowedWithoutAttrs["wbr"] = struct{}{} + +} + +// addDefaultSkipElementContent adds the HTML elements that we should skip +// rendering the character content of, if the element itself is not allowed. +// This is all character data that the end user would not normally see. +// i.e. if we exclude a tag. +func (p *Policy) addDefaultSkipElementContent() { + p.init() + + p.setOfElementsToSkipContent["frame"] = struct{}{} + p.setOfElementsToSkipContent["frameset"] = struct{}{} + p.setOfElementsToSkipContent["iframe"] = struct{}{} + p.setOfElementsToSkipContent["noembed"] = struct{}{} + p.setOfElementsToSkipContent["noframes"] = struct{}{} + p.setOfElementsToSkipContent["noscript"] = struct{}{} + p.setOfElementsToSkipContent["nostyle"] = struct{}{} + p.setOfElementsToSkipContent["object"] = struct{}{} + p.setOfElementsToSkipContent["script"] = struct{}{} + p.setOfElementsToSkipContent["style"] = struct{}{} + p.setOfElementsToSkipContent["title"] = struct{}{} +} diff --git a/vendor/github.com/microcosm-cc/bluemonday/sanitize.go b/vendor/github.com/microcosm-cc/bluemonday/sanitize.go new file mode 100644 index 000000000..65ed89b79 --- /dev/null +++ b/vendor/github.com/microcosm-cc/bluemonday/sanitize.go @@ -0,0 +1,581 @@ +// Copyright (c) 2014, David Kitchen +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the organisation (Microcosm) nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package bluemonday + +import ( + "bytes" + "io" + "net/url" + "regexp" + "strings" + + "golang.org/x/net/html" +) + +var ( + dataAttribute = regexp.MustCompile("^data-.+") + dataAttributeXMLPrefix = regexp.MustCompile("^xml.+") + dataAttributeInvalidChars = regexp.MustCompile("[A-Z;]+") +) + +// Sanitize takes a string that contains a HTML fragment or document and applies +// the given policy whitelist. +// +// It returns a HTML string that has been sanitized by the policy or an empty +// string if an error has occurred (most likely as a consequence of extremely +// malformed input) +func (p *Policy) Sanitize(s string) string { + if strings.TrimSpace(s) == "" { + return s + } + + return p.sanitize(strings.NewReader(s)).String() +} + +// SanitizeBytes takes a []byte that contains a HTML fragment or document and applies +// the given policy whitelist. +// +// It returns a []byte containing the HTML that has been sanitized by the policy +// or an empty []byte if an error has occurred (most likely as a consequence of +// extremely malformed input) +func (p *Policy) SanitizeBytes(b []byte) []byte { + if len(bytes.TrimSpace(b)) == 0 { + return b + } + + return p.sanitize(bytes.NewReader(b)).Bytes() +} + +// SanitizeReader takes an io.Reader that contains a HTML fragment or document +// and applies the given policy whitelist. +// +// It returns a bytes.Buffer containing the HTML that has been sanitized by the +// policy. Errors during sanitization will merely return an empty result. +func (p *Policy) SanitizeReader(r io.Reader) *bytes.Buffer { + return p.sanitize(r) +} + +// Performs the actual sanitization process. +func (p *Policy) sanitize(r io.Reader) *bytes.Buffer { + + // It is possible that the developer has created the policy via: + // p := bluemonday.Policy{} + // rather than: + // p := bluemonday.NewPolicy() + // If this is the case, and if they haven't yet triggered an action that + // would initiliaze the maps, then we need to do that. + p.init() + + var ( + buff bytes.Buffer + skipElementContent bool + skippingElementsCount int64 + skipClosingTag bool + closingTagToSkipStack []string + mostRecentlyStartedToken string + ) + + tokenizer := html.NewTokenizer(r) + for { + if tokenizer.Next() == html.ErrorToken { + err := tokenizer.Err() + if err == io.EOF { + // End of input means end of processing + return &buff + } + + // Raw tokenizer error + return &bytes.Buffer{} + } + + token := tokenizer.Token() + switch token.Type { + case html.DoctypeToken: + + // DocType is not handled as there is no safe parsing mechanism + // provided by golang.org/x/net/html for the content, and this can + // be misused to insert HTML tags that are not then sanitized + // + // One might wish to recursively sanitize here using the same policy + // but I will need to do some further testing before considering + // this. + + case html.CommentToken: + + // Comments are ignored by default + + case html.StartTagToken: + + mostRecentlyStartedToken = token.Data + + aps, ok := p.elsAndAttrs[token.Data] + if !ok { + if _, ok := p.setOfElementsToSkipContent[token.Data]; ok { + skipElementContent = true + skippingElementsCount++ + } + if p.addSpaces { + buff.WriteString(" ") + } + break + } + + if len(token.Attr) != 0 { + token.Attr = p.sanitizeAttrs(token.Data, token.Attr, aps) + } + + if len(token.Attr) == 0 { + if !p.allowNoAttrs(token.Data) { + skipClosingTag = true + closingTagToSkipStack = append(closingTagToSkipStack, token.Data) + if p.addSpaces { + buff.WriteString(" ") + } + break + } + } + + if !skipElementContent { + buff.WriteString(token.String()) + } + + case html.EndTagToken: + + if mostRecentlyStartedToken == token.Data { + mostRecentlyStartedToken = "" + } + + if skipClosingTag && closingTagToSkipStack[len(closingTagToSkipStack)-1] == token.Data { + closingTagToSkipStack = closingTagToSkipStack[:len(closingTagToSkipStack)-1] + if len(closingTagToSkipStack) == 0 { + skipClosingTag = false + } + if p.addSpaces { + buff.WriteString(" ") + } + break + } + + if _, ok := p.elsAndAttrs[token.Data]; !ok { + if _, ok := p.setOfElementsToSkipContent[token.Data]; ok { + skippingElementsCount-- + if skippingElementsCount == 0 { + skipElementContent = false + } + } + if p.addSpaces { + buff.WriteString(" ") + } + break + } + + if !skipElementContent { + buff.WriteString(token.String()) + } + + case html.SelfClosingTagToken: + + aps, ok := p.elsAndAttrs[token.Data] + if !ok { + if p.addSpaces { + buff.WriteString(" ") + } + break + } + + if len(token.Attr) != 0 { + token.Attr = p.sanitizeAttrs(token.Data, token.Attr, aps) + } + + if len(token.Attr) == 0 && !p.allowNoAttrs(token.Data) { + if p.addSpaces { + buff.WriteString(" ") + } + break + } + + if !skipElementContent { + buff.WriteString(token.String()) + } + + case html.TextToken: + + if !skipElementContent { + switch mostRecentlyStartedToken { + case "script": + // not encouraged, but if a policy allows JavaScript we + // should not HTML escape it as that would break the output + buff.WriteString(token.Data) + case "style": + // not encouraged, but if a policy allows CSS styles we + // should not HTML escape it as that would break the output + buff.WriteString(token.Data) + default: + // HTML escape the text + buff.WriteString(token.String()) + } + } + default: + // A token that didn't exist in the html package when we wrote this + return &bytes.Buffer{} + } + } +} + +// sanitizeAttrs takes a set of element attribute policies and the global +// attribute policies and applies them to the []html.Attribute returning a set +// of html.Attributes that match the policies +func (p *Policy) sanitizeAttrs( + elementName string, + attrs []html.Attribute, + aps map[string]attrPolicy, +) []html.Attribute { + + if len(attrs) == 0 { + return attrs + } + + // Builds a new attribute slice based on the whether the attribute has been + // whitelisted explicitly or globally. + cleanAttrs := []html.Attribute{} + for _, htmlAttr := range attrs { + if p.allowDataAttributes { + // If we see a data attribute, let it through. + if isDataAttribute(htmlAttr.Key) { + cleanAttrs = append(cleanAttrs, htmlAttr) + continue + } + } + // Is there an element specific attribute policy that applies? + if ap, ok := aps[htmlAttr.Key]; ok { + if ap.regexp != nil { + if ap.regexp.MatchString(htmlAttr.Val) { + cleanAttrs = append(cleanAttrs, htmlAttr) + continue + } + } else { + cleanAttrs = append(cleanAttrs, htmlAttr) + continue + } + } + + // Is there a global attribute policy that applies? + if ap, ok := p.globalAttrs[htmlAttr.Key]; ok { + + if ap.regexp != nil { + if ap.regexp.MatchString(htmlAttr.Val) { + cleanAttrs = append(cleanAttrs, htmlAttr) + } + } else { + cleanAttrs = append(cleanAttrs, htmlAttr) + } + } + } + + if len(cleanAttrs) == 0 { + // If nothing was allowed, let's get out of here + return cleanAttrs + } + // cleanAttrs now contains the attributes that are permitted + + if linkable(elementName) { + if p.requireParseableURLs { + // Ensure URLs are parseable: + // - a.href + // - area.href + // - link.href + // - blockquote.cite + // - q.cite + // - img.src + // - script.src + tmpAttrs := []html.Attribute{} + for _, htmlAttr := range cleanAttrs { + switch elementName { + case "a", "area", "link": + if htmlAttr.Key == "href" { + if u, ok := p.validURL(htmlAttr.Val); ok { + htmlAttr.Val = u + tmpAttrs = append(tmpAttrs, htmlAttr) + } + break + } + tmpAttrs = append(tmpAttrs, htmlAttr) + case "blockquote", "q": + if htmlAttr.Key == "cite" { + if u, ok := p.validURL(htmlAttr.Val); ok { + htmlAttr.Val = u + tmpAttrs = append(tmpAttrs, htmlAttr) + } + break + } + tmpAttrs = append(tmpAttrs, htmlAttr) + case "img", "script": + if htmlAttr.Key == "src" { + if u, ok := p.validURL(htmlAttr.Val); ok { + htmlAttr.Val = u + tmpAttrs = append(tmpAttrs, htmlAttr) + } + break + } + tmpAttrs = append(tmpAttrs, htmlAttr) + default: + tmpAttrs = append(tmpAttrs, htmlAttr) + } + } + cleanAttrs = tmpAttrs + } + + if (p.requireNoFollow || + p.requireNoFollowFullyQualifiedLinks || + p.addTargetBlankToFullyQualifiedLinks) && + len(cleanAttrs) > 0 { + + // Add rel="nofollow" if a "href" exists + switch elementName { + case "a", "area", "link": + var hrefFound bool + var externalLink bool + for _, htmlAttr := range cleanAttrs { + if htmlAttr.Key == "href" { + hrefFound = true + + u, err := url.Parse(htmlAttr.Val) + if err != nil { + continue + } + if u.Host != "" { + externalLink = true + } + + continue + } + } + + if hrefFound { + var ( + noFollowFound bool + targetBlankFound bool + ) + + addNoFollow := (p.requireNoFollow || + externalLink && p.requireNoFollowFullyQualifiedLinks) + + addTargetBlank := (externalLink && + p.addTargetBlankToFullyQualifiedLinks) + + tmpAttrs := []html.Attribute{} + for _, htmlAttr := range cleanAttrs { + + var appended bool + if htmlAttr.Key == "rel" && addNoFollow { + + if strings.Contains(htmlAttr.Val, "nofollow") { + noFollowFound = true + tmpAttrs = append(tmpAttrs, htmlAttr) + appended = true + } else { + htmlAttr.Val += " nofollow" + noFollowFound = true + tmpAttrs = append(tmpAttrs, htmlAttr) + appended = true + } + } + + if elementName == "a" && htmlAttr.Key == "target" { + if htmlAttr.Val == "_blank" { + targetBlankFound = true + } + if addTargetBlank && !targetBlankFound { + htmlAttr.Val = "_blank" + targetBlankFound = true + tmpAttrs = append(tmpAttrs, htmlAttr) + appended = true + } + } + + if !appended { + tmpAttrs = append(tmpAttrs, htmlAttr) + } + } + if noFollowFound || targetBlankFound { + cleanAttrs = tmpAttrs + } + + if addNoFollow && !noFollowFound { + rel := html.Attribute{} + rel.Key = "rel" + rel.Val = "nofollow" + cleanAttrs = append(cleanAttrs, rel) + } + + if elementName == "a" && addTargetBlank && !targetBlankFound { + rel := html.Attribute{} + rel.Key = "target" + rel.Val = "_blank" + targetBlankFound = true + cleanAttrs = append(cleanAttrs, rel) + } + + if targetBlankFound { + // target="_blank" has a security risk that allows the + // opened window/tab to issue JavaScript calls against + // window.opener, which in effect allow the destination + // of the link to control the source: + // https://dev.to/ben/the-targetblank-vulnerability-by-example + // + // To mitigate this risk, we need to add a specific rel + // attribute if it is not already present. + // rel="noopener" + // + // Unfortunately this is processing the rel twice (we + // already looked at it earlier ^^) as we cannot be sure + // of the ordering of the href and rel, and whether we + // have fully satisfied that we need to do this. This + // double processing only happens *if* target="_blank" + // is true. + var noOpenerAdded bool + tmpAttrs := []html.Attribute{} + for _, htmlAttr := range cleanAttrs { + var appended bool + if htmlAttr.Key == "rel" { + if strings.Contains(htmlAttr.Val, "noopener") { + noOpenerAdded = true + tmpAttrs = append(tmpAttrs, htmlAttr) + } else { + htmlAttr.Val += " noopener" + noOpenerAdded = true + tmpAttrs = append(tmpAttrs, htmlAttr) + } + + appended = true + } + if !appended { + tmpAttrs = append(tmpAttrs, htmlAttr) + } + } + if noOpenerAdded { + cleanAttrs = tmpAttrs + } else { + // rel attr was not found, or else noopener would + // have been added already + rel := html.Attribute{} + rel.Key = "rel" + rel.Val = "noopener" + cleanAttrs = append(cleanAttrs, rel) + } + + } + } + default: + } + } + } + + return cleanAttrs +} + +func (p *Policy) allowNoAttrs(elementName string) bool { + _, ok := p.setOfElementsAllowedWithoutAttrs[elementName] + return ok +} + +func (p *Policy) validURL(rawurl string) (string, bool) { + if p.requireParseableURLs { + // URLs are valid if when space is trimmed the URL is valid + rawurl = strings.TrimSpace(rawurl) + + // URLs cannot contain whitespace, unless it is a data-uri + if (strings.Contains(rawurl, " ") || + strings.Contains(rawurl, "\t") || + strings.Contains(rawurl, "\n")) && + !strings.HasPrefix(rawurl, `data:`) { + return "", false + } + + // URLs are valid if they parse + u, err := url.Parse(rawurl) + if err != nil { + return "", false + } + + if u.Scheme != "" { + + urlPolicy, ok := p.allowURLSchemes[u.Scheme] + if !ok { + return "", false + + } + + if urlPolicy == nil || urlPolicy(u) == true { + return u.String(), true + } + + return "", false + } + + if p.allowRelativeURLs { + if u.String() != "" { + return u.String(), true + } + } + + return "", false + } + + return rawurl, true +} + +func linkable(elementName string) bool { + switch elementName { + case "a", "area", "blockquote", "img", "link", "script": + return true + default: + return false + } +} + +func isDataAttribute(val string) bool { + if !dataAttribute.MatchString(val) { + return false + } + rest := strings.Split(val, "data-") + if len(rest) == 1 { + return false + } + // data-xml* is invalid. + if dataAttributeXMLPrefix.MatchString(rest[1]) { + return false + } + // no uppercase or semi-colons allowed. + if dataAttributeInvalidChars.MatchString(rest[1]) { + return false + } + return true +} diff --git a/vendor/github.com/shurcooL/sanitized_anchor_name/.travis.yml b/vendor/github.com/shurcooL/sanitized_anchor_name/.travis.yml new file mode 100644 index 000000000..93b1fcdb3 --- /dev/null +++ b/vendor/github.com/shurcooL/sanitized_anchor_name/.travis.yml @@ -0,0 +1,16 @@ +sudo: false +language: go +go: + - 1.x + - master +matrix: + allow_failures: + - go: master + fast_finish: true +install: + - # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step). +script: + - go get -t -v ./... + - diff -u <(echo -n) <(gofmt -d -s .) + - go tool vet . + - go test -v -race ./... diff --git a/vendor/github.com/shurcooL/sanitized_anchor_name/LICENSE b/vendor/github.com/shurcooL/sanitized_anchor_name/LICENSE new file mode 100644 index 000000000..c35c17af9 --- /dev/null +++ b/vendor/github.com/shurcooL/sanitized_anchor_name/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015 Dmitri Shuralyov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/shurcooL/sanitized_anchor_name/README.md b/vendor/github.com/shurcooL/sanitized_anchor_name/README.md new file mode 100644 index 000000000..670bf0fe6 --- /dev/null +++ b/vendor/github.com/shurcooL/sanitized_anchor_name/README.md @@ -0,0 +1,36 @@ +sanitized_anchor_name +===================== + +[![Build Status](https://travis-ci.org/shurcooL/sanitized_anchor_name.svg?branch=master)](https://travis-ci.org/shurcooL/sanitized_anchor_name) [![GoDoc](https://godoc.org/github.com/shurcooL/sanitized_anchor_name?status.svg)](https://godoc.org/github.com/shurcooL/sanitized_anchor_name) + +Package sanitized_anchor_name provides a func to create sanitized anchor names. + +Its logic can be reused by multiple packages to create interoperable anchor names +and links to those anchors. + +At this time, it does not try to ensure that generated anchor names +are unique, that responsibility falls on the caller. + +Installation +------------ + +```bash +go get -u github.com/shurcooL/sanitized_anchor_name +``` + +Example +------- + +```Go +anchorName := sanitized_anchor_name.Create("This is a header") + +fmt.Println(anchorName) + +// Output: +// this-is-a-header +``` + +License +------- + +- [MIT License](LICENSE) diff --git a/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod b/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod new file mode 100644 index 000000000..1e2553475 --- /dev/null +++ b/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod @@ -0,0 +1 @@ +module github.com/shurcooL/sanitized_anchor_name diff --git a/vendor/github.com/shurcooL/sanitized_anchor_name/main.go b/vendor/github.com/shurcooL/sanitized_anchor_name/main.go new file mode 100644 index 000000000..6a77d1243 --- /dev/null +++ b/vendor/github.com/shurcooL/sanitized_anchor_name/main.go @@ -0,0 +1,29 @@ +// Package sanitized_anchor_name provides a func to create sanitized anchor names. +// +// Its logic can be reused by multiple packages to create interoperable anchor names +// and links to those anchors. +// +// At this time, it does not try to ensure that generated anchor names +// are unique, that responsibility falls on the caller. +package sanitized_anchor_name // import "github.com/shurcooL/sanitized_anchor_name" + +import "unicode" + +// Create returns a sanitized anchor name for the given text. +func Create(text string) string { + var anchorName []rune + var futureDash = false + for _, r := range text { + switch { + case unicode.IsLetter(r) || unicode.IsNumber(r): + if futureDash && len(anchorName) > 0 { + anchorName = append(anchorName, '-') + } + futureDash = false + anchorName = append(anchorName, unicode.ToLower(r)) + default: + futureDash = true + } + } + return string(anchorName) +} diff --git a/vendor/golang.org/x/net/html/atom/atom.go b/vendor/golang.org/x/net/html/atom/atom.go new file mode 100644 index 000000000..cd0a8ac15 --- /dev/null +++ b/vendor/golang.org/x/net/html/atom/atom.go @@ -0,0 +1,78 @@ +// Copyright 2012 The Go 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 atom provides integer codes (also known as atoms) for a fixed set of +// frequently occurring HTML strings: tag names and attribute keys such as "p" +// and "id". +// +// Sharing an atom's name between all elements with the same tag can result in +// fewer string allocations when tokenizing and parsing HTML. Integer +// comparisons are also generally faster than string comparisons. +// +// The value of an atom's particular code is not guaranteed to stay the same +// between versions of this package. Neither is any ordering guaranteed: +// whether atom.H1 < atom.H2 may also change. The codes are not guaranteed to +// be dense. The only guarantees are that e.g. looking up "div" will yield +// atom.Div, calling atom.Div.String will return "div", and atom.Div != 0. +package atom // import "golang.org/x/net/html/atom" + +// Atom is an integer code for a string. The zero value maps to "". +type Atom uint32 + +// String returns the atom's name. +func (a Atom) String() string { + start := uint32(a >> 8) + n := uint32(a & 0xff) + if start+n > uint32(len(atomText)) { + return "" + } + return atomText[start : start+n] +} + +func (a Atom) string() string { + return atomText[a>>8 : a>>8+a&0xff] +} + +// fnv computes the FNV hash with an arbitrary starting value h. +func fnv(h uint32, s []byte) uint32 { + for i := range s { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + +func match(s string, t []byte) bool { + for i, c := range t { + if s[i] != c { + return false + } + } + return true +} + +// Lookup returns the atom whose name is s. It returns zero if there is no +// such atom. The lookup is case sensitive. +func Lookup(s []byte) Atom { + if len(s) == 0 || len(s) > maxAtomLen { + return 0 + } + h := fnv(hash0, s) + if a := table[h&uint32(len(table)-1)]; int(a&0xff) == len(s) && match(a.string(), s) { + return a + } + if a := table[(h>>16)&uint32(len(table)-1)]; int(a&0xff) == len(s) && match(a.string(), s) { + return a + } + return 0 +} + +// String returns a string whose contents are equal to s. In that sense, it is +// equivalent to string(s) but may be more efficient. +func String(s []byte) string { + if a := Lookup(s); a != 0 { + return a.String() + } + return string(s) +} diff --git a/vendor/golang.org/x/net/html/atom/gen.go b/vendor/golang.org/x/net/html/atom/gen.go new file mode 100644 index 000000000..cc5dc5dbc --- /dev/null +++ b/vendor/golang.org/x/net/html/atom/gen.go @@ -0,0 +1,709 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build ignore + +//go:generate go run gen.go +//go:generate go run gen.go -test + +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "io/ioutil" + "math/rand" + "os" + "sort" + "strings" +) + +// identifier converts s to a Go exported identifier. +// It converts "div" to "Div" and "accept-charset" to "AcceptCharset". +func identifier(s string) string { + b := make([]byte, 0, len(s)) + cap := true + for _, c := range s { + if c == '-' { + cap = true + continue + } + if cap && 'a' <= c && c <= 'z' { + c -= 'a' - 'A' + } + cap = false + b = append(b, byte(c)) + } + return string(b) +} + +var test = flag.Bool("test", false, "generate table_test.go") + +func genFile(name string, buf *bytes.Buffer) { + b, err := format.Source(buf.Bytes()) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := ioutil.WriteFile(name, b, 0644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func main() { + flag.Parse() + + var all []string + all = append(all, elements...) + all = append(all, attributes...) + all = append(all, eventHandlers...) + all = append(all, extra...) + sort.Strings(all) + + // uniq - lists have dups + w := 0 + for _, s := range all { + if w == 0 || all[w-1] != s { + all[w] = s + w++ + } + } + all = all[:w] + + if *test { + var buf bytes.Buffer + fmt.Fprintln(&buf, "// Code generated by go generate gen.go; DO NOT EDIT.\n") + fmt.Fprintln(&buf, "//go:generate go run gen.go -test\n") + fmt.Fprintln(&buf, "package atom\n") + fmt.Fprintln(&buf, "var testAtomList = []string{") + for _, s := range all { + fmt.Fprintf(&buf, "\t%q,\n", s) + } + fmt.Fprintln(&buf, "}") + + genFile("table_test.go", &buf) + return + } + + // Find hash that minimizes table size. + var best *table + for i := 0; i < 1000000; i++ { + if best != nil && 1<<(best.k-1) < len(all) { + break + } + h := rand.Uint32() + for k := uint(0); k <= 16; k++ { + if best != nil && k >= best.k { + break + } + var t table + if t.init(h, k, all) { + best = &t + break + } + } + } + if best == nil { + fmt.Fprintf(os.Stderr, "failed to construct string table\n") + os.Exit(1) + } + + // Lay out strings, using overlaps when possible. + layout := append([]string{}, all...) + + // Remove strings that are substrings of other strings + for changed := true; changed; { + changed = false + for i, s := range layout { + if s == "" { + continue + } + for j, t := range layout { + if i != j && t != "" && strings.Contains(s, t) { + changed = true + layout[j] = "" + } + } + } + } + + // Join strings where one suffix matches another prefix. + for { + // Find best i, j, k such that layout[i][len-k:] == layout[j][:k], + // maximizing overlap length k. + besti := -1 + bestj := -1 + bestk := 0 + for i, s := range layout { + if s == "" { + continue + } + for j, t := range layout { + if i == j { + continue + } + for k := bestk + 1; k <= len(s) && k <= len(t); k++ { + if s[len(s)-k:] == t[:k] { + besti = i + bestj = j + bestk = k + } + } + } + } + if bestk > 0 { + layout[besti] += layout[bestj][bestk:] + layout[bestj] = "" + continue + } + break + } + + text := strings.Join(layout, "") + + atom := map[string]uint32{} + for _, s := range all { + off := strings.Index(text, s) + if off < 0 { + panic("lost string " + s) + } + atom[s] = uint32(off<<8 | len(s)) + } + + var buf bytes.Buffer + // Generate the Go code. + fmt.Fprintln(&buf, "// Code generated by go generate gen.go; DO NOT EDIT.\n") + fmt.Fprintln(&buf, "//go:generate go run gen.go\n") + fmt.Fprintln(&buf, "package atom\n\nconst (") + + // compute max len + maxLen := 0 + for _, s := range all { + if maxLen < len(s) { + maxLen = len(s) + } + fmt.Fprintf(&buf, "\t%s Atom = %#x\n", identifier(s), atom[s]) + } + fmt.Fprintln(&buf, ")\n") + + fmt.Fprintf(&buf, "const hash0 = %#x\n\n", best.h0) + fmt.Fprintf(&buf, "const maxAtomLen = %d\n\n", maxLen) + + fmt.Fprintf(&buf, "var table = [1<<%d]Atom{\n", best.k) + for i, s := range best.tab { + if s == "" { + continue + } + fmt.Fprintf(&buf, "\t%#x: %#x, // %s\n", i, atom[s], s) + } + fmt.Fprintf(&buf, "}\n") + datasize := (1 << best.k) * 4 + + fmt.Fprintln(&buf, "const atomText =") + textsize := len(text) + for len(text) > 60 { + fmt.Fprintf(&buf, "\t%q +\n", text[:60]) + text = text[60:] + } + fmt.Fprintf(&buf, "\t%q\n\n", text) + + genFile("table.go", &buf) + + fmt.Fprintf(os.Stdout, "%d atoms; %d string bytes + %d tables = %d total data\n", len(all), textsize, datasize, textsize+datasize) +} + +type byLen []string + +func (x byLen) Less(i, j int) bool { return len(x[i]) > len(x[j]) } +func (x byLen) Swap(i, j int) { x[i], x[j] = x[j], x[i] } +func (x byLen) Len() int { return len(x) } + +// fnv computes the FNV hash with an arbitrary starting value h. +func fnv(h uint32, s string) uint32 { + for i := 0; i < len(s); i++ { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + +// A table represents an attempt at constructing the lookup table. +// The lookup table uses cuckoo hashing, meaning that each string +// can be found in one of two positions. +type table struct { + h0 uint32 + k uint + mask uint32 + tab []string +} + +// hash returns the two hashes for s. +func (t *table) hash(s string) (h1, h2 uint32) { + h := fnv(t.h0, s) + h1 = h & t.mask + h2 = (h >> 16) & t.mask + return +} + +// init initializes the table with the given parameters. +// h0 is the initial hash value, +// k is the number of bits of hash value to use, and +// x is the list of strings to store in the table. +// init returns false if the table cannot be constructed. +func (t *table) init(h0 uint32, k uint, x []string) bool { + t.h0 = h0 + t.k = k + t.tab = make([]string, 1< len(t.tab) { + return false + } + s := t.tab[i] + h1, h2 := t.hash(s) + j := h1 + h2 - i + if t.tab[j] != "" && !t.push(j, depth+1) { + return false + } + t.tab[j] = s + return true +} + +// The lists of element names and attribute keys were taken from +// https://html.spec.whatwg.org/multipage/indices.html#index +// as of the "HTML Living Standard - Last Updated 18 September 2017" version. + +// "command", "keygen" and "menuitem" have been removed from the spec, +// but are kept here for backwards compatibility. +var elements = []string{ + "a", + "abbr", + "address", + "area", + "article", + "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "command", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", + "li", + "link", + "main", + "map", + "mark", + "menu", + "menuitem", + "meta", + "meter", + "nav", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "picture", + "pre", + "progress", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "script", + "section", + "select", + "slot", + "small", + "source", + "span", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "template", + "textarea", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", +} + +// https://html.spec.whatwg.org/multipage/indices.html#attributes-3 +// +// "challenge", "command", "contextmenu", "dropzone", "icon", "keytype", "mediagroup", +// "radiogroup", "spellcheck", "scoped", "seamless", "sortable" and "sorted" have been removed from the spec, +// but are kept here for backwards compatibility. +var attributes = []string{ + "abbr", + "accept", + "accept-charset", + "accesskey", + "action", + "allowfullscreen", + "allowpaymentrequest", + "allowusermedia", + "alt", + "as", + "async", + "autocomplete", + "autofocus", + "autoplay", + "challenge", + "charset", + "checked", + "cite", + "class", + "color", + "cols", + "colspan", + "command", + "content", + "contenteditable", + "contextmenu", + "controls", + "coords", + "crossorigin", + "data", + "datetime", + "default", + "defer", + "dir", + "dirname", + "disabled", + "download", + "draggable", + "dropzone", + "enctype", + "for", + "form", + "formaction", + "formenctype", + "formmethod", + "formnovalidate", + "formtarget", + "headers", + "height", + "hidden", + "high", + "href", + "hreflang", + "http-equiv", + "icon", + "id", + "inputmode", + "integrity", + "is", + "ismap", + "itemid", + "itemprop", + "itemref", + "itemscope", + "itemtype", + "keytype", + "kind", + "label", + "lang", + "list", + "loop", + "low", + "manifest", + "max", + "maxlength", + "media", + "mediagroup", + "method", + "min", + "minlength", + "multiple", + "muted", + "name", + "nomodule", + "nonce", + "novalidate", + "open", + "optimum", + "pattern", + "ping", + "placeholder", + "playsinline", + "poster", + "preload", + "radiogroup", + "readonly", + "referrerpolicy", + "rel", + "required", + "reversed", + "rows", + "rowspan", + "sandbox", + "spellcheck", + "scope", + "scoped", + "seamless", + "selected", + "shape", + "size", + "sizes", + "sortable", + "sorted", + "slot", + "span", + "spellcheck", + "src", + "srcdoc", + "srclang", + "srcset", + "start", + "step", + "style", + "tabindex", + "target", + "title", + "translate", + "type", + "typemustmatch", + "updateviacache", + "usemap", + "value", + "width", + "workertype", + "wrap", +} + +// "onautocomplete", "onautocompleteerror", "onmousewheel", +// "onshow" and "onsort" have been removed from the spec, +// but are kept here for backwards compatibility. +var eventHandlers = []string{ + "onabort", + "onautocomplete", + "onautocompleteerror", + "onauxclick", + "onafterprint", + "onbeforeprint", + "onbeforeunload", + "onblur", + "oncancel", + "oncanplay", + "oncanplaythrough", + "onchange", + "onclick", + "onclose", + "oncontextmenu", + "oncopy", + "oncuechange", + "oncut", + "ondblclick", + "ondrag", + "ondragend", + "ondragenter", + "ondragexit", + "ondragleave", + "ondragover", + "ondragstart", + "ondrop", + "ondurationchange", + "onemptied", + "onended", + "onerror", + "onfocus", + "onhashchange", + "oninput", + "oninvalid", + "onkeydown", + "onkeypress", + "onkeyup", + "onlanguagechange", + "onload", + "onloadeddata", + "onloadedmetadata", + "onloadend", + "onloadstart", + "onmessage", + "onmessageerror", + "onmousedown", + "onmouseenter", + "onmouseleave", + "onmousemove", + "onmouseout", + "onmouseover", + "onmouseup", + "onmousewheel", + "onwheel", + "onoffline", + "ononline", + "onpagehide", + "onpageshow", + "onpaste", + "onpause", + "onplay", + "onplaying", + "onpopstate", + "onprogress", + "onratechange", + "onreset", + "onresize", + "onrejectionhandled", + "onscroll", + "onsecuritypolicyviolation", + "onseeked", + "onseeking", + "onselect", + "onshow", + "onsort", + "onstalled", + "onstorage", + "onsubmit", + "onsuspend", + "ontimeupdate", + "ontoggle", + "onunhandledrejection", + "onunload", + "onvolumechange", + "onwaiting", +} + +// extra are ad-hoc values not covered by any of the lists above. +var extra = []string{ + "align", + "annotation", + "annotation-xml", + "applet", + "basefont", + "bgsound", + "big", + "blink", + "center", + "color", + "desc", + "face", + "font", + "foreignObject", // HTML is case-insensitive, but SVG-embedded-in-HTML is case-sensitive. + "foreignobject", + "frame", + "frameset", + "image", + "isindex", + "listing", + "malignmark", + "marquee", + "math", + "mglyph", + "mi", + "mn", + "mo", + "ms", + "mtext", + "nobr", + "noembed", + "noframes", + "plaintext", + "prompt", + "public", + "spacer", + "strike", + "svg", + "system", + "tt", + "xmp", +} diff --git a/vendor/golang.org/x/net/html/atom/table.go b/vendor/golang.org/x/net/html/atom/table.go new file mode 100644 index 000000000..f74018ece --- /dev/null +++ b/vendor/golang.org/x/net/html/atom/table.go @@ -0,0 +1,777 @@ +// Code generated by go generate gen.go; DO NOT EDIT. + +//go:generate go run gen.go + +package atom + +const ( + A Atom = 0x1 + Abbr Atom = 0x4 + Accept Atom = 0x1a06 + AcceptCharset Atom = 0x1a0e + Accesskey Atom = 0x2c09 + Action Atom = 0x25a06 + Address Atom = 0x6ed07 + Align Atom = 0x6d405 + Allowfullscreen Atom = 0x1f00f + Allowpaymentrequest Atom = 0x6913 + Allowusermedia Atom = 0x850e + Alt Atom = 0xb003 + Annotation Atom = 0x1b90a + AnnotationXml Atom = 0x1b90e + Applet Atom = 0x30106 + Area Atom = 0x34a04 + Article Atom = 0x3f007 + As Atom = 0xb902 + Aside Atom = 0xc105 + Async Atom = 0xb905 + Audio Atom = 0xcf05 + Autocomplete Atom = 0x2600c + Autofocus Atom = 0xeb09 + Autoplay Atom = 0x10608 + B Atom = 0x101 + Base Atom = 0x11504 + Basefont Atom = 0x11508 + Bdi Atom = 0x16103 + Bdo Atom = 0x13403 + Bgsound Atom = 0x14707 + Big Atom = 0x15903 + Blink Atom = 0x15c05 + Blockquote Atom = 0x1680a + Body Atom = 0x2804 + Br Atom = 0x202 + Button Atom = 0x17206 + Canvas Atom = 0xbd06 + Caption Atom = 0x21907 + Center Atom = 0x20806 + Challenge Atom = 0x28309 + Charset Atom = 0x2107 + Checked Atom = 0x46d07 + Cite Atom = 0x55804 + Class Atom = 0x5b905 + Code Atom = 0x19004 + Col Atom = 0x19703 + Colgroup Atom = 0x19708 + Color Atom = 0x1af05 + Cols Atom = 0x1b404 + Colspan Atom = 0x1b407 + Command Atom = 0x1c707 + Content Atom = 0x57f07 + Contenteditable Atom = 0x57f0f + Contextmenu Atom = 0x3740b + Controls Atom = 0x1ce08 + Coords Atom = 0x1da06 + Crossorigin Atom = 0x1e30b + Data Atom = 0x49904 + Datalist Atom = 0x49908 + Datetime Atom = 0x2a008 + Dd Atom = 0x2bf02 + Default Atom = 0xc407 + Defer Atom = 0x19205 + Del Atom = 0x44603 + Desc Atom = 0x55504 + Details Atom = 0x4607 + Dfn Atom = 0x5f03 + Dialog Atom = 0x16206 + Dir Atom = 0xa303 + Dirname Atom = 0xa307 + Disabled Atom = 0x14d08 + Div Atom = 0x15403 + Dl Atom = 0x5e202 + Download Atom = 0x45708 + Draggable Atom = 0x18309 + Dropzone Atom = 0x3f908 + Dt Atom = 0x64702 + Em Atom = 0x4202 + Embed Atom = 0x4205 + Enctype Atom = 0x27507 + Face Atom = 0x20604 + Fieldset Atom = 0x20e08 + Figcaption Atom = 0x2160a + Figure Atom = 0x23006 + Font Atom = 0x11904 + Footer Atom = 0xb306 + For Atom = 0x23c03 + ForeignObject Atom = 0x23c0d + Foreignobject Atom = 0x2490d + Form Atom = 0x25604 + Formaction Atom = 0x2560a + Formenctype Atom = 0x2710b + Formmethod Atom = 0x28c0a + Formnovalidate Atom = 0x2960e + Formtarget Atom = 0x2a80a + Frame Atom = 0x5705 + Frameset Atom = 0x5708 + H1 Atom = 0x14502 + H2 Atom = 0x2c602 + H3 Atom = 0x2f502 + H4 Atom = 0x33902 + H5 Atom = 0x34302 + H6 Atom = 0x64902 + Head Atom = 0x32504 + Header Atom = 0x32506 + Headers Atom = 0x32507 + Height Atom = 0x12c06 + Hgroup Atom = 0x2b206 + Hidden Atom = 0x2bd06 + High Atom = 0x2c304 + Hr Atom = 0x14002 + Href Atom = 0x2c804 + Hreflang Atom = 0x2c808 + Html Atom = 0x13004 + HttpEquiv Atom = 0x2d00a + I Atom = 0x601 + Icon Atom = 0x57e04 + Id Atom = 0xc302 + Iframe Atom = 0x2e406 + Image Atom = 0x2ea05 + Img Atom = 0x2ef03 + Input Atom = 0x43f05 + Inputmode Atom = 0x43f09 + Ins Atom = 0x1ec03 + Integrity Atom = 0x22709 + Is Atom = 0x14e02 + Isindex Atom = 0x2f707 + Ismap Atom = 0x2fe05 + Itemid Atom = 0x37f06 + Itemprop Atom = 0x55908 + Itemref Atom = 0x3c107 + Itemscope Atom = 0x66d09 + Itemtype Atom = 0x30708 + Kbd Atom = 0x16003 + Keygen Atom = 0x3206 + Keytype Atom = 0x7e07 + Kind Atom = 0x18004 + Label Atom = 0xda05 + Lang Atom = 0x2cc04 + Legend Atom = 0x18a06 + Li Atom = 0x11102 + Link Atom = 0x15d04 + List Atom = 0x49d04 + Listing Atom = 0x49d07 + Loop Atom = 0xde04 + Low Atom = 0x6b03 + Main Atom = 0x1004 + Malignmark Atom = 0x6d30a + Manifest Atom = 0x30f08 + Map Atom = 0x30003 + Mark Atom = 0x6d904 + Marquee Atom = 0x31b07 + Math Atom = 0x32204 + Max Atom = 0x33103 + Maxlength Atom = 0x33109 + Media Atom = 0x8e05 + Mediagroup Atom = 0x8e0a + Menu Atom = 0x37b04 + Menuitem Atom = 0x37b08 + Meta Atom = 0x4ac04 + Meter Atom = 0xa805 + Method Atom = 0x29006 + Mglyph Atom = 0x2f006 + Mi Atom = 0x33b02 + Min Atom = 0x33b03 + Minlength Atom = 0x33b09 + Mn Atom = 0x29902 + Mo Atom = 0x6302 + Ms Atom = 0x67002 + Mtext Atom = 0x34505 + Multiple Atom = 0x35308 + Muted Atom = 0x35b05 + Name Atom = 0xa604 + Nav Atom = 0x1303 + Nobr Atom = 0x3704 + Noembed Atom = 0x4007 + Noframes Atom = 0x5508 + Nomodule Atom = 0x6108 + Nonce Atom = 0x56205 + Noscript Atom = 0x1fe08 + Novalidate Atom = 0x29a0a + Object Atom = 0x25006 + Ol Atom = 0x10102 + Onabort Atom = 0x17607 + Onafterprint Atom = 0x21e0c + Onautocomplete Atom = 0x25e0e + Onautocompleteerror Atom = 0x25e13 + Onauxclick Atom = 0x61b0a + Onbeforeprint Atom = 0x69a0d + Onbeforeunload Atom = 0x6e10e + Onblur Atom = 0x5c206 + Oncancel Atom = 0xd308 + Oncanplay Atom = 0x13609 + Oncanplaythrough Atom = 0x13610 + Onchange Atom = 0x40f08 + Onclick Atom = 0x2dd07 + Onclose Atom = 0x36007 + Oncontextmenu Atom = 0x3720d + Oncopy Atom = 0x38506 + Oncuechange Atom = 0x38b0b + Oncut Atom = 0x39605 + Ondblclick Atom = 0x39b0a + Ondrag Atom = 0x3a506 + Ondragend Atom = 0x3a509 + Ondragenter Atom = 0x3ae0b + Ondragexit Atom = 0x3b90a + Ondragleave Atom = 0x3d30b + Ondragover Atom = 0x3de0a + Ondragstart Atom = 0x3e80b + Ondrop Atom = 0x3f706 + Ondurationchange Atom = 0x40710 + Onemptied Atom = 0x3fe09 + Onended Atom = 0x41707 + Onerror Atom = 0x41e07 + Onfocus Atom = 0x42507 + Onhashchange Atom = 0x4310c + Oninput Atom = 0x43d07 + Oninvalid Atom = 0x44909 + Onkeydown Atom = 0x45209 + Onkeypress Atom = 0x45f0a + Onkeyup Atom = 0x47407 + Onlanguagechange Atom = 0x48110 + Onload Atom = 0x49106 + Onloadeddata Atom = 0x4910c + Onloadedmetadata Atom = 0x4a410 + Onloadend Atom = 0x4ba09 + Onloadstart Atom = 0x4c30b + Onmessage Atom = 0x4ce09 + Onmessageerror Atom = 0x4ce0e + Onmousedown Atom = 0x4dc0b + Onmouseenter Atom = 0x4e70c + Onmouseleave Atom = 0x4f30c + Onmousemove Atom = 0x4ff0b + Onmouseout Atom = 0x50a0a + Onmouseover Atom = 0x5170b + Onmouseup Atom = 0x52209 + Onmousewheel Atom = 0x5300c + Onoffline Atom = 0x53c09 + Ononline Atom = 0x54508 + Onpagehide Atom = 0x54d0a + Onpageshow Atom = 0x5670a + Onpaste Atom = 0x57307 + Onpause Atom = 0x58e07 + Onplay Atom = 0x59806 + Onplaying Atom = 0x59809 + Onpopstate Atom = 0x5a10a + Onprogress Atom = 0x5ab0a + Onratechange Atom = 0x5c80c + Onrejectionhandled Atom = 0x5d412 + Onreset Atom = 0x5e607 + Onresize Atom = 0x5ed08 + Onscroll Atom = 0x5fc08 + Onsecuritypolicyviolation Atom = 0x60419 + Onseeked Atom = 0x62508 + Onseeking Atom = 0x62d09 + Onselect Atom = 0x63608 + Onshow Atom = 0x64006 + Onsort Atom = 0x64b06 + Onstalled Atom = 0x65509 + Onstorage Atom = 0x65e09 + Onsubmit Atom = 0x66708 + Onsuspend Atom = 0x67709 + Ontimeupdate Atom = 0x11a0c + Ontoggle Atom = 0x68008 + Onunhandledrejection Atom = 0x68814 + Onunload Atom = 0x6a708 + Onvolumechange Atom = 0x6af0e + Onwaiting Atom = 0x6bd09 + Onwheel Atom = 0x6c607 + Open Atom = 0x55f04 + Optgroup Atom = 0xe008 + Optimum Atom = 0x6cd07 + Option Atom = 0x6dd06 + Output Atom = 0x51106 + P Atom = 0xc01 + Param Atom = 0xc05 + Pattern Atom = 0x4f07 + Picture Atom = 0x9707 + Ping Atom = 0xe704 + Placeholder Atom = 0xfb0b + Plaintext Atom = 0x19e09 + Playsinline Atom = 0x10a0b + Poster Atom = 0x2b706 + Pre Atom = 0x46403 + Preload Atom = 0x47a07 + Progress Atom = 0x5ad08 + Prompt Atom = 0x52a06 + Public Atom = 0x57a06 + Q Atom = 0x7701 + Radiogroup Atom = 0x30a + Readonly Atom = 0x34b08 + Referrerpolicy Atom = 0x3c50e + Rel Atom = 0x47b03 + Required Atom = 0x23408 + Reversed Atom = 0x9c08 + Rows Atom = 0x3a04 + Rowspan Atom = 0x3a07 + Rp Atom = 0x22402 + Rt Atom = 0x17b02 + Ruby Atom = 0xac04 + S Atom = 0x2501 + Samp Atom = 0x4c04 + Sandbox Atom = 0xf307 + Scope Atom = 0x67105 + Scoped Atom = 0x67106 + Script Atom = 0x20006 + Seamless Atom = 0x36508 + Section Atom = 0x5bd07 + Select Atom = 0x63806 + Selected Atom = 0x63808 + Shape Atom = 0x1d505 + Size Atom = 0x5f104 + Sizes Atom = 0x5f105 + Slot Atom = 0x1df04 + Small Atom = 0x1ee05 + Sortable Atom = 0x64d08 + Sorted Atom = 0x32b06 + Source Atom = 0x36c06 + Spacer Atom = 0x42b06 + Span Atom = 0x3d04 + Spellcheck Atom = 0x4680a + Src Atom = 0x5b403 + Srcdoc Atom = 0x5b406 + Srclang Atom = 0x5f507 + Srcset Atom = 0x6f306 + Start Atom = 0x3ee05 + Step Atom = 0x57704 + Strike Atom = 0x7a06 + Strong Atom = 0x31506 + Style Atom = 0x6f905 + Sub Atom = 0x66903 + Summary Atom = 0x6fe07 + Sup Atom = 0x70503 + Svg Atom = 0x70803 + System Atom = 0x70b06 + Tabindex Atom = 0x4b208 + Table Atom = 0x58905 + Target Atom = 0x2ac06 + Tbody Atom = 0x2705 + Td Atom = 0x5e02 + Template Atom = 0x70e08 + Textarea Atom = 0x34608 + Tfoot Atom = 0xb205 + Th Atom = 0x13f02 + Thead Atom = 0x32405 + Time Atom = 0x11c04 + Title Atom = 0xca05 + Tr Atom = 0x7402 + Track Atom = 0x17c05 + Translate Atom = 0x1a609 + Tt Atom = 0x5102 + Type Atom = 0x8104 + Typemustmatch Atom = 0x2780d + U Atom = 0xb01 + Ul Atom = 0x6602 + Updateviacache Atom = 0x1200e + Usemap Atom = 0x59206 + Value Atom = 0x1505 + Var Atom = 0x15603 + Video Atom = 0x2d905 + Wbr Atom = 0x57003 + Width Atom = 0x64505 + Workertype Atom = 0x7160a + Wrap Atom = 0x72004 + Xmp Atom = 0xf903 +) + +const hash0 = 0x81cdf10e + +const maxAtomLen = 25 + +var table = [1 << 9]Atom{ + 0x1: 0x8e0a, // mediagroup + 0x2: 0x2cc04, // lang + 0x4: 0x2c09, // accesskey + 0x5: 0x5708, // frameset + 0x7: 0x63608, // onselect + 0x8: 0x70b06, // system + 0xa: 0x64505, // width + 0xc: 0x2710b, // formenctype + 0xd: 0x10102, // ol + 0xe: 0x38b0b, // oncuechange + 0x10: 0x13403, // bdo + 0x11: 0xcf05, // audio + 0x12: 0x18309, // draggable + 0x14: 0x2d905, // video + 0x15: 0x29902, // mn + 0x16: 0x37b04, // menu + 0x17: 0x2b706, // poster + 0x19: 0xb306, // footer + 0x1a: 0x29006, // method + 0x1b: 0x2a008, // datetime + 0x1c: 0x17607, // onabort + 0x1d: 0x1200e, // updateviacache + 0x1e: 0xb905, // async + 0x1f: 0x49106, // onload + 0x21: 0xd308, // oncancel + 0x22: 0x62508, // onseeked + 0x23: 0x2ea05, // image + 0x24: 0x5d412, // onrejectionhandled + 0x26: 0x15d04, // link + 0x27: 0x51106, // output + 0x28: 0x32504, // head + 0x29: 0x4f30c, // onmouseleave + 0x2a: 0x57307, // onpaste + 0x2b: 0x59809, // onplaying + 0x2c: 0x1b407, // colspan + 0x2f: 0x1af05, // color + 0x30: 0x5f104, // size + 0x31: 0x2d00a, // http-equiv + 0x33: 0x601, // i + 0x34: 0x54d0a, // onpagehide + 0x35: 0x68814, // onunhandledrejection + 0x37: 0x41e07, // onerror + 0x3a: 0x11508, // basefont + 0x3f: 0x1303, // nav + 0x40: 0x18004, // kind + 0x41: 0x34b08, // readonly + 0x42: 0x2f006, // mglyph + 0x44: 0x11102, // li + 0x46: 0x2bd06, // hidden + 0x47: 0x70803, // svg + 0x48: 0x57704, // step + 0x49: 0x22709, // integrity + 0x4a: 0x57a06, // public + 0x4c: 0x19703, // col + 0x4d: 0x1680a, // blockquote + 0x4e: 0x34302, // h5 + 0x50: 0x5ad08, // progress + 0x51: 0x5f105, // sizes + 0x52: 0x33902, // h4 + 0x56: 0x32405, // thead + 0x57: 0x7e07, // keytype + 0x58: 0x5ab0a, // onprogress + 0x59: 0x43f09, // inputmode + 0x5a: 0x3a509, // ondragend + 0x5d: 0x39605, // oncut + 0x5e: 0x42b06, // spacer + 0x5f: 0x19708, // colgroup + 0x62: 0x14e02, // is + 0x65: 0xb902, // as + 0x66: 0x53c09, // onoffline + 0x67: 0x32b06, // sorted + 0x69: 0x48110, // onlanguagechange + 0x6c: 0x4310c, // onhashchange + 0x6d: 0xa604, // name + 0x6e: 0xb205, // tfoot + 0x6f: 0x55504, // desc + 0x70: 0x33103, // max + 0x72: 0x1da06, // coords + 0x73: 0x2f502, // h3 + 0x74: 0x6e10e, // onbeforeunload + 0x75: 0x3a04, // rows + 0x76: 0x63806, // select + 0x77: 0xa805, // meter + 0x78: 0x37f06, // itemid + 0x79: 0x5300c, // onmousewheel + 0x7a: 0x5b406, // srcdoc + 0x7d: 0x17c05, // track + 0x7f: 0x30708, // itemtype + 0x82: 0x6302, // mo + 0x83: 0x40f08, // onchange + 0x84: 0x32507, // headers + 0x85: 0x5c80c, // onratechange + 0x86: 0x60419, // onsecuritypolicyviolation + 0x88: 0x49908, // datalist + 0x89: 0x4dc0b, // onmousedown + 0x8a: 0x1df04, // slot + 0x8b: 0x4a410, // onloadedmetadata + 0x8c: 0x1a06, // accept + 0x8d: 0x25006, // object + 0x91: 0x6af0e, // onvolumechange + 0x92: 0x2107, // charset + 0x93: 0x25e13, // onautocompleteerror + 0x94: 0x6913, // allowpaymentrequest + 0x95: 0x2804, // body + 0x96: 0xc407, // default + 0x97: 0x63808, // selected + 0x98: 0x20604, // face + 0x99: 0x1d505, // shape + 0x9b: 0x68008, // ontoggle + 0x9e: 0x64702, // dt + 0x9f: 0x6d904, // mark + 0xa1: 0xb01, // u + 0xa4: 0x6a708, // onunload + 0xa5: 0xde04, // loop + 0xa6: 0x14d08, // disabled + 0xaa: 0x41707, // onended + 0xab: 0x6d30a, // malignmark + 0xad: 0x67709, // onsuspend + 0xae: 0x34505, // mtext + 0xaf: 0x64b06, // onsort + 0xb0: 0x55908, // itemprop + 0xb3: 0x66d09, // itemscope + 0xb4: 0x15c05, // blink + 0xb6: 0x3a506, // ondrag + 0xb7: 0x6602, // ul + 0xb8: 0x25604, // form + 0xb9: 0xf307, // sandbox + 0xba: 0x5705, // frame + 0xbb: 0x1505, // value + 0xbc: 0x65e09, // onstorage + 0xc0: 0x17b02, // rt + 0xc2: 0x202, // br + 0xc3: 0x20e08, // fieldset + 0xc4: 0x2780d, // typemustmatch + 0xc5: 0x6108, // nomodule + 0xc6: 0x4007, // noembed + 0xc7: 0x69a0d, // onbeforeprint + 0xc8: 0x17206, // button + 0xc9: 0x2dd07, // onclick + 0xca: 0x6fe07, // summary + 0xcd: 0xac04, // ruby + 0xce: 0x5b905, // class + 0xcf: 0x3e80b, // ondragstart + 0xd0: 0x21907, // caption + 0xd4: 0x850e, // allowusermedia + 0xd5: 0x4c30b, // onloadstart + 0xd9: 0x15403, // div + 0xda: 0x49d04, // list + 0xdb: 0x32204, // math + 0xdc: 0x43f05, // input + 0xdf: 0x3de0a, // ondragover + 0xe0: 0x2c602, // h2 + 0xe2: 0x19e09, // plaintext + 0xe4: 0x4e70c, // onmouseenter + 0xe7: 0x46d07, // checked + 0xe8: 0x46403, // pre + 0xea: 0x35308, // multiple + 0xeb: 0x16103, // bdi + 0xec: 0x33109, // maxlength + 0xed: 0x7701, // q + 0xee: 0x61b0a, // onauxclick + 0xf0: 0x57003, // wbr + 0xf2: 0x11504, // base + 0xf3: 0x6dd06, // option + 0xf5: 0x40710, // ondurationchange + 0xf7: 0x5508, // noframes + 0xf9: 0x3f908, // dropzone + 0xfb: 0x67105, // scope + 0xfc: 0x9c08, // reversed + 0xfd: 0x3ae0b, // ondragenter + 0xfe: 0x3ee05, // start + 0xff: 0xf903, // xmp + 0x100: 0x5f507, // srclang + 0x101: 0x2ef03, // img + 0x104: 0x101, // b + 0x105: 0x23c03, // for + 0x106: 0xc105, // aside + 0x107: 0x43d07, // oninput + 0x108: 0x34a04, // area + 0x109: 0x28c0a, // formmethod + 0x10a: 0x72004, // wrap + 0x10c: 0x22402, // rp + 0x10d: 0x45f0a, // onkeypress + 0x10e: 0x5102, // tt + 0x110: 0x33b02, // mi + 0x111: 0x35b05, // muted + 0x112: 0xb003, // alt + 0x113: 0x19004, // code + 0x114: 0x4202, // em + 0x115: 0x3b90a, // ondragexit + 0x117: 0x3d04, // span + 0x119: 0x30f08, // manifest + 0x11a: 0x37b08, // menuitem + 0x11b: 0x57f07, // content + 0x11d: 0x6bd09, // onwaiting + 0x11f: 0x4ba09, // onloadend + 0x121: 0x3720d, // oncontextmenu + 0x123: 0x5c206, // onblur + 0x124: 0x3f007, // article + 0x125: 0xa303, // dir + 0x126: 0xe704, // ping + 0x127: 0x23408, // required + 0x128: 0x44909, // oninvalid + 0x129: 0x6d405, // align + 0x12b: 0x57e04, // icon + 0x12c: 0x64902, // h6 + 0x12d: 0x1b404, // cols + 0x12e: 0x2160a, // figcaption + 0x12f: 0x45209, // onkeydown + 0x130: 0x66708, // onsubmit + 0x131: 0x13609, // oncanplay + 0x132: 0x70503, // sup + 0x133: 0xc01, // p + 0x135: 0x3fe09, // onemptied + 0x136: 0x38506, // oncopy + 0x137: 0x55804, // cite + 0x138: 0x39b0a, // ondblclick + 0x13a: 0x4ff0b, // onmousemove + 0x13c: 0x66903, // sub + 0x13d: 0x47b03, // rel + 0x13e: 0xe008, // optgroup + 0x142: 0x3a07, // rowspan + 0x143: 0x36c06, // source + 0x144: 0x1fe08, // noscript + 0x145: 0x55f04, // open + 0x146: 0x1ec03, // ins + 0x147: 0x23c0d, // foreignObject + 0x148: 0x5a10a, // onpopstate + 0x14a: 0x27507, // enctype + 0x14b: 0x25e0e, // onautocomplete + 0x14c: 0x34608, // textarea + 0x14e: 0x2600c, // autocomplete + 0x14f: 0x14002, // hr + 0x150: 0x1ce08, // controls + 0x151: 0xc302, // id + 0x153: 0x21e0c, // onafterprint + 0x155: 0x2490d, // foreignobject + 0x156: 0x31b07, // marquee + 0x157: 0x58e07, // onpause + 0x158: 0x5e202, // dl + 0x159: 0x12c06, // height + 0x15a: 0x33b03, // min + 0x15b: 0xa307, // dirname + 0x15c: 0x1a609, // translate + 0x15d: 0x13004, // html + 0x15e: 0x33b09, // minlength + 0x15f: 0x47a07, // preload + 0x160: 0x70e08, // template + 0x161: 0x3d30b, // ondragleave + 0x164: 0x5b403, // src + 0x165: 0x31506, // strong + 0x167: 0x4c04, // samp + 0x168: 0x6ed07, // address + 0x169: 0x54508, // ononline + 0x16b: 0xfb0b, // placeholder + 0x16c: 0x2ac06, // target + 0x16d: 0x1ee05, // small + 0x16e: 0x6c607, // onwheel + 0x16f: 0x1b90a, // annotation + 0x170: 0x4680a, // spellcheck + 0x171: 0x4607, // details + 0x172: 0xbd06, // canvas + 0x173: 0xeb09, // autofocus + 0x174: 0xc05, // param + 0x176: 0x45708, // download + 0x177: 0x44603, // del + 0x178: 0x36007, // onclose + 0x179: 0x16003, // kbd + 0x17a: 0x30106, // applet + 0x17b: 0x2c804, // href + 0x17c: 0x5ed08, // onresize + 0x17e: 0x4910c, // onloadeddata + 0x180: 0x7402, // tr + 0x181: 0x2a80a, // formtarget + 0x182: 0xca05, // title + 0x183: 0x6f905, // style + 0x184: 0x7a06, // strike + 0x185: 0x59206, // usemap + 0x186: 0x2e406, // iframe + 0x187: 0x1004, // main + 0x189: 0x9707, // picture + 0x18c: 0x2fe05, // ismap + 0x18e: 0x49904, // data + 0x18f: 0xda05, // label + 0x191: 0x3c50e, // referrerpolicy + 0x192: 0x13f02, // th + 0x194: 0x52a06, // prompt + 0x195: 0x5bd07, // section + 0x197: 0x6cd07, // optimum + 0x198: 0x2c304, // high + 0x199: 0x14502, // h1 + 0x19a: 0x65509, // onstalled + 0x19b: 0x15603, // var + 0x19c: 0x11c04, // time + 0x19e: 0x67002, // ms + 0x19f: 0x32506, // header + 0x1a0: 0x4ce09, // onmessage + 0x1a1: 0x56205, // nonce + 0x1a2: 0x2560a, // formaction + 0x1a3: 0x20806, // center + 0x1a4: 0x3704, // nobr + 0x1a5: 0x58905, // table + 0x1a6: 0x49d07, // listing + 0x1a7: 0x18a06, // legend + 0x1a9: 0x28309, // challenge + 0x1aa: 0x23006, // figure + 0x1ab: 0x8e05, // media + 0x1ae: 0x8104, // type + 0x1af: 0x11904, // font + 0x1b0: 0x4ce0e, // onmessageerror + 0x1b1: 0x36508, // seamless + 0x1b2: 0x5f03, // dfn + 0x1b3: 0x19205, // defer + 0x1b4: 0x6b03, // low + 0x1b5: 0x62d09, // onseeking + 0x1b6: 0x5170b, // onmouseover + 0x1b7: 0x29a0a, // novalidate + 0x1b8: 0x7160a, // workertype + 0x1ba: 0x3c107, // itemref + 0x1bd: 0x1, // a + 0x1be: 0x30003, // map + 0x1bf: 0x11a0c, // ontimeupdate + 0x1c0: 0x14707, // bgsound + 0x1c1: 0x3206, // keygen + 0x1c2: 0x2705, // tbody + 0x1c5: 0x64006, // onshow + 0x1c7: 0x2501, // s + 0x1c8: 0x4f07, // pattern + 0x1cc: 0x13610, // oncanplaythrough + 0x1ce: 0x2bf02, // dd + 0x1cf: 0x6f306, // srcset + 0x1d0: 0x15903, // big + 0x1d2: 0x64d08, // sortable + 0x1d3: 0x47407, // onkeyup + 0x1d5: 0x59806, // onplay + 0x1d7: 0x4ac04, // meta + 0x1d8: 0x3f706, // ondrop + 0x1da: 0x5fc08, // onscroll + 0x1db: 0x1e30b, // crossorigin + 0x1dc: 0x5670a, // onpageshow + 0x1dd: 0x4, // abbr + 0x1de: 0x5e02, // td + 0x1df: 0x57f0f, // contenteditable + 0x1e0: 0x25a06, // action + 0x1e1: 0x10a0b, // playsinline + 0x1e2: 0x42507, // onfocus + 0x1e3: 0x2c808, // hreflang + 0x1e5: 0x50a0a, // onmouseout + 0x1e6: 0x5e607, // onreset + 0x1e7: 0x10608, // autoplay + 0x1ea: 0x67106, // scoped + 0x1ec: 0x30a, // radiogroup + 0x1ee: 0x3740b, // contextmenu + 0x1ef: 0x52209, // onmouseup + 0x1f1: 0x2b206, // hgroup + 0x1f2: 0x1f00f, // allowfullscreen + 0x1f3: 0x4b208, // tabindex + 0x1f6: 0x2f707, // isindex + 0x1f7: 0x1a0e, // accept-charset + 0x1f8: 0x2960e, // formnovalidate + 0x1fb: 0x1b90e, // annotation-xml + 0x1fc: 0x4205, // embed + 0x1fd: 0x20006, // script + 0x1fe: 0x16206, // dialog + 0x1ff: 0x1c707, // command +} + +const atomText = "abbradiogrouparamainavalueaccept-charsetbodyaccesskeygenobro" + + "wspanoembedetailsampatternoframesetdfnomoduleallowpaymentreq" + + "uestrikeytypeallowusermediagroupictureversedirnameterubyaltf" + + "ooterasyncanvasidefaultitleaudioncancelabelooptgroupingautof" + + "ocusandboxmplaceholderautoplaysinlinebasefontimeupdateviacac" + + "heightmlbdoncanplaythrough1bgsoundisabledivarbigblinkbdialog" + + "blockquotebuttonabortrackindraggablegendcodefercolgrouplaint" + + "extranslatecolorcolspannotation-xmlcommandcontrolshapecoords" + + "lotcrossoriginsmallowfullscreenoscriptfacenterfieldsetfigcap" + + "tionafterprintegrityfigurequiredforeignObjectforeignobjectfo" + + "rmactionautocompleteerrorformenctypemustmatchallengeformmeth" + + "odformnovalidatetimeformtargethgrouposterhiddenhigh2hreflang" + + "http-equivideonclickiframeimageimglyph3isindexismappletitemt" + + "ypemanifestrongmarqueematheadersortedmaxlength4minlength5mte" + + "xtareadonlymultiplemutedoncloseamlessourceoncontextmenuitemi" + + "doncopyoncuechangeoncutondblclickondragendondragenterondrage" + + "xitemreferrerpolicyondragleaveondragoverondragstarticleondro" + + "pzonemptiedondurationchangeonendedonerroronfocuspaceronhashc" + + "hangeoninputmodeloninvalidonkeydownloadonkeypresspellchecked" + + "onkeyupreloadonlanguagechangeonloadeddatalistingonloadedmeta" + + "databindexonloadendonloadstartonmessageerroronmousedownonmou" + + "seenteronmouseleaveonmousemoveonmouseoutputonmouseoveronmous" + + "eupromptonmousewheelonofflineononlineonpagehidescitempropeno" + + "nceonpageshowbronpastepublicontenteditableonpausemaponplayin" + + "gonpopstateonprogressrcdoclassectionbluronratechangeonreject" + + "ionhandledonresetonresizesrclangonscrollonsecuritypolicyviol" + + "ationauxclickonseekedonseekingonselectedonshowidth6onsortabl" + + "eonstalledonstorageonsubmitemscopedonsuspendontoggleonunhand" + + "ledrejectionbeforeprintonunloadonvolumechangeonwaitingonwhee" + + "loptimumalignmarkoptionbeforeunloaddressrcsetstylesummarysup" + + "svgsystemplateworkertypewrap" diff --git a/vendor/golang.org/x/net/html/const.go b/vendor/golang.org/x/net/html/const.go new file mode 100644 index 000000000..b37e62124 --- /dev/null +++ b/vendor/golang.org/x/net/html/const.go @@ -0,0 +1,104 @@ +// Copyright 2011 The Go 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 html + +// Section 12.2.3.2 of the HTML5 specification says "The following elements +// have varying levels of special parsing rules". +// https://html.spec.whatwg.org/multipage/syntax.html#the-stack-of-open-elements +var isSpecialElementMap = map[string]bool{ + "address": true, + "applet": true, + "area": true, + "article": true, + "aside": true, + "base": true, + "basefont": true, + "bgsound": true, + "blockquote": true, + "body": true, + "br": true, + "button": true, + "caption": true, + "center": true, + "col": true, + "colgroup": true, + "dd": true, + "details": true, + "dir": true, + "div": true, + "dl": true, + "dt": true, + "embed": true, + "fieldset": true, + "figcaption": true, + "figure": true, + "footer": true, + "form": true, + "frame": true, + "frameset": true, + "h1": true, + "h2": true, + "h3": true, + "h4": true, + "h5": true, + "h6": true, + "head": true, + "header": true, + "hgroup": true, + "hr": true, + "html": true, + "iframe": true, + "img": true, + "input": true, + "isindex": true, // The 'isindex' element has been removed, but keep it for backwards compatibility. + "keygen": true, + "li": true, + "link": true, + "listing": true, + "main": true, + "marquee": true, + "menu": true, + "meta": true, + "nav": true, + "noembed": true, + "noframes": true, + "noscript": true, + "object": true, + "ol": true, + "p": true, + "param": true, + "plaintext": true, + "pre": true, + "script": true, + "section": true, + "select": true, + "source": true, + "style": true, + "summary": true, + "table": true, + "tbody": true, + "td": true, + "template": true, + "textarea": true, + "tfoot": true, + "th": true, + "thead": true, + "title": true, + "tr": true, + "track": true, + "ul": true, + "wbr": true, + "xmp": true, +} + +func isSpecialElement(element *Node) bool { + switch element.Namespace { + case "", "html": + return isSpecialElementMap[element.Data] + case "svg": + return element.Data == "foreignObject" + } + return false +} diff --git a/vendor/golang.org/x/net/html/doc.go b/vendor/golang.org/x/net/html/doc.go new file mode 100644 index 000000000..822ed42a0 --- /dev/null +++ b/vendor/golang.org/x/net/html/doc.go @@ -0,0 +1,106 @@ +// Copyright 2010 The Go 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 html implements an HTML5-compliant tokenizer and parser. + +Tokenization is done by creating a Tokenizer for an io.Reader r. It is the +caller's responsibility to ensure that r provides UTF-8 encoded HTML. + + z := html.NewTokenizer(r) + +Given a Tokenizer z, the HTML is tokenized by repeatedly calling z.Next(), +which parses the next token and returns its type, or an error: + + for { + tt := z.Next() + if tt == html.ErrorToken { + // ... + return ... + } + // Process the current token. + } + +There are two APIs for retrieving the current token. The high-level API is to +call Token; the low-level API is to call Text or TagName / TagAttr. Both APIs +allow optionally calling Raw after Next but before Token, Text, TagName, or +TagAttr. In EBNF notation, the valid call sequence per token is: + + Next {Raw} [ Token | Text | TagName {TagAttr} ] + +Token returns an independent data structure that completely describes a token. +Entities (such as "<") are unescaped, tag names and attribute keys are +lower-cased, and attributes are collected into a []Attribute. For example: + + for { + if z.Next() == html.ErrorToken { + // Returning io.EOF indicates success. + return z.Err() + } + emitToken(z.Token()) + } + +The low-level API performs fewer allocations and copies, but the contents of +the []byte values returned by Text, TagName and TagAttr may change on the next +call to Next. For example, to extract an HTML page's anchor text: + + depth := 0 + for { + tt := z.Next() + switch tt { + case html.ErrorToken: + return z.Err() + case html.TextToken: + if depth > 0 { + // emitBytes should copy the []byte it receives, + // if it doesn't process it immediately. + emitBytes(z.Text()) + } + case html.StartTagToken, html.EndTagToken: + tn, _ := z.TagName() + if len(tn) == 1 && tn[0] == 'a' { + if tt == html.StartTagToken { + depth++ + } else { + depth-- + } + } + } + } + +Parsing is done by calling Parse with an io.Reader, which returns the root of +the parse tree (the document element) as a *Node. It is the caller's +responsibility to ensure that the Reader provides UTF-8 encoded HTML. For +example, to process each anchor node in depth-first order: + + doc, err := html.Parse(r) + if err != nil { + // ... + } + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "a" { + // Do something with n... + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(doc) + +The relevant specifications include: +https://html.spec.whatwg.org/multipage/syntax.html and +https://html.spec.whatwg.org/multipage/syntax.html#tokenization +*/ +package html // import "golang.org/x/net/html" + +// The tokenization algorithm implemented by this package is not a line-by-line +// transliteration of the relatively verbose state-machine in the WHATWG +// specification. A more direct approach is used instead, where the program +// counter implies the state, such as whether it is tokenizing a tag or a text +// node. Specification compliance is verified by checking expected and actual +// outputs over a test suite rather than aiming for algorithmic fidelity. + +// TODO(nigeltao): Does a DOM API belong in this package or a separate one? +// TODO(nigeltao): How does parsing interact with a JavaScript engine? diff --git a/vendor/golang.org/x/net/html/doctype.go b/vendor/golang.org/x/net/html/doctype.go new file mode 100644 index 000000000..c484e5a94 --- /dev/null +++ b/vendor/golang.org/x/net/html/doctype.go @@ -0,0 +1,156 @@ +// Copyright 2011 The Go 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 html + +import ( + "strings" +) + +// parseDoctype parses the data from a DoctypeToken into a name, +// public identifier, and system identifier. It returns a Node whose Type +// is DoctypeNode, whose Data is the name, and which has attributes +// named "system" and "public" for the two identifiers if they were present. +// quirks is whether the document should be parsed in "quirks mode". +func parseDoctype(s string) (n *Node, quirks bool) { + n = &Node{Type: DoctypeNode} + + // Find the name. + space := strings.IndexAny(s, whitespace) + if space == -1 { + space = len(s) + } + n.Data = s[:space] + // The comparison to "html" is case-sensitive. + if n.Data != "html" { + quirks = true + } + n.Data = strings.ToLower(n.Data) + s = strings.TrimLeft(s[space:], whitespace) + + if len(s) < 6 { + // It can't start with "PUBLIC" or "SYSTEM". + // Ignore the rest of the string. + return n, quirks || s != "" + } + + key := strings.ToLower(s[:6]) + s = s[6:] + for key == "public" || key == "system" { + s = strings.TrimLeft(s, whitespace) + if s == "" { + break + } + quote := s[0] + if quote != '"' && quote != '\'' { + break + } + s = s[1:] + q := strings.IndexRune(s, rune(quote)) + var id string + if q == -1 { + id = s + s = "" + } else { + id = s[:q] + s = s[q+1:] + } + n.Attr = append(n.Attr, Attribute{Key: key, Val: id}) + if key == "public" { + key = "system" + } else { + key = "" + } + } + + if key != "" || s != "" { + quirks = true + } else if len(n.Attr) > 0 { + if n.Attr[0].Key == "public" { + public := strings.ToLower(n.Attr[0].Val) + switch public { + case "-//w3o//dtd w3 html strict 3.0//en//", "-/w3d/dtd html 4.0 transitional/en", "html": + quirks = true + default: + for _, q := range quirkyIDs { + if strings.HasPrefix(public, q) { + quirks = true + break + } + } + } + // The following two public IDs only cause quirks mode if there is no system ID. + if len(n.Attr) == 1 && (strings.HasPrefix(public, "-//w3c//dtd html 4.01 frameset//") || + strings.HasPrefix(public, "-//w3c//dtd html 4.01 transitional//")) { + quirks = true + } + } + if lastAttr := n.Attr[len(n.Attr)-1]; lastAttr.Key == "system" && + strings.ToLower(lastAttr.Val) == "http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd" { + quirks = true + } + } + + return n, quirks +} + +// quirkyIDs is a list of public doctype identifiers that cause a document +// to be interpreted in quirks mode. The identifiers should be in lower case. +var quirkyIDs = []string{ + "+//silmaril//dtd html pro v0r11 19970101//", + "-//advasoft ltd//dtd html 3.0 aswedit + extensions//", + "-//as//dtd html 3.0 aswedit + extensions//", + "-//ietf//dtd html 2.0 level 1//", + "-//ietf//dtd html 2.0 level 2//", + "-//ietf//dtd html 2.0 strict level 1//", + "-//ietf//dtd html 2.0 strict level 2//", + "-//ietf//dtd html 2.0 strict//", + "-//ietf//dtd html 2.0//", + "-//ietf//dtd html 2.1e//", + "-//ietf//dtd html 3.0//", + "-//ietf//dtd html 3.2 final//", + "-//ietf//dtd html 3.2//", + "-//ietf//dtd html 3//", + "-//ietf//dtd html level 0//", + "-//ietf//dtd html level 1//", + "-//ietf//dtd html level 2//", + "-//ietf//dtd html level 3//", + "-//ietf//dtd html strict level 0//", + "-//ietf//dtd html strict level 1//", + "-//ietf//dtd html strict level 2//", + "-//ietf//dtd html strict level 3//", + "-//ietf//dtd html strict//", + "-//ietf//dtd html//", + "-//metrius//dtd metrius presentational//", + "-//microsoft//dtd internet explorer 2.0 html strict//", + "-//microsoft//dtd internet explorer 2.0 html//", + "-//microsoft//dtd internet explorer 2.0 tables//", + "-//microsoft//dtd internet explorer 3.0 html strict//", + "-//microsoft//dtd internet explorer 3.0 html//", + "-//microsoft//dtd internet explorer 3.0 tables//", + "-//netscape comm. corp.//dtd html//", + "-//netscape comm. corp.//dtd strict html//", + "-//o'reilly and associates//dtd html 2.0//", + "-//o'reilly and associates//dtd html extended 1.0//", + "-//o'reilly and associates//dtd html extended relaxed 1.0//", + "-//softquad software//dtd hotmetal pro 6.0::19990601::extensions to html 4.0//", + "-//softquad//dtd hotmetal pro 4.0::19971010::extensions to html 4.0//", + "-//spyglass//dtd html 2.0 extended//", + "-//sq//dtd html 2.0 hotmetal + extensions//", + "-//sun microsystems corp.//dtd hotjava html//", + "-//sun microsystems corp.//dtd hotjava strict html//", + "-//w3c//dtd html 3 1995-03-24//", + "-//w3c//dtd html 3.2 draft//", + "-//w3c//dtd html 3.2 final//", + "-//w3c//dtd html 3.2//", + "-//w3c//dtd html 3.2s draft//", + "-//w3c//dtd html 4.0 frameset//", + "-//w3c//dtd html 4.0 transitional//", + "-//w3c//dtd html experimental 19960712//", + "-//w3c//dtd html experimental 970421//", + "-//w3c//dtd w3 html//", + "-//w3o//dtd w3 html 3.0//", + "-//webtechs//dtd mozilla html 2.0//", + "-//webtechs//dtd mozilla html//", +} diff --git a/vendor/golang.org/x/net/html/entity.go b/vendor/golang.org/x/net/html/entity.go new file mode 100644 index 000000000..a50c04c60 --- /dev/null +++ b/vendor/golang.org/x/net/html/entity.go @@ -0,0 +1,2253 @@ +// Copyright 2010 The Go 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 html + +// All entities that do not end with ';' are 6 or fewer bytes long. +const longestEntityWithoutSemicolon = 6 + +// entity is a map from HTML entity names to their values. The semicolon matters: +// https://html.spec.whatwg.org/multipage/syntax.html#named-character-references +// lists both "amp" and "amp;" as two separate entries. +// +// Note that the HTML5 list is larger than the HTML4 list at +// http://www.w3.org/TR/html4/sgml/entities.html +var entity = map[string]rune{ + "AElig;": '\U000000C6', + "AMP;": '\U00000026', + "Aacute;": '\U000000C1', + "Abreve;": '\U00000102', + "Acirc;": '\U000000C2', + "Acy;": '\U00000410', + "Afr;": '\U0001D504', + "Agrave;": '\U000000C0', + "Alpha;": '\U00000391', + "Amacr;": '\U00000100', + "And;": '\U00002A53', + "Aogon;": '\U00000104', + "Aopf;": '\U0001D538', + "ApplyFunction;": '\U00002061', + "Aring;": '\U000000C5', + "Ascr;": '\U0001D49C', + "Assign;": '\U00002254', + "Atilde;": '\U000000C3', + "Auml;": '\U000000C4', + "Backslash;": '\U00002216', + "Barv;": '\U00002AE7', + "Barwed;": '\U00002306', + "Bcy;": '\U00000411', + "Because;": '\U00002235', + "Bernoullis;": '\U0000212C', + "Beta;": '\U00000392', + "Bfr;": '\U0001D505', + "Bopf;": '\U0001D539', + "Breve;": '\U000002D8', + "Bscr;": '\U0000212C', + "Bumpeq;": '\U0000224E', + "CHcy;": '\U00000427', + "COPY;": '\U000000A9', + "Cacute;": '\U00000106', + "Cap;": '\U000022D2', + "CapitalDifferentialD;": '\U00002145', + "Cayleys;": '\U0000212D', + "Ccaron;": '\U0000010C', + "Ccedil;": '\U000000C7', + "Ccirc;": '\U00000108', + "Cconint;": '\U00002230', + "Cdot;": '\U0000010A', + "Cedilla;": '\U000000B8', + "CenterDot;": '\U000000B7', + "Cfr;": '\U0000212D', + "Chi;": '\U000003A7', + "CircleDot;": '\U00002299', + "CircleMinus;": '\U00002296', + "CirclePlus;": '\U00002295', + "CircleTimes;": '\U00002297', + "ClockwiseContourIntegral;": '\U00002232', + "CloseCurlyDoubleQuote;": '\U0000201D', + "CloseCurlyQuote;": '\U00002019', + "Colon;": '\U00002237', + "Colone;": '\U00002A74', + "Congruent;": '\U00002261', + "Conint;": '\U0000222F', + "ContourIntegral;": '\U0000222E', + "Copf;": '\U00002102', + "Coproduct;": '\U00002210', + "CounterClockwiseContourIntegral;": '\U00002233', + "Cross;": '\U00002A2F', + "Cscr;": '\U0001D49E', + "Cup;": '\U000022D3', + "CupCap;": '\U0000224D', + "DD;": '\U00002145', + "DDotrahd;": '\U00002911', + "DJcy;": '\U00000402', + "DScy;": '\U00000405', + "DZcy;": '\U0000040F', + "Dagger;": '\U00002021', + "Darr;": '\U000021A1', + "Dashv;": '\U00002AE4', + "Dcaron;": '\U0000010E', + "Dcy;": '\U00000414', + "Del;": '\U00002207', + "Delta;": '\U00000394', + "Dfr;": '\U0001D507', + "DiacriticalAcute;": '\U000000B4', + "DiacriticalDot;": '\U000002D9', + "DiacriticalDoubleAcute;": '\U000002DD', + "DiacriticalGrave;": '\U00000060', + "DiacriticalTilde;": '\U000002DC', + "Diamond;": '\U000022C4', + "DifferentialD;": '\U00002146', + "Dopf;": '\U0001D53B', + "Dot;": '\U000000A8', + "DotDot;": '\U000020DC', + "DotEqual;": '\U00002250', + "DoubleContourIntegral;": '\U0000222F', + "DoubleDot;": '\U000000A8', + "DoubleDownArrow;": '\U000021D3', + "DoubleLeftArrow;": '\U000021D0', + "DoubleLeftRightArrow;": '\U000021D4', + "DoubleLeftTee;": '\U00002AE4', + "DoubleLongLeftArrow;": '\U000027F8', + "DoubleLongLeftRightArrow;": '\U000027FA', + "DoubleLongRightArrow;": '\U000027F9', + "DoubleRightArrow;": '\U000021D2', + "DoubleRightTee;": '\U000022A8', + "DoubleUpArrow;": '\U000021D1', + "DoubleUpDownArrow;": '\U000021D5', + "DoubleVerticalBar;": '\U00002225', + "DownArrow;": '\U00002193', + "DownArrowBar;": '\U00002913', + "DownArrowUpArrow;": '\U000021F5', + "DownBreve;": '\U00000311', + "DownLeftRightVector;": '\U00002950', + "DownLeftTeeVector;": '\U0000295E', + "DownLeftVector;": '\U000021BD', + "DownLeftVectorBar;": '\U00002956', + "DownRightTeeVector;": '\U0000295F', + "DownRightVector;": '\U000021C1', + "DownRightVectorBar;": '\U00002957', + "DownTee;": '\U000022A4', + "DownTeeArrow;": '\U000021A7', + "Downarrow;": '\U000021D3', + "Dscr;": '\U0001D49F', + "Dstrok;": '\U00000110', + "ENG;": '\U0000014A', + "ETH;": '\U000000D0', + "Eacute;": '\U000000C9', + "Ecaron;": '\U0000011A', + "Ecirc;": '\U000000CA', + "Ecy;": '\U0000042D', + "Edot;": '\U00000116', + "Efr;": '\U0001D508', + "Egrave;": '\U000000C8', + "Element;": '\U00002208', + "Emacr;": '\U00000112', + "EmptySmallSquare;": '\U000025FB', + "EmptyVerySmallSquare;": '\U000025AB', + "Eogon;": '\U00000118', + "Eopf;": '\U0001D53C', + "Epsilon;": '\U00000395', + "Equal;": '\U00002A75', + "EqualTilde;": '\U00002242', + "Equilibrium;": '\U000021CC', + "Escr;": '\U00002130', + "Esim;": '\U00002A73', + "Eta;": '\U00000397', + "Euml;": '\U000000CB', + "Exists;": '\U00002203', + "ExponentialE;": '\U00002147', + "Fcy;": '\U00000424', + "Ffr;": '\U0001D509', + "FilledSmallSquare;": '\U000025FC', + "FilledVerySmallSquare;": '\U000025AA', + "Fopf;": '\U0001D53D', + "ForAll;": '\U00002200', + "Fouriertrf;": '\U00002131', + "Fscr;": '\U00002131', + "GJcy;": '\U00000403', + "GT;": '\U0000003E', + "Gamma;": '\U00000393', + "Gammad;": '\U000003DC', + "Gbreve;": '\U0000011E', + "Gcedil;": '\U00000122', + "Gcirc;": '\U0000011C', + "Gcy;": '\U00000413', + "Gdot;": '\U00000120', + "Gfr;": '\U0001D50A', + "Gg;": '\U000022D9', + "Gopf;": '\U0001D53E', + "GreaterEqual;": '\U00002265', + "GreaterEqualLess;": '\U000022DB', + "GreaterFullEqual;": '\U00002267', + "GreaterGreater;": '\U00002AA2', + "GreaterLess;": '\U00002277', + "GreaterSlantEqual;": '\U00002A7E', + "GreaterTilde;": '\U00002273', + "Gscr;": '\U0001D4A2', + "Gt;": '\U0000226B', + "HARDcy;": '\U0000042A', + "Hacek;": '\U000002C7', + "Hat;": '\U0000005E', + "Hcirc;": '\U00000124', + "Hfr;": '\U0000210C', + "HilbertSpace;": '\U0000210B', + "Hopf;": '\U0000210D', + "HorizontalLine;": '\U00002500', + "Hscr;": '\U0000210B', + "Hstrok;": '\U00000126', + "HumpDownHump;": '\U0000224E', + "HumpEqual;": '\U0000224F', + "IEcy;": '\U00000415', + "IJlig;": '\U00000132', + "IOcy;": '\U00000401', + "Iacute;": '\U000000CD', + "Icirc;": '\U000000CE', + "Icy;": '\U00000418', + "Idot;": '\U00000130', + "Ifr;": '\U00002111', + "Igrave;": '\U000000CC', + "Im;": '\U00002111', + "Imacr;": '\U0000012A', + "ImaginaryI;": '\U00002148', + "Implies;": '\U000021D2', + "Int;": '\U0000222C', + "Integral;": '\U0000222B', + "Intersection;": '\U000022C2', + "InvisibleComma;": '\U00002063', + "InvisibleTimes;": '\U00002062', + "Iogon;": '\U0000012E', + "Iopf;": '\U0001D540', + "Iota;": '\U00000399', + "Iscr;": '\U00002110', + "Itilde;": '\U00000128', + "Iukcy;": '\U00000406', + "Iuml;": '\U000000CF', + "Jcirc;": '\U00000134', + "Jcy;": '\U00000419', + "Jfr;": '\U0001D50D', + "Jopf;": '\U0001D541', + "Jscr;": '\U0001D4A5', + "Jsercy;": '\U00000408', + "Jukcy;": '\U00000404', + "KHcy;": '\U00000425', + "KJcy;": '\U0000040C', + "Kappa;": '\U0000039A', + "Kcedil;": '\U00000136', + "Kcy;": '\U0000041A', + "Kfr;": '\U0001D50E', + "Kopf;": '\U0001D542', + "Kscr;": '\U0001D4A6', + "LJcy;": '\U00000409', + "LT;": '\U0000003C', + "Lacute;": '\U00000139', + "Lambda;": '\U0000039B', + "Lang;": '\U000027EA', + "Laplacetrf;": '\U00002112', + "Larr;": '\U0000219E', + "Lcaron;": '\U0000013D', + "Lcedil;": '\U0000013B', + "Lcy;": '\U0000041B', + "LeftAngleBracket;": '\U000027E8', + "LeftArrow;": '\U00002190', + "LeftArrowBar;": '\U000021E4', + "LeftArrowRightArrow;": '\U000021C6', + "LeftCeiling;": '\U00002308', + "LeftDoubleBracket;": '\U000027E6', + "LeftDownTeeVector;": '\U00002961', + "LeftDownVector;": '\U000021C3', + "LeftDownVectorBar;": '\U00002959', + "LeftFloor;": '\U0000230A', + "LeftRightArrow;": '\U00002194', + "LeftRightVector;": '\U0000294E', + "LeftTee;": '\U000022A3', + "LeftTeeArrow;": '\U000021A4', + "LeftTeeVector;": '\U0000295A', + "LeftTriangle;": '\U000022B2', + "LeftTriangleBar;": '\U000029CF', + "LeftTriangleEqual;": '\U000022B4', + "LeftUpDownVector;": '\U00002951', + "LeftUpTeeVector;": '\U00002960', + "LeftUpVector;": '\U000021BF', + "LeftUpVectorBar;": '\U00002958', + "LeftVector;": '\U000021BC', + "LeftVectorBar;": '\U00002952', + "Leftarrow;": '\U000021D0', + "Leftrightarrow;": '\U000021D4', + "LessEqualGreater;": '\U000022DA', + "LessFullEqual;": '\U00002266', + "LessGreater;": '\U00002276', + "LessLess;": '\U00002AA1', + "LessSlantEqual;": '\U00002A7D', + "LessTilde;": '\U00002272', + "Lfr;": '\U0001D50F', + "Ll;": '\U000022D8', + "Lleftarrow;": '\U000021DA', + "Lmidot;": '\U0000013F', + "LongLeftArrow;": '\U000027F5', + "LongLeftRightArrow;": '\U000027F7', + "LongRightArrow;": '\U000027F6', + "Longleftarrow;": '\U000027F8', + "Longleftrightarrow;": '\U000027FA', + "Longrightarrow;": '\U000027F9', + "Lopf;": '\U0001D543', + "LowerLeftArrow;": '\U00002199', + "LowerRightArrow;": '\U00002198', + "Lscr;": '\U00002112', + "Lsh;": '\U000021B0', + "Lstrok;": '\U00000141', + "Lt;": '\U0000226A', + "Map;": '\U00002905', + "Mcy;": '\U0000041C', + "MediumSpace;": '\U0000205F', + "Mellintrf;": '\U00002133', + "Mfr;": '\U0001D510', + "MinusPlus;": '\U00002213', + "Mopf;": '\U0001D544', + "Mscr;": '\U00002133', + "Mu;": '\U0000039C', + "NJcy;": '\U0000040A', + "Nacute;": '\U00000143', + "Ncaron;": '\U00000147', + "Ncedil;": '\U00000145', + "Ncy;": '\U0000041D', + "NegativeMediumSpace;": '\U0000200B', + "NegativeThickSpace;": '\U0000200B', + "NegativeThinSpace;": '\U0000200B', + "NegativeVeryThinSpace;": '\U0000200B', + "NestedGreaterGreater;": '\U0000226B', + "NestedLessLess;": '\U0000226A', + "NewLine;": '\U0000000A', + "Nfr;": '\U0001D511', + "NoBreak;": '\U00002060', + "NonBreakingSpace;": '\U000000A0', + "Nopf;": '\U00002115', + "Not;": '\U00002AEC', + "NotCongruent;": '\U00002262', + "NotCupCap;": '\U0000226D', + "NotDoubleVerticalBar;": '\U00002226', + "NotElement;": '\U00002209', + "NotEqual;": '\U00002260', + "NotExists;": '\U00002204', + "NotGreater;": '\U0000226F', + "NotGreaterEqual;": '\U00002271', + "NotGreaterLess;": '\U00002279', + "NotGreaterTilde;": '\U00002275', + "NotLeftTriangle;": '\U000022EA', + "NotLeftTriangleEqual;": '\U000022EC', + "NotLess;": '\U0000226E', + "NotLessEqual;": '\U00002270', + "NotLessGreater;": '\U00002278', + "NotLessTilde;": '\U00002274', + "NotPrecedes;": '\U00002280', + "NotPrecedesSlantEqual;": '\U000022E0', + "NotReverseElement;": '\U0000220C', + "NotRightTriangle;": '\U000022EB', + "NotRightTriangleEqual;": '\U000022ED', + "NotSquareSubsetEqual;": '\U000022E2', + "NotSquareSupersetEqual;": '\U000022E3', + "NotSubsetEqual;": '\U00002288', + "NotSucceeds;": '\U00002281', + "NotSucceedsSlantEqual;": '\U000022E1', + "NotSupersetEqual;": '\U00002289', + "NotTilde;": '\U00002241', + "NotTildeEqual;": '\U00002244', + "NotTildeFullEqual;": '\U00002247', + "NotTildeTilde;": '\U00002249', + "NotVerticalBar;": '\U00002224', + "Nscr;": '\U0001D4A9', + "Ntilde;": '\U000000D1', + "Nu;": '\U0000039D', + "OElig;": '\U00000152', + "Oacute;": '\U000000D3', + "Ocirc;": '\U000000D4', + "Ocy;": '\U0000041E', + "Odblac;": '\U00000150', + "Ofr;": '\U0001D512', + "Ograve;": '\U000000D2', + "Omacr;": '\U0000014C', + "Omega;": '\U000003A9', + "Omicron;": '\U0000039F', + "Oopf;": '\U0001D546', + "OpenCurlyDoubleQuote;": '\U0000201C', + "OpenCurlyQuote;": '\U00002018', + "Or;": '\U00002A54', + "Oscr;": '\U0001D4AA', + "Oslash;": '\U000000D8', + "Otilde;": '\U000000D5', + "Otimes;": '\U00002A37', + "Ouml;": '\U000000D6', + "OverBar;": '\U0000203E', + "OverBrace;": '\U000023DE', + "OverBracket;": '\U000023B4', + "OverParenthesis;": '\U000023DC', + "PartialD;": '\U00002202', + "Pcy;": '\U0000041F', + "Pfr;": '\U0001D513', + "Phi;": '\U000003A6', + "Pi;": '\U000003A0', + "PlusMinus;": '\U000000B1', + "Poincareplane;": '\U0000210C', + "Popf;": '\U00002119', + "Pr;": '\U00002ABB', + "Precedes;": '\U0000227A', + "PrecedesEqual;": '\U00002AAF', + "PrecedesSlantEqual;": '\U0000227C', + "PrecedesTilde;": '\U0000227E', + "Prime;": '\U00002033', + "Product;": '\U0000220F', + "Proportion;": '\U00002237', + "Proportional;": '\U0000221D', + "Pscr;": '\U0001D4AB', + "Psi;": '\U000003A8', + "QUOT;": '\U00000022', + "Qfr;": '\U0001D514', + "Qopf;": '\U0000211A', + "Qscr;": '\U0001D4AC', + "RBarr;": '\U00002910', + "REG;": '\U000000AE', + "Racute;": '\U00000154', + "Rang;": '\U000027EB', + "Rarr;": '\U000021A0', + "Rarrtl;": '\U00002916', + "Rcaron;": '\U00000158', + "Rcedil;": '\U00000156', + "Rcy;": '\U00000420', + "Re;": '\U0000211C', + "ReverseElement;": '\U0000220B', + "ReverseEquilibrium;": '\U000021CB', + "ReverseUpEquilibrium;": '\U0000296F', + "Rfr;": '\U0000211C', + "Rho;": '\U000003A1', + "RightAngleBracket;": '\U000027E9', + "RightArrow;": '\U00002192', + "RightArrowBar;": '\U000021E5', + "RightArrowLeftArrow;": '\U000021C4', + "RightCeiling;": '\U00002309', + "RightDoubleBracket;": '\U000027E7', + "RightDownTeeVector;": '\U0000295D', + "RightDownVector;": '\U000021C2', + "RightDownVectorBar;": '\U00002955', + "RightFloor;": '\U0000230B', + "RightTee;": '\U000022A2', + "RightTeeArrow;": '\U000021A6', + "RightTeeVector;": '\U0000295B', + "RightTriangle;": '\U000022B3', + "RightTriangleBar;": '\U000029D0', + "RightTriangleEqual;": '\U000022B5', + "RightUpDownVector;": '\U0000294F', + "RightUpTeeVector;": '\U0000295C', + "RightUpVector;": '\U000021BE', + "RightUpVectorBar;": '\U00002954', + "RightVector;": '\U000021C0', + "RightVectorBar;": '\U00002953', + "Rightarrow;": '\U000021D2', + "Ropf;": '\U0000211D', + "RoundImplies;": '\U00002970', + "Rrightarrow;": '\U000021DB', + "Rscr;": '\U0000211B', + "Rsh;": '\U000021B1', + "RuleDelayed;": '\U000029F4', + "SHCHcy;": '\U00000429', + "SHcy;": '\U00000428', + "SOFTcy;": '\U0000042C', + "Sacute;": '\U0000015A', + "Sc;": '\U00002ABC', + "Scaron;": '\U00000160', + "Scedil;": '\U0000015E', + "Scirc;": '\U0000015C', + "Scy;": '\U00000421', + "Sfr;": '\U0001D516', + "ShortDownArrow;": '\U00002193', + "ShortLeftArrow;": '\U00002190', + "ShortRightArrow;": '\U00002192', + "ShortUpArrow;": '\U00002191', + "Sigma;": '\U000003A3', + "SmallCircle;": '\U00002218', + "Sopf;": '\U0001D54A', + "Sqrt;": '\U0000221A', + "Square;": '\U000025A1', + "SquareIntersection;": '\U00002293', + "SquareSubset;": '\U0000228F', + "SquareSubsetEqual;": '\U00002291', + "SquareSuperset;": '\U00002290', + "SquareSupersetEqual;": '\U00002292', + "SquareUnion;": '\U00002294', + "Sscr;": '\U0001D4AE', + "Star;": '\U000022C6', + "Sub;": '\U000022D0', + "Subset;": '\U000022D0', + "SubsetEqual;": '\U00002286', + "Succeeds;": '\U0000227B', + "SucceedsEqual;": '\U00002AB0', + "SucceedsSlantEqual;": '\U0000227D', + "SucceedsTilde;": '\U0000227F', + "SuchThat;": '\U0000220B', + "Sum;": '\U00002211', + "Sup;": '\U000022D1', + "Superset;": '\U00002283', + "SupersetEqual;": '\U00002287', + "Supset;": '\U000022D1', + "THORN;": '\U000000DE', + "TRADE;": '\U00002122', + "TSHcy;": '\U0000040B', + "TScy;": '\U00000426', + "Tab;": '\U00000009', + "Tau;": '\U000003A4', + "Tcaron;": '\U00000164', + "Tcedil;": '\U00000162', + "Tcy;": '\U00000422', + "Tfr;": '\U0001D517', + "Therefore;": '\U00002234', + "Theta;": '\U00000398', + "ThinSpace;": '\U00002009', + "Tilde;": '\U0000223C', + "TildeEqual;": '\U00002243', + "TildeFullEqual;": '\U00002245', + "TildeTilde;": '\U00002248', + "Topf;": '\U0001D54B', + "TripleDot;": '\U000020DB', + "Tscr;": '\U0001D4AF', + "Tstrok;": '\U00000166', + "Uacute;": '\U000000DA', + "Uarr;": '\U0000219F', + "Uarrocir;": '\U00002949', + "Ubrcy;": '\U0000040E', + "Ubreve;": '\U0000016C', + "Ucirc;": '\U000000DB', + "Ucy;": '\U00000423', + "Udblac;": '\U00000170', + "Ufr;": '\U0001D518', + "Ugrave;": '\U000000D9', + "Umacr;": '\U0000016A', + "UnderBar;": '\U0000005F', + "UnderBrace;": '\U000023DF', + "UnderBracket;": '\U000023B5', + "UnderParenthesis;": '\U000023DD', + "Union;": '\U000022C3', + "UnionPlus;": '\U0000228E', + "Uogon;": '\U00000172', + "Uopf;": '\U0001D54C', + "UpArrow;": '\U00002191', + "UpArrowBar;": '\U00002912', + "UpArrowDownArrow;": '\U000021C5', + "UpDownArrow;": '\U00002195', + "UpEquilibrium;": '\U0000296E', + "UpTee;": '\U000022A5', + "UpTeeArrow;": '\U000021A5', + "Uparrow;": '\U000021D1', + "Updownarrow;": '\U000021D5', + "UpperLeftArrow;": '\U00002196', + "UpperRightArrow;": '\U00002197', + "Upsi;": '\U000003D2', + "Upsilon;": '\U000003A5', + "Uring;": '\U0000016E', + "Uscr;": '\U0001D4B0', + "Utilde;": '\U00000168', + "Uuml;": '\U000000DC', + "VDash;": '\U000022AB', + "Vbar;": '\U00002AEB', + "Vcy;": '\U00000412', + "Vdash;": '\U000022A9', + "Vdashl;": '\U00002AE6', + "Vee;": '\U000022C1', + "Verbar;": '\U00002016', + "Vert;": '\U00002016', + "VerticalBar;": '\U00002223', + "VerticalLine;": '\U0000007C', + "VerticalSeparator;": '\U00002758', + "VerticalTilde;": '\U00002240', + "VeryThinSpace;": '\U0000200A', + "Vfr;": '\U0001D519', + "Vopf;": '\U0001D54D', + "Vscr;": '\U0001D4B1', + "Vvdash;": '\U000022AA', + "Wcirc;": '\U00000174', + "Wedge;": '\U000022C0', + "Wfr;": '\U0001D51A', + "Wopf;": '\U0001D54E', + "Wscr;": '\U0001D4B2', + "Xfr;": '\U0001D51B', + "Xi;": '\U0000039E', + "Xopf;": '\U0001D54F', + "Xscr;": '\U0001D4B3', + "YAcy;": '\U0000042F', + "YIcy;": '\U00000407', + "YUcy;": '\U0000042E', + "Yacute;": '\U000000DD', + "Ycirc;": '\U00000176', + "Ycy;": '\U0000042B', + "Yfr;": '\U0001D51C', + "Yopf;": '\U0001D550', + "Yscr;": '\U0001D4B4', + "Yuml;": '\U00000178', + "ZHcy;": '\U00000416', + "Zacute;": '\U00000179', + "Zcaron;": '\U0000017D', + "Zcy;": '\U00000417', + "Zdot;": '\U0000017B', + "ZeroWidthSpace;": '\U0000200B', + "Zeta;": '\U00000396', + "Zfr;": '\U00002128', + "Zopf;": '\U00002124', + "Zscr;": '\U0001D4B5', + "aacute;": '\U000000E1', + "abreve;": '\U00000103', + "ac;": '\U0000223E', + "acd;": '\U0000223F', + "acirc;": '\U000000E2', + "acute;": '\U000000B4', + "acy;": '\U00000430', + "aelig;": '\U000000E6', + "af;": '\U00002061', + "afr;": '\U0001D51E', + "agrave;": '\U000000E0', + "alefsym;": '\U00002135', + "aleph;": '\U00002135', + "alpha;": '\U000003B1', + "amacr;": '\U00000101', + "amalg;": '\U00002A3F', + "amp;": '\U00000026', + "and;": '\U00002227', + "andand;": '\U00002A55', + "andd;": '\U00002A5C', + "andslope;": '\U00002A58', + "andv;": '\U00002A5A', + "ang;": '\U00002220', + "ange;": '\U000029A4', + "angle;": '\U00002220', + "angmsd;": '\U00002221', + "angmsdaa;": '\U000029A8', + "angmsdab;": '\U000029A9', + "angmsdac;": '\U000029AA', + "angmsdad;": '\U000029AB', + "angmsdae;": '\U000029AC', + "angmsdaf;": '\U000029AD', + "angmsdag;": '\U000029AE', + "angmsdah;": '\U000029AF', + "angrt;": '\U0000221F', + "angrtvb;": '\U000022BE', + "angrtvbd;": '\U0000299D', + "angsph;": '\U00002222', + "angst;": '\U000000C5', + "angzarr;": '\U0000237C', + "aogon;": '\U00000105', + "aopf;": '\U0001D552', + "ap;": '\U00002248', + "apE;": '\U00002A70', + "apacir;": '\U00002A6F', + "ape;": '\U0000224A', + "apid;": '\U0000224B', + "apos;": '\U00000027', + "approx;": '\U00002248', + "approxeq;": '\U0000224A', + "aring;": '\U000000E5', + "ascr;": '\U0001D4B6', + "ast;": '\U0000002A', + "asymp;": '\U00002248', + "asympeq;": '\U0000224D', + "atilde;": '\U000000E3', + "auml;": '\U000000E4', + "awconint;": '\U00002233', + "awint;": '\U00002A11', + "bNot;": '\U00002AED', + "backcong;": '\U0000224C', + "backepsilon;": '\U000003F6', + "backprime;": '\U00002035', + "backsim;": '\U0000223D', + "backsimeq;": '\U000022CD', + "barvee;": '\U000022BD', + "barwed;": '\U00002305', + "barwedge;": '\U00002305', + "bbrk;": '\U000023B5', + "bbrktbrk;": '\U000023B6', + "bcong;": '\U0000224C', + "bcy;": '\U00000431', + "bdquo;": '\U0000201E', + "becaus;": '\U00002235', + "because;": '\U00002235', + "bemptyv;": '\U000029B0', + "bepsi;": '\U000003F6', + "bernou;": '\U0000212C', + "beta;": '\U000003B2', + "beth;": '\U00002136', + "between;": '\U0000226C', + "bfr;": '\U0001D51F', + "bigcap;": '\U000022C2', + "bigcirc;": '\U000025EF', + "bigcup;": '\U000022C3', + "bigodot;": '\U00002A00', + "bigoplus;": '\U00002A01', + "bigotimes;": '\U00002A02', + "bigsqcup;": '\U00002A06', + "bigstar;": '\U00002605', + "bigtriangledown;": '\U000025BD', + "bigtriangleup;": '\U000025B3', + "biguplus;": '\U00002A04', + "bigvee;": '\U000022C1', + "bigwedge;": '\U000022C0', + "bkarow;": '\U0000290D', + "blacklozenge;": '\U000029EB', + "blacksquare;": '\U000025AA', + "blacktriangle;": '\U000025B4', + "blacktriangledown;": '\U000025BE', + "blacktriangleleft;": '\U000025C2', + "blacktriangleright;": '\U000025B8', + "blank;": '\U00002423', + "blk12;": '\U00002592', + "blk14;": '\U00002591', + "blk34;": '\U00002593', + "block;": '\U00002588', + "bnot;": '\U00002310', + "bopf;": '\U0001D553', + "bot;": '\U000022A5', + "bottom;": '\U000022A5', + "bowtie;": '\U000022C8', + "boxDL;": '\U00002557', + "boxDR;": '\U00002554', + "boxDl;": '\U00002556', + "boxDr;": '\U00002553', + "boxH;": '\U00002550', + "boxHD;": '\U00002566', + "boxHU;": '\U00002569', + "boxHd;": '\U00002564', + "boxHu;": '\U00002567', + "boxUL;": '\U0000255D', + "boxUR;": '\U0000255A', + "boxUl;": '\U0000255C', + "boxUr;": '\U00002559', + "boxV;": '\U00002551', + "boxVH;": '\U0000256C', + "boxVL;": '\U00002563', + "boxVR;": '\U00002560', + "boxVh;": '\U0000256B', + "boxVl;": '\U00002562', + "boxVr;": '\U0000255F', + "boxbox;": '\U000029C9', + "boxdL;": '\U00002555', + "boxdR;": '\U00002552', + "boxdl;": '\U00002510', + "boxdr;": '\U0000250C', + "boxh;": '\U00002500', + "boxhD;": '\U00002565', + "boxhU;": '\U00002568', + "boxhd;": '\U0000252C', + "boxhu;": '\U00002534', + "boxminus;": '\U0000229F', + "boxplus;": '\U0000229E', + "boxtimes;": '\U000022A0', + "boxuL;": '\U0000255B', + "boxuR;": '\U00002558', + "boxul;": '\U00002518', + "boxur;": '\U00002514', + "boxv;": '\U00002502', + "boxvH;": '\U0000256A', + "boxvL;": '\U00002561', + "boxvR;": '\U0000255E', + "boxvh;": '\U0000253C', + "boxvl;": '\U00002524', + "boxvr;": '\U0000251C', + "bprime;": '\U00002035', + "breve;": '\U000002D8', + "brvbar;": '\U000000A6', + "bscr;": '\U0001D4B7', + "bsemi;": '\U0000204F', + "bsim;": '\U0000223D', + "bsime;": '\U000022CD', + "bsol;": '\U0000005C', + "bsolb;": '\U000029C5', + "bsolhsub;": '\U000027C8', + "bull;": '\U00002022', + "bullet;": '\U00002022', + "bump;": '\U0000224E', + "bumpE;": '\U00002AAE', + "bumpe;": '\U0000224F', + "bumpeq;": '\U0000224F', + "cacute;": '\U00000107', + "cap;": '\U00002229', + "capand;": '\U00002A44', + "capbrcup;": '\U00002A49', + "capcap;": '\U00002A4B', + "capcup;": '\U00002A47', + "capdot;": '\U00002A40', + "caret;": '\U00002041', + "caron;": '\U000002C7', + "ccaps;": '\U00002A4D', + "ccaron;": '\U0000010D', + "ccedil;": '\U000000E7', + "ccirc;": '\U00000109', + "ccups;": '\U00002A4C', + "ccupssm;": '\U00002A50', + "cdot;": '\U0000010B', + "cedil;": '\U000000B8', + "cemptyv;": '\U000029B2', + "cent;": '\U000000A2', + "centerdot;": '\U000000B7', + "cfr;": '\U0001D520', + "chcy;": '\U00000447', + "check;": '\U00002713', + "checkmark;": '\U00002713', + "chi;": '\U000003C7', + "cir;": '\U000025CB', + "cirE;": '\U000029C3', + "circ;": '\U000002C6', + "circeq;": '\U00002257', + "circlearrowleft;": '\U000021BA', + "circlearrowright;": '\U000021BB', + "circledR;": '\U000000AE', + "circledS;": '\U000024C8', + "circledast;": '\U0000229B', + "circledcirc;": '\U0000229A', + "circleddash;": '\U0000229D', + "cire;": '\U00002257', + "cirfnint;": '\U00002A10', + "cirmid;": '\U00002AEF', + "cirscir;": '\U000029C2', + "clubs;": '\U00002663', + "clubsuit;": '\U00002663', + "colon;": '\U0000003A', + "colone;": '\U00002254', + "coloneq;": '\U00002254', + "comma;": '\U0000002C', + "commat;": '\U00000040', + "comp;": '\U00002201', + "compfn;": '\U00002218', + "complement;": '\U00002201', + "complexes;": '\U00002102', + "cong;": '\U00002245', + "congdot;": '\U00002A6D', + "conint;": '\U0000222E', + "copf;": '\U0001D554', + "coprod;": '\U00002210', + "copy;": '\U000000A9', + "copysr;": '\U00002117', + "crarr;": '\U000021B5', + "cross;": '\U00002717', + "cscr;": '\U0001D4B8', + "csub;": '\U00002ACF', + "csube;": '\U00002AD1', + "csup;": '\U00002AD0', + "csupe;": '\U00002AD2', + "ctdot;": '\U000022EF', + "cudarrl;": '\U00002938', + "cudarrr;": '\U00002935', + "cuepr;": '\U000022DE', + "cuesc;": '\U000022DF', + "cularr;": '\U000021B6', + "cularrp;": '\U0000293D', + "cup;": '\U0000222A', + "cupbrcap;": '\U00002A48', + "cupcap;": '\U00002A46', + "cupcup;": '\U00002A4A', + "cupdot;": '\U0000228D', + "cupor;": '\U00002A45', + "curarr;": '\U000021B7', + "curarrm;": '\U0000293C', + "curlyeqprec;": '\U000022DE', + "curlyeqsucc;": '\U000022DF', + "curlyvee;": '\U000022CE', + "curlywedge;": '\U000022CF', + "curren;": '\U000000A4', + "curvearrowleft;": '\U000021B6', + "curvearrowright;": '\U000021B7', + "cuvee;": '\U000022CE', + "cuwed;": '\U000022CF', + "cwconint;": '\U00002232', + "cwint;": '\U00002231', + "cylcty;": '\U0000232D', + "dArr;": '\U000021D3', + "dHar;": '\U00002965', + "dagger;": '\U00002020', + "daleth;": '\U00002138', + "darr;": '\U00002193', + "dash;": '\U00002010', + "dashv;": '\U000022A3', + "dbkarow;": '\U0000290F', + "dblac;": '\U000002DD', + "dcaron;": '\U0000010F', + "dcy;": '\U00000434', + "dd;": '\U00002146', + "ddagger;": '\U00002021', + "ddarr;": '\U000021CA', + "ddotseq;": '\U00002A77', + "deg;": '\U000000B0', + "delta;": '\U000003B4', + "demptyv;": '\U000029B1', + "dfisht;": '\U0000297F', + "dfr;": '\U0001D521', + "dharl;": '\U000021C3', + "dharr;": '\U000021C2', + "diam;": '\U000022C4', + "diamond;": '\U000022C4', + "diamondsuit;": '\U00002666', + "diams;": '\U00002666', + "die;": '\U000000A8', + "digamma;": '\U000003DD', + "disin;": '\U000022F2', + "div;": '\U000000F7', + "divide;": '\U000000F7', + "divideontimes;": '\U000022C7', + "divonx;": '\U000022C7', + "djcy;": '\U00000452', + "dlcorn;": '\U0000231E', + "dlcrop;": '\U0000230D', + "dollar;": '\U00000024', + "dopf;": '\U0001D555', + "dot;": '\U000002D9', + "doteq;": '\U00002250', + "doteqdot;": '\U00002251', + "dotminus;": '\U00002238', + "dotplus;": '\U00002214', + "dotsquare;": '\U000022A1', + "doublebarwedge;": '\U00002306', + "downarrow;": '\U00002193', + "downdownarrows;": '\U000021CA', + "downharpoonleft;": '\U000021C3', + "downharpoonright;": '\U000021C2', + "drbkarow;": '\U00002910', + "drcorn;": '\U0000231F', + "drcrop;": '\U0000230C', + "dscr;": '\U0001D4B9', + "dscy;": '\U00000455', + "dsol;": '\U000029F6', + "dstrok;": '\U00000111', + "dtdot;": '\U000022F1', + "dtri;": '\U000025BF', + "dtrif;": '\U000025BE', + "duarr;": '\U000021F5', + "duhar;": '\U0000296F', + "dwangle;": '\U000029A6', + "dzcy;": '\U0000045F', + "dzigrarr;": '\U000027FF', + "eDDot;": '\U00002A77', + "eDot;": '\U00002251', + "eacute;": '\U000000E9', + "easter;": '\U00002A6E', + "ecaron;": '\U0000011B', + "ecir;": '\U00002256', + "ecirc;": '\U000000EA', + "ecolon;": '\U00002255', + "ecy;": '\U0000044D', + "edot;": '\U00000117', + "ee;": '\U00002147', + "efDot;": '\U00002252', + "efr;": '\U0001D522', + "eg;": '\U00002A9A', + "egrave;": '\U000000E8', + "egs;": '\U00002A96', + "egsdot;": '\U00002A98', + "el;": '\U00002A99', + "elinters;": '\U000023E7', + "ell;": '\U00002113', + "els;": '\U00002A95', + "elsdot;": '\U00002A97', + "emacr;": '\U00000113', + "empty;": '\U00002205', + "emptyset;": '\U00002205', + "emptyv;": '\U00002205', + "emsp;": '\U00002003', + "emsp13;": '\U00002004', + "emsp14;": '\U00002005', + "eng;": '\U0000014B', + "ensp;": '\U00002002', + "eogon;": '\U00000119', + "eopf;": '\U0001D556', + "epar;": '\U000022D5', + "eparsl;": '\U000029E3', + "eplus;": '\U00002A71', + "epsi;": '\U000003B5', + "epsilon;": '\U000003B5', + "epsiv;": '\U000003F5', + "eqcirc;": '\U00002256', + "eqcolon;": '\U00002255', + "eqsim;": '\U00002242', + "eqslantgtr;": '\U00002A96', + "eqslantless;": '\U00002A95', + "equals;": '\U0000003D', + "equest;": '\U0000225F', + "equiv;": '\U00002261', + "equivDD;": '\U00002A78', + "eqvparsl;": '\U000029E5', + "erDot;": '\U00002253', + "erarr;": '\U00002971', + "escr;": '\U0000212F', + "esdot;": '\U00002250', + "esim;": '\U00002242', + "eta;": '\U000003B7', + "eth;": '\U000000F0', + "euml;": '\U000000EB', + "euro;": '\U000020AC', + "excl;": '\U00000021', + "exist;": '\U00002203', + "expectation;": '\U00002130', + "exponentiale;": '\U00002147', + "fallingdotseq;": '\U00002252', + "fcy;": '\U00000444', + "female;": '\U00002640', + "ffilig;": '\U0000FB03', + "fflig;": '\U0000FB00', + "ffllig;": '\U0000FB04', + "ffr;": '\U0001D523', + "filig;": '\U0000FB01', + "flat;": '\U0000266D', + "fllig;": '\U0000FB02', + "fltns;": '\U000025B1', + "fnof;": '\U00000192', + "fopf;": '\U0001D557', + "forall;": '\U00002200', + "fork;": '\U000022D4', + "forkv;": '\U00002AD9', + "fpartint;": '\U00002A0D', + "frac12;": '\U000000BD', + "frac13;": '\U00002153', + "frac14;": '\U000000BC', + "frac15;": '\U00002155', + "frac16;": '\U00002159', + "frac18;": '\U0000215B', + "frac23;": '\U00002154', + "frac25;": '\U00002156', + "frac34;": '\U000000BE', + "frac35;": '\U00002157', + "frac38;": '\U0000215C', + "frac45;": '\U00002158', + "frac56;": '\U0000215A', + "frac58;": '\U0000215D', + "frac78;": '\U0000215E', + "frasl;": '\U00002044', + "frown;": '\U00002322', + "fscr;": '\U0001D4BB', + "gE;": '\U00002267', + "gEl;": '\U00002A8C', + "gacute;": '\U000001F5', + "gamma;": '\U000003B3', + "gammad;": '\U000003DD', + "gap;": '\U00002A86', + "gbreve;": '\U0000011F', + "gcirc;": '\U0000011D', + "gcy;": '\U00000433', + "gdot;": '\U00000121', + "ge;": '\U00002265', + "gel;": '\U000022DB', + "geq;": '\U00002265', + "geqq;": '\U00002267', + "geqslant;": '\U00002A7E', + "ges;": '\U00002A7E', + "gescc;": '\U00002AA9', + "gesdot;": '\U00002A80', + "gesdoto;": '\U00002A82', + "gesdotol;": '\U00002A84', + "gesles;": '\U00002A94', + "gfr;": '\U0001D524', + "gg;": '\U0000226B', + "ggg;": '\U000022D9', + "gimel;": '\U00002137', + "gjcy;": '\U00000453', + "gl;": '\U00002277', + "glE;": '\U00002A92', + "gla;": '\U00002AA5', + "glj;": '\U00002AA4', + "gnE;": '\U00002269', + "gnap;": '\U00002A8A', + "gnapprox;": '\U00002A8A', + "gne;": '\U00002A88', + "gneq;": '\U00002A88', + "gneqq;": '\U00002269', + "gnsim;": '\U000022E7', + "gopf;": '\U0001D558', + "grave;": '\U00000060', + "gscr;": '\U0000210A', + "gsim;": '\U00002273', + "gsime;": '\U00002A8E', + "gsiml;": '\U00002A90', + "gt;": '\U0000003E', + "gtcc;": '\U00002AA7', + "gtcir;": '\U00002A7A', + "gtdot;": '\U000022D7', + "gtlPar;": '\U00002995', + "gtquest;": '\U00002A7C', + "gtrapprox;": '\U00002A86', + "gtrarr;": '\U00002978', + "gtrdot;": '\U000022D7', + "gtreqless;": '\U000022DB', + "gtreqqless;": '\U00002A8C', + "gtrless;": '\U00002277', + "gtrsim;": '\U00002273', + "hArr;": '\U000021D4', + "hairsp;": '\U0000200A', + "half;": '\U000000BD', + "hamilt;": '\U0000210B', + "hardcy;": '\U0000044A', + "harr;": '\U00002194', + "harrcir;": '\U00002948', + "harrw;": '\U000021AD', + "hbar;": '\U0000210F', + "hcirc;": '\U00000125', + "hearts;": '\U00002665', + "heartsuit;": '\U00002665', + "hellip;": '\U00002026', + "hercon;": '\U000022B9', + "hfr;": '\U0001D525', + "hksearow;": '\U00002925', + "hkswarow;": '\U00002926', + "hoarr;": '\U000021FF', + "homtht;": '\U0000223B', + "hookleftarrow;": '\U000021A9', + "hookrightarrow;": '\U000021AA', + "hopf;": '\U0001D559', + "horbar;": '\U00002015', + "hscr;": '\U0001D4BD', + "hslash;": '\U0000210F', + "hstrok;": '\U00000127', + "hybull;": '\U00002043', + "hyphen;": '\U00002010', + "iacute;": '\U000000ED', + "ic;": '\U00002063', + "icirc;": '\U000000EE', + "icy;": '\U00000438', + "iecy;": '\U00000435', + "iexcl;": '\U000000A1', + "iff;": '\U000021D4', + "ifr;": '\U0001D526', + "igrave;": '\U000000EC', + "ii;": '\U00002148', + "iiiint;": '\U00002A0C', + "iiint;": '\U0000222D', + "iinfin;": '\U000029DC', + "iiota;": '\U00002129', + "ijlig;": '\U00000133', + "imacr;": '\U0000012B', + "image;": '\U00002111', + "imagline;": '\U00002110', + "imagpart;": '\U00002111', + "imath;": '\U00000131', + "imof;": '\U000022B7', + "imped;": '\U000001B5', + "in;": '\U00002208', + "incare;": '\U00002105', + "infin;": '\U0000221E', + "infintie;": '\U000029DD', + "inodot;": '\U00000131', + "int;": '\U0000222B', + "intcal;": '\U000022BA', + "integers;": '\U00002124', + "intercal;": '\U000022BA', + "intlarhk;": '\U00002A17', + "intprod;": '\U00002A3C', + "iocy;": '\U00000451', + "iogon;": '\U0000012F', + "iopf;": '\U0001D55A', + "iota;": '\U000003B9', + "iprod;": '\U00002A3C', + "iquest;": '\U000000BF', + "iscr;": '\U0001D4BE', + "isin;": '\U00002208', + "isinE;": '\U000022F9', + "isindot;": '\U000022F5', + "isins;": '\U000022F4', + "isinsv;": '\U000022F3', + "isinv;": '\U00002208', + "it;": '\U00002062', + "itilde;": '\U00000129', + "iukcy;": '\U00000456', + "iuml;": '\U000000EF', + "jcirc;": '\U00000135', + "jcy;": '\U00000439', + "jfr;": '\U0001D527', + "jmath;": '\U00000237', + "jopf;": '\U0001D55B', + "jscr;": '\U0001D4BF', + "jsercy;": '\U00000458', + "jukcy;": '\U00000454', + "kappa;": '\U000003BA', + "kappav;": '\U000003F0', + "kcedil;": '\U00000137', + "kcy;": '\U0000043A', + "kfr;": '\U0001D528', + "kgreen;": '\U00000138', + "khcy;": '\U00000445', + "kjcy;": '\U0000045C', + "kopf;": '\U0001D55C', + "kscr;": '\U0001D4C0', + "lAarr;": '\U000021DA', + "lArr;": '\U000021D0', + "lAtail;": '\U0000291B', + "lBarr;": '\U0000290E', + "lE;": '\U00002266', + "lEg;": '\U00002A8B', + "lHar;": '\U00002962', + "lacute;": '\U0000013A', + "laemptyv;": '\U000029B4', + "lagran;": '\U00002112', + "lambda;": '\U000003BB', + "lang;": '\U000027E8', + "langd;": '\U00002991', + "langle;": '\U000027E8', + "lap;": '\U00002A85', + "laquo;": '\U000000AB', + "larr;": '\U00002190', + "larrb;": '\U000021E4', + "larrbfs;": '\U0000291F', + "larrfs;": '\U0000291D', + "larrhk;": '\U000021A9', + "larrlp;": '\U000021AB', + "larrpl;": '\U00002939', + "larrsim;": '\U00002973', + "larrtl;": '\U000021A2', + "lat;": '\U00002AAB', + "latail;": '\U00002919', + "late;": '\U00002AAD', + "lbarr;": '\U0000290C', + "lbbrk;": '\U00002772', + "lbrace;": '\U0000007B', + "lbrack;": '\U0000005B', + "lbrke;": '\U0000298B', + "lbrksld;": '\U0000298F', + "lbrkslu;": '\U0000298D', + "lcaron;": '\U0000013E', + "lcedil;": '\U0000013C', + "lceil;": '\U00002308', + "lcub;": '\U0000007B', + "lcy;": '\U0000043B', + "ldca;": '\U00002936', + "ldquo;": '\U0000201C', + "ldquor;": '\U0000201E', + "ldrdhar;": '\U00002967', + "ldrushar;": '\U0000294B', + "ldsh;": '\U000021B2', + "le;": '\U00002264', + "leftarrow;": '\U00002190', + "leftarrowtail;": '\U000021A2', + "leftharpoondown;": '\U000021BD', + "leftharpoonup;": '\U000021BC', + "leftleftarrows;": '\U000021C7', + "leftrightarrow;": '\U00002194', + "leftrightarrows;": '\U000021C6', + "leftrightharpoons;": '\U000021CB', + "leftrightsquigarrow;": '\U000021AD', + "leftthreetimes;": '\U000022CB', + "leg;": '\U000022DA', + "leq;": '\U00002264', + "leqq;": '\U00002266', + "leqslant;": '\U00002A7D', + "les;": '\U00002A7D', + "lescc;": '\U00002AA8', + "lesdot;": '\U00002A7F', + "lesdoto;": '\U00002A81', + "lesdotor;": '\U00002A83', + "lesges;": '\U00002A93', + "lessapprox;": '\U00002A85', + "lessdot;": '\U000022D6', + "lesseqgtr;": '\U000022DA', + "lesseqqgtr;": '\U00002A8B', + "lessgtr;": '\U00002276', + "lesssim;": '\U00002272', + "lfisht;": '\U0000297C', + "lfloor;": '\U0000230A', + "lfr;": '\U0001D529', + "lg;": '\U00002276', + "lgE;": '\U00002A91', + "lhard;": '\U000021BD', + "lharu;": '\U000021BC', + "lharul;": '\U0000296A', + "lhblk;": '\U00002584', + "ljcy;": '\U00000459', + "ll;": '\U0000226A', + "llarr;": '\U000021C7', + "llcorner;": '\U0000231E', + "llhard;": '\U0000296B', + "lltri;": '\U000025FA', + "lmidot;": '\U00000140', + "lmoust;": '\U000023B0', + "lmoustache;": '\U000023B0', + "lnE;": '\U00002268', + "lnap;": '\U00002A89', + "lnapprox;": '\U00002A89', + "lne;": '\U00002A87', + "lneq;": '\U00002A87', + "lneqq;": '\U00002268', + "lnsim;": '\U000022E6', + "loang;": '\U000027EC', + "loarr;": '\U000021FD', + "lobrk;": '\U000027E6', + "longleftarrow;": '\U000027F5', + "longleftrightarrow;": '\U000027F7', + "longmapsto;": '\U000027FC', + "longrightarrow;": '\U000027F6', + "looparrowleft;": '\U000021AB', + "looparrowright;": '\U000021AC', + "lopar;": '\U00002985', + "lopf;": '\U0001D55D', + "loplus;": '\U00002A2D', + "lotimes;": '\U00002A34', + "lowast;": '\U00002217', + "lowbar;": '\U0000005F', + "loz;": '\U000025CA', + "lozenge;": '\U000025CA', + "lozf;": '\U000029EB', + "lpar;": '\U00000028', + "lparlt;": '\U00002993', + "lrarr;": '\U000021C6', + "lrcorner;": '\U0000231F', + "lrhar;": '\U000021CB', + "lrhard;": '\U0000296D', + "lrm;": '\U0000200E', + "lrtri;": '\U000022BF', + "lsaquo;": '\U00002039', + "lscr;": '\U0001D4C1', + "lsh;": '\U000021B0', + "lsim;": '\U00002272', + "lsime;": '\U00002A8D', + "lsimg;": '\U00002A8F', + "lsqb;": '\U0000005B', + "lsquo;": '\U00002018', + "lsquor;": '\U0000201A', + "lstrok;": '\U00000142', + "lt;": '\U0000003C', + "ltcc;": '\U00002AA6', + "ltcir;": '\U00002A79', + "ltdot;": '\U000022D6', + "lthree;": '\U000022CB', + "ltimes;": '\U000022C9', + "ltlarr;": '\U00002976', + "ltquest;": '\U00002A7B', + "ltrPar;": '\U00002996', + "ltri;": '\U000025C3', + "ltrie;": '\U000022B4', + "ltrif;": '\U000025C2', + "lurdshar;": '\U0000294A', + "luruhar;": '\U00002966', + "mDDot;": '\U0000223A', + "macr;": '\U000000AF', + "male;": '\U00002642', + "malt;": '\U00002720', + "maltese;": '\U00002720', + "map;": '\U000021A6', + "mapsto;": '\U000021A6', + "mapstodown;": '\U000021A7', + "mapstoleft;": '\U000021A4', + "mapstoup;": '\U000021A5', + "marker;": '\U000025AE', + "mcomma;": '\U00002A29', + "mcy;": '\U0000043C', + "mdash;": '\U00002014', + "measuredangle;": '\U00002221', + "mfr;": '\U0001D52A', + "mho;": '\U00002127', + "micro;": '\U000000B5', + "mid;": '\U00002223', + "midast;": '\U0000002A', + "midcir;": '\U00002AF0', + "middot;": '\U000000B7', + "minus;": '\U00002212', + "minusb;": '\U0000229F', + "minusd;": '\U00002238', + "minusdu;": '\U00002A2A', + "mlcp;": '\U00002ADB', + "mldr;": '\U00002026', + "mnplus;": '\U00002213', + "models;": '\U000022A7', + "mopf;": '\U0001D55E', + "mp;": '\U00002213', + "mscr;": '\U0001D4C2', + "mstpos;": '\U0000223E', + "mu;": '\U000003BC', + "multimap;": '\U000022B8', + "mumap;": '\U000022B8', + "nLeftarrow;": '\U000021CD', + "nLeftrightarrow;": '\U000021CE', + "nRightarrow;": '\U000021CF', + "nVDash;": '\U000022AF', + "nVdash;": '\U000022AE', + "nabla;": '\U00002207', + "nacute;": '\U00000144', + "nap;": '\U00002249', + "napos;": '\U00000149', + "napprox;": '\U00002249', + "natur;": '\U0000266E', + "natural;": '\U0000266E', + "naturals;": '\U00002115', + "nbsp;": '\U000000A0', + "ncap;": '\U00002A43', + "ncaron;": '\U00000148', + "ncedil;": '\U00000146', + "ncong;": '\U00002247', + "ncup;": '\U00002A42', + "ncy;": '\U0000043D', + "ndash;": '\U00002013', + "ne;": '\U00002260', + "neArr;": '\U000021D7', + "nearhk;": '\U00002924', + "nearr;": '\U00002197', + "nearrow;": '\U00002197', + "nequiv;": '\U00002262', + "nesear;": '\U00002928', + "nexist;": '\U00002204', + "nexists;": '\U00002204', + "nfr;": '\U0001D52B', + "nge;": '\U00002271', + "ngeq;": '\U00002271', + "ngsim;": '\U00002275', + "ngt;": '\U0000226F', + "ngtr;": '\U0000226F', + "nhArr;": '\U000021CE', + "nharr;": '\U000021AE', + "nhpar;": '\U00002AF2', + "ni;": '\U0000220B', + "nis;": '\U000022FC', + "nisd;": '\U000022FA', + "niv;": '\U0000220B', + "njcy;": '\U0000045A', + "nlArr;": '\U000021CD', + "nlarr;": '\U0000219A', + "nldr;": '\U00002025', + "nle;": '\U00002270', + "nleftarrow;": '\U0000219A', + "nleftrightarrow;": '\U000021AE', + "nleq;": '\U00002270', + "nless;": '\U0000226E', + "nlsim;": '\U00002274', + "nlt;": '\U0000226E', + "nltri;": '\U000022EA', + "nltrie;": '\U000022EC', + "nmid;": '\U00002224', + "nopf;": '\U0001D55F', + "not;": '\U000000AC', + "notin;": '\U00002209', + "notinva;": '\U00002209', + "notinvb;": '\U000022F7', + "notinvc;": '\U000022F6', + "notni;": '\U0000220C', + "notniva;": '\U0000220C', + "notnivb;": '\U000022FE', + "notnivc;": '\U000022FD', + "npar;": '\U00002226', + "nparallel;": '\U00002226', + "npolint;": '\U00002A14', + "npr;": '\U00002280', + "nprcue;": '\U000022E0', + "nprec;": '\U00002280', + "nrArr;": '\U000021CF', + "nrarr;": '\U0000219B', + "nrightarrow;": '\U0000219B', + "nrtri;": '\U000022EB', + "nrtrie;": '\U000022ED', + "nsc;": '\U00002281', + "nsccue;": '\U000022E1', + "nscr;": '\U0001D4C3', + "nshortmid;": '\U00002224', + "nshortparallel;": '\U00002226', + "nsim;": '\U00002241', + "nsime;": '\U00002244', + "nsimeq;": '\U00002244', + "nsmid;": '\U00002224', + "nspar;": '\U00002226', + "nsqsube;": '\U000022E2', + "nsqsupe;": '\U000022E3', + "nsub;": '\U00002284', + "nsube;": '\U00002288', + "nsubseteq;": '\U00002288', + "nsucc;": '\U00002281', + "nsup;": '\U00002285', + "nsupe;": '\U00002289', + "nsupseteq;": '\U00002289', + "ntgl;": '\U00002279', + "ntilde;": '\U000000F1', + "ntlg;": '\U00002278', + "ntriangleleft;": '\U000022EA', + "ntrianglelefteq;": '\U000022EC', + "ntriangleright;": '\U000022EB', + "ntrianglerighteq;": '\U000022ED', + "nu;": '\U000003BD', + "num;": '\U00000023', + "numero;": '\U00002116', + "numsp;": '\U00002007', + "nvDash;": '\U000022AD', + "nvHarr;": '\U00002904', + "nvdash;": '\U000022AC', + "nvinfin;": '\U000029DE', + "nvlArr;": '\U00002902', + "nvrArr;": '\U00002903', + "nwArr;": '\U000021D6', + "nwarhk;": '\U00002923', + "nwarr;": '\U00002196', + "nwarrow;": '\U00002196', + "nwnear;": '\U00002927', + "oS;": '\U000024C8', + "oacute;": '\U000000F3', + "oast;": '\U0000229B', + "ocir;": '\U0000229A', + "ocirc;": '\U000000F4', + "ocy;": '\U0000043E', + "odash;": '\U0000229D', + "odblac;": '\U00000151', + "odiv;": '\U00002A38', + "odot;": '\U00002299', + "odsold;": '\U000029BC', + "oelig;": '\U00000153', + "ofcir;": '\U000029BF', + "ofr;": '\U0001D52C', + "ogon;": '\U000002DB', + "ograve;": '\U000000F2', + "ogt;": '\U000029C1', + "ohbar;": '\U000029B5', + "ohm;": '\U000003A9', + "oint;": '\U0000222E', + "olarr;": '\U000021BA', + "olcir;": '\U000029BE', + "olcross;": '\U000029BB', + "oline;": '\U0000203E', + "olt;": '\U000029C0', + "omacr;": '\U0000014D', + "omega;": '\U000003C9', + "omicron;": '\U000003BF', + "omid;": '\U000029B6', + "ominus;": '\U00002296', + "oopf;": '\U0001D560', + "opar;": '\U000029B7', + "operp;": '\U000029B9', + "oplus;": '\U00002295', + "or;": '\U00002228', + "orarr;": '\U000021BB', + "ord;": '\U00002A5D', + "order;": '\U00002134', + "orderof;": '\U00002134', + "ordf;": '\U000000AA', + "ordm;": '\U000000BA', + "origof;": '\U000022B6', + "oror;": '\U00002A56', + "orslope;": '\U00002A57', + "orv;": '\U00002A5B', + "oscr;": '\U00002134', + "oslash;": '\U000000F8', + "osol;": '\U00002298', + "otilde;": '\U000000F5', + "otimes;": '\U00002297', + "otimesas;": '\U00002A36', + "ouml;": '\U000000F6', + "ovbar;": '\U0000233D', + "par;": '\U00002225', + "para;": '\U000000B6', + "parallel;": '\U00002225', + "parsim;": '\U00002AF3', + "parsl;": '\U00002AFD', + "part;": '\U00002202', + "pcy;": '\U0000043F', + "percnt;": '\U00000025', + "period;": '\U0000002E', + "permil;": '\U00002030', + "perp;": '\U000022A5', + "pertenk;": '\U00002031', + "pfr;": '\U0001D52D', + "phi;": '\U000003C6', + "phiv;": '\U000003D5', + "phmmat;": '\U00002133', + "phone;": '\U0000260E', + "pi;": '\U000003C0', + "pitchfork;": '\U000022D4', + "piv;": '\U000003D6', + "planck;": '\U0000210F', + "planckh;": '\U0000210E', + "plankv;": '\U0000210F', + "plus;": '\U0000002B', + "plusacir;": '\U00002A23', + "plusb;": '\U0000229E', + "pluscir;": '\U00002A22', + "plusdo;": '\U00002214', + "plusdu;": '\U00002A25', + "pluse;": '\U00002A72', + "plusmn;": '\U000000B1', + "plussim;": '\U00002A26', + "plustwo;": '\U00002A27', + "pm;": '\U000000B1', + "pointint;": '\U00002A15', + "popf;": '\U0001D561', + "pound;": '\U000000A3', + "pr;": '\U0000227A', + "prE;": '\U00002AB3', + "prap;": '\U00002AB7', + "prcue;": '\U0000227C', + "pre;": '\U00002AAF', + "prec;": '\U0000227A', + "precapprox;": '\U00002AB7', + "preccurlyeq;": '\U0000227C', + "preceq;": '\U00002AAF', + "precnapprox;": '\U00002AB9', + "precneqq;": '\U00002AB5', + "precnsim;": '\U000022E8', + "precsim;": '\U0000227E', + "prime;": '\U00002032', + "primes;": '\U00002119', + "prnE;": '\U00002AB5', + "prnap;": '\U00002AB9', + "prnsim;": '\U000022E8', + "prod;": '\U0000220F', + "profalar;": '\U0000232E', + "profline;": '\U00002312', + "profsurf;": '\U00002313', + "prop;": '\U0000221D', + "propto;": '\U0000221D', + "prsim;": '\U0000227E', + "prurel;": '\U000022B0', + "pscr;": '\U0001D4C5', + "psi;": '\U000003C8', + "puncsp;": '\U00002008', + "qfr;": '\U0001D52E', + "qint;": '\U00002A0C', + "qopf;": '\U0001D562', + "qprime;": '\U00002057', + "qscr;": '\U0001D4C6', + "quaternions;": '\U0000210D', + "quatint;": '\U00002A16', + "quest;": '\U0000003F', + "questeq;": '\U0000225F', + "quot;": '\U00000022', + "rAarr;": '\U000021DB', + "rArr;": '\U000021D2', + "rAtail;": '\U0000291C', + "rBarr;": '\U0000290F', + "rHar;": '\U00002964', + "racute;": '\U00000155', + "radic;": '\U0000221A', + "raemptyv;": '\U000029B3', + "rang;": '\U000027E9', + "rangd;": '\U00002992', + "range;": '\U000029A5', + "rangle;": '\U000027E9', + "raquo;": '\U000000BB', + "rarr;": '\U00002192', + "rarrap;": '\U00002975', + "rarrb;": '\U000021E5', + "rarrbfs;": '\U00002920', + "rarrc;": '\U00002933', + "rarrfs;": '\U0000291E', + "rarrhk;": '\U000021AA', + "rarrlp;": '\U000021AC', + "rarrpl;": '\U00002945', + "rarrsim;": '\U00002974', + "rarrtl;": '\U000021A3', + "rarrw;": '\U0000219D', + "ratail;": '\U0000291A', + "ratio;": '\U00002236', + "rationals;": '\U0000211A', + "rbarr;": '\U0000290D', + "rbbrk;": '\U00002773', + "rbrace;": '\U0000007D', + "rbrack;": '\U0000005D', + "rbrke;": '\U0000298C', + "rbrksld;": '\U0000298E', + "rbrkslu;": '\U00002990', + "rcaron;": '\U00000159', + "rcedil;": '\U00000157', + "rceil;": '\U00002309', + "rcub;": '\U0000007D', + "rcy;": '\U00000440', + "rdca;": '\U00002937', + "rdldhar;": '\U00002969', + "rdquo;": '\U0000201D', + "rdquor;": '\U0000201D', + "rdsh;": '\U000021B3', + "real;": '\U0000211C', + "realine;": '\U0000211B', + "realpart;": '\U0000211C', + "reals;": '\U0000211D', + "rect;": '\U000025AD', + "reg;": '\U000000AE', + "rfisht;": '\U0000297D', + "rfloor;": '\U0000230B', + "rfr;": '\U0001D52F', + "rhard;": '\U000021C1', + "rharu;": '\U000021C0', + "rharul;": '\U0000296C', + "rho;": '\U000003C1', + "rhov;": '\U000003F1', + "rightarrow;": '\U00002192', + "rightarrowtail;": '\U000021A3', + "rightharpoondown;": '\U000021C1', + "rightharpoonup;": '\U000021C0', + "rightleftarrows;": '\U000021C4', + "rightleftharpoons;": '\U000021CC', + "rightrightarrows;": '\U000021C9', + "rightsquigarrow;": '\U0000219D', + "rightthreetimes;": '\U000022CC', + "ring;": '\U000002DA', + "risingdotseq;": '\U00002253', + "rlarr;": '\U000021C4', + "rlhar;": '\U000021CC', + "rlm;": '\U0000200F', + "rmoust;": '\U000023B1', + "rmoustache;": '\U000023B1', + "rnmid;": '\U00002AEE', + "roang;": '\U000027ED', + "roarr;": '\U000021FE', + "robrk;": '\U000027E7', + "ropar;": '\U00002986', + "ropf;": '\U0001D563', + "roplus;": '\U00002A2E', + "rotimes;": '\U00002A35', + "rpar;": '\U00000029', + "rpargt;": '\U00002994', + "rppolint;": '\U00002A12', + "rrarr;": '\U000021C9', + "rsaquo;": '\U0000203A', + "rscr;": '\U0001D4C7', + "rsh;": '\U000021B1', + "rsqb;": '\U0000005D', + "rsquo;": '\U00002019', + "rsquor;": '\U00002019', + "rthree;": '\U000022CC', + "rtimes;": '\U000022CA', + "rtri;": '\U000025B9', + "rtrie;": '\U000022B5', + "rtrif;": '\U000025B8', + "rtriltri;": '\U000029CE', + "ruluhar;": '\U00002968', + "rx;": '\U0000211E', + "sacute;": '\U0000015B', + "sbquo;": '\U0000201A', + "sc;": '\U0000227B', + "scE;": '\U00002AB4', + "scap;": '\U00002AB8', + "scaron;": '\U00000161', + "sccue;": '\U0000227D', + "sce;": '\U00002AB0', + "scedil;": '\U0000015F', + "scirc;": '\U0000015D', + "scnE;": '\U00002AB6', + "scnap;": '\U00002ABA', + "scnsim;": '\U000022E9', + "scpolint;": '\U00002A13', + "scsim;": '\U0000227F', + "scy;": '\U00000441', + "sdot;": '\U000022C5', + "sdotb;": '\U000022A1', + "sdote;": '\U00002A66', + "seArr;": '\U000021D8', + "searhk;": '\U00002925', + "searr;": '\U00002198', + "searrow;": '\U00002198', + "sect;": '\U000000A7', + "semi;": '\U0000003B', + "seswar;": '\U00002929', + "setminus;": '\U00002216', + "setmn;": '\U00002216', + "sext;": '\U00002736', + "sfr;": '\U0001D530', + "sfrown;": '\U00002322', + "sharp;": '\U0000266F', + "shchcy;": '\U00000449', + "shcy;": '\U00000448', + "shortmid;": '\U00002223', + "shortparallel;": '\U00002225', + "shy;": '\U000000AD', + "sigma;": '\U000003C3', + "sigmaf;": '\U000003C2', + "sigmav;": '\U000003C2', + "sim;": '\U0000223C', + "simdot;": '\U00002A6A', + "sime;": '\U00002243', + "simeq;": '\U00002243', + "simg;": '\U00002A9E', + "simgE;": '\U00002AA0', + "siml;": '\U00002A9D', + "simlE;": '\U00002A9F', + "simne;": '\U00002246', + "simplus;": '\U00002A24', + "simrarr;": '\U00002972', + "slarr;": '\U00002190', + "smallsetminus;": '\U00002216', + "smashp;": '\U00002A33', + "smeparsl;": '\U000029E4', + "smid;": '\U00002223', + "smile;": '\U00002323', + "smt;": '\U00002AAA', + "smte;": '\U00002AAC', + "softcy;": '\U0000044C', + "sol;": '\U0000002F', + "solb;": '\U000029C4', + "solbar;": '\U0000233F', + "sopf;": '\U0001D564', + "spades;": '\U00002660', + "spadesuit;": '\U00002660', + "spar;": '\U00002225', + "sqcap;": '\U00002293', + "sqcup;": '\U00002294', + "sqsub;": '\U0000228F', + "sqsube;": '\U00002291', + "sqsubset;": '\U0000228F', + "sqsubseteq;": '\U00002291', + "sqsup;": '\U00002290', + "sqsupe;": '\U00002292', + "sqsupset;": '\U00002290', + "sqsupseteq;": '\U00002292', + "squ;": '\U000025A1', + "square;": '\U000025A1', + "squarf;": '\U000025AA', + "squf;": '\U000025AA', + "srarr;": '\U00002192', + "sscr;": '\U0001D4C8', + "ssetmn;": '\U00002216', + "ssmile;": '\U00002323', + "sstarf;": '\U000022C6', + "star;": '\U00002606', + "starf;": '\U00002605', + "straightepsilon;": '\U000003F5', + "straightphi;": '\U000003D5', + "strns;": '\U000000AF', + "sub;": '\U00002282', + "subE;": '\U00002AC5', + "subdot;": '\U00002ABD', + "sube;": '\U00002286', + "subedot;": '\U00002AC3', + "submult;": '\U00002AC1', + "subnE;": '\U00002ACB', + "subne;": '\U0000228A', + "subplus;": '\U00002ABF', + "subrarr;": '\U00002979', + "subset;": '\U00002282', + "subseteq;": '\U00002286', + "subseteqq;": '\U00002AC5', + "subsetneq;": '\U0000228A', + "subsetneqq;": '\U00002ACB', + "subsim;": '\U00002AC7', + "subsub;": '\U00002AD5', + "subsup;": '\U00002AD3', + "succ;": '\U0000227B', + "succapprox;": '\U00002AB8', + "succcurlyeq;": '\U0000227D', + "succeq;": '\U00002AB0', + "succnapprox;": '\U00002ABA', + "succneqq;": '\U00002AB6', + "succnsim;": '\U000022E9', + "succsim;": '\U0000227F', + "sum;": '\U00002211', + "sung;": '\U0000266A', + "sup;": '\U00002283', + "sup1;": '\U000000B9', + "sup2;": '\U000000B2', + "sup3;": '\U000000B3', + "supE;": '\U00002AC6', + "supdot;": '\U00002ABE', + "supdsub;": '\U00002AD8', + "supe;": '\U00002287', + "supedot;": '\U00002AC4', + "suphsol;": '\U000027C9', + "suphsub;": '\U00002AD7', + "suplarr;": '\U0000297B', + "supmult;": '\U00002AC2', + "supnE;": '\U00002ACC', + "supne;": '\U0000228B', + "supplus;": '\U00002AC0', + "supset;": '\U00002283', + "supseteq;": '\U00002287', + "supseteqq;": '\U00002AC6', + "supsetneq;": '\U0000228B', + "supsetneqq;": '\U00002ACC', + "supsim;": '\U00002AC8', + "supsub;": '\U00002AD4', + "supsup;": '\U00002AD6', + "swArr;": '\U000021D9', + "swarhk;": '\U00002926', + "swarr;": '\U00002199', + "swarrow;": '\U00002199', + "swnwar;": '\U0000292A', + "szlig;": '\U000000DF', + "target;": '\U00002316', + "tau;": '\U000003C4', + "tbrk;": '\U000023B4', + "tcaron;": '\U00000165', + "tcedil;": '\U00000163', + "tcy;": '\U00000442', + "tdot;": '\U000020DB', + "telrec;": '\U00002315', + "tfr;": '\U0001D531', + "there4;": '\U00002234', + "therefore;": '\U00002234', + "theta;": '\U000003B8', + "thetasym;": '\U000003D1', + "thetav;": '\U000003D1', + "thickapprox;": '\U00002248', + "thicksim;": '\U0000223C', + "thinsp;": '\U00002009', + "thkap;": '\U00002248', + "thksim;": '\U0000223C', + "thorn;": '\U000000FE', + "tilde;": '\U000002DC', + "times;": '\U000000D7', + "timesb;": '\U000022A0', + "timesbar;": '\U00002A31', + "timesd;": '\U00002A30', + "tint;": '\U0000222D', + "toea;": '\U00002928', + "top;": '\U000022A4', + "topbot;": '\U00002336', + "topcir;": '\U00002AF1', + "topf;": '\U0001D565', + "topfork;": '\U00002ADA', + "tosa;": '\U00002929', + "tprime;": '\U00002034', + "trade;": '\U00002122', + "triangle;": '\U000025B5', + "triangledown;": '\U000025BF', + "triangleleft;": '\U000025C3', + "trianglelefteq;": '\U000022B4', + "triangleq;": '\U0000225C', + "triangleright;": '\U000025B9', + "trianglerighteq;": '\U000022B5', + "tridot;": '\U000025EC', + "trie;": '\U0000225C', + "triminus;": '\U00002A3A', + "triplus;": '\U00002A39', + "trisb;": '\U000029CD', + "tritime;": '\U00002A3B', + "trpezium;": '\U000023E2', + "tscr;": '\U0001D4C9', + "tscy;": '\U00000446', + "tshcy;": '\U0000045B', + "tstrok;": '\U00000167', + "twixt;": '\U0000226C', + "twoheadleftarrow;": '\U0000219E', + "twoheadrightarrow;": '\U000021A0', + "uArr;": '\U000021D1', + "uHar;": '\U00002963', + "uacute;": '\U000000FA', + "uarr;": '\U00002191', + "ubrcy;": '\U0000045E', + "ubreve;": '\U0000016D', + "ucirc;": '\U000000FB', + "ucy;": '\U00000443', + "udarr;": '\U000021C5', + "udblac;": '\U00000171', + "udhar;": '\U0000296E', + "ufisht;": '\U0000297E', + "ufr;": '\U0001D532', + "ugrave;": '\U000000F9', + "uharl;": '\U000021BF', + "uharr;": '\U000021BE', + "uhblk;": '\U00002580', + "ulcorn;": '\U0000231C', + "ulcorner;": '\U0000231C', + "ulcrop;": '\U0000230F', + "ultri;": '\U000025F8', + "umacr;": '\U0000016B', + "uml;": '\U000000A8', + "uogon;": '\U00000173', + "uopf;": '\U0001D566', + "uparrow;": '\U00002191', + "updownarrow;": '\U00002195', + "upharpoonleft;": '\U000021BF', + "upharpoonright;": '\U000021BE', + "uplus;": '\U0000228E', + "upsi;": '\U000003C5', + "upsih;": '\U000003D2', + "upsilon;": '\U000003C5', + "upuparrows;": '\U000021C8', + "urcorn;": '\U0000231D', + "urcorner;": '\U0000231D', + "urcrop;": '\U0000230E', + "uring;": '\U0000016F', + "urtri;": '\U000025F9', + "uscr;": '\U0001D4CA', + "utdot;": '\U000022F0', + "utilde;": '\U00000169', + "utri;": '\U000025B5', + "utrif;": '\U000025B4', + "uuarr;": '\U000021C8', + "uuml;": '\U000000FC', + "uwangle;": '\U000029A7', + "vArr;": '\U000021D5', + "vBar;": '\U00002AE8', + "vBarv;": '\U00002AE9', + "vDash;": '\U000022A8', + "vangrt;": '\U0000299C', + "varepsilon;": '\U000003F5', + "varkappa;": '\U000003F0', + "varnothing;": '\U00002205', + "varphi;": '\U000003D5', + "varpi;": '\U000003D6', + "varpropto;": '\U0000221D', + "varr;": '\U00002195', + "varrho;": '\U000003F1', + "varsigma;": '\U000003C2', + "vartheta;": '\U000003D1', + "vartriangleleft;": '\U000022B2', + "vartriangleright;": '\U000022B3', + "vcy;": '\U00000432', + "vdash;": '\U000022A2', + "vee;": '\U00002228', + "veebar;": '\U000022BB', + "veeeq;": '\U0000225A', + "vellip;": '\U000022EE', + "verbar;": '\U0000007C', + "vert;": '\U0000007C', + "vfr;": '\U0001D533', + "vltri;": '\U000022B2', + "vopf;": '\U0001D567', + "vprop;": '\U0000221D', + "vrtri;": '\U000022B3', + "vscr;": '\U0001D4CB', + "vzigzag;": '\U0000299A', + "wcirc;": '\U00000175', + "wedbar;": '\U00002A5F', + "wedge;": '\U00002227', + "wedgeq;": '\U00002259', + "weierp;": '\U00002118', + "wfr;": '\U0001D534', + "wopf;": '\U0001D568', + "wp;": '\U00002118', + "wr;": '\U00002240', + "wreath;": '\U00002240', + "wscr;": '\U0001D4CC', + "xcap;": '\U000022C2', + "xcirc;": '\U000025EF', + "xcup;": '\U000022C3', + "xdtri;": '\U000025BD', + "xfr;": '\U0001D535', + "xhArr;": '\U000027FA', + "xharr;": '\U000027F7', + "xi;": '\U000003BE', + "xlArr;": '\U000027F8', + "xlarr;": '\U000027F5', + "xmap;": '\U000027FC', + "xnis;": '\U000022FB', + "xodot;": '\U00002A00', + "xopf;": '\U0001D569', + "xoplus;": '\U00002A01', + "xotime;": '\U00002A02', + "xrArr;": '\U000027F9', + "xrarr;": '\U000027F6', + "xscr;": '\U0001D4CD', + "xsqcup;": '\U00002A06', + "xuplus;": '\U00002A04', + "xutri;": '\U000025B3', + "xvee;": '\U000022C1', + "xwedge;": '\U000022C0', + "yacute;": '\U000000FD', + "yacy;": '\U0000044F', + "ycirc;": '\U00000177', + "ycy;": '\U0000044B', + "yen;": '\U000000A5', + "yfr;": '\U0001D536', + "yicy;": '\U00000457', + "yopf;": '\U0001D56A', + "yscr;": '\U0001D4CE', + "yucy;": '\U0000044E', + "yuml;": '\U000000FF', + "zacute;": '\U0000017A', + "zcaron;": '\U0000017E', + "zcy;": '\U00000437', + "zdot;": '\U0000017C', + "zeetrf;": '\U00002128', + "zeta;": '\U000003B6', + "zfr;": '\U0001D537', + "zhcy;": '\U00000436', + "zigrarr;": '\U000021DD', + "zopf;": '\U0001D56B', + "zscr;": '\U0001D4CF', + "zwj;": '\U0000200D', + "zwnj;": '\U0000200C', + "AElig": '\U000000C6', + "AMP": '\U00000026', + "Aacute": '\U000000C1', + "Acirc": '\U000000C2', + "Agrave": '\U000000C0', + "Aring": '\U000000C5', + "Atilde": '\U000000C3', + "Auml": '\U000000C4', + "COPY": '\U000000A9', + "Ccedil": '\U000000C7', + "ETH": '\U000000D0', + "Eacute": '\U000000C9', + "Ecirc": '\U000000CA', + "Egrave": '\U000000C8', + "Euml": '\U000000CB', + "GT": '\U0000003E', + "Iacute": '\U000000CD', + "Icirc": '\U000000CE', + "Igrave": '\U000000CC', + "Iuml": '\U000000CF', + "LT": '\U0000003C', + "Ntilde": '\U000000D1', + "Oacute": '\U000000D3', + "Ocirc": '\U000000D4', + "Ograve": '\U000000D2', + "Oslash": '\U000000D8', + "Otilde": '\U000000D5', + "Ouml": '\U000000D6', + "QUOT": '\U00000022', + "REG": '\U000000AE', + "THORN": '\U000000DE', + "Uacute": '\U000000DA', + "Ucirc": '\U000000DB', + "Ugrave": '\U000000D9', + "Uuml": '\U000000DC', + "Yacute": '\U000000DD', + "aacute": '\U000000E1', + "acirc": '\U000000E2', + "acute": '\U000000B4', + "aelig": '\U000000E6', + "agrave": '\U000000E0', + "amp": '\U00000026', + "aring": '\U000000E5', + "atilde": '\U000000E3', + "auml": '\U000000E4', + "brvbar": '\U000000A6', + "ccedil": '\U000000E7', + "cedil": '\U000000B8', + "cent": '\U000000A2', + "copy": '\U000000A9', + "curren": '\U000000A4', + "deg": '\U000000B0', + "divide": '\U000000F7', + "eacute": '\U000000E9', + "ecirc": '\U000000EA', + "egrave": '\U000000E8', + "eth": '\U000000F0', + "euml": '\U000000EB', + "frac12": '\U000000BD', + "frac14": '\U000000BC', + "frac34": '\U000000BE', + "gt": '\U0000003E', + "iacute": '\U000000ED', + "icirc": '\U000000EE', + "iexcl": '\U000000A1', + "igrave": '\U000000EC', + "iquest": '\U000000BF', + "iuml": '\U000000EF', + "laquo": '\U000000AB', + "lt": '\U0000003C', + "macr": '\U000000AF', + "micro": '\U000000B5', + "middot": '\U000000B7', + "nbsp": '\U000000A0', + "not": '\U000000AC', + "ntilde": '\U000000F1', + "oacute": '\U000000F3', + "ocirc": '\U000000F4', + "ograve": '\U000000F2', + "ordf": '\U000000AA', + "ordm": '\U000000BA', + "oslash": '\U000000F8', + "otilde": '\U000000F5', + "ouml": '\U000000F6', + "para": '\U000000B6', + "plusmn": '\U000000B1', + "pound": '\U000000A3', + "quot": '\U00000022', + "raquo": '\U000000BB', + "reg": '\U000000AE', + "sect": '\U000000A7', + "shy": '\U000000AD', + "sup1": '\U000000B9', + "sup2": '\U000000B2', + "sup3": '\U000000B3', + "szlig": '\U000000DF', + "thorn": '\U000000FE', + "times": '\U000000D7', + "uacute": '\U000000FA', + "ucirc": '\U000000FB', + "ugrave": '\U000000F9', + "uml": '\U000000A8', + "uuml": '\U000000FC', + "yacute": '\U000000FD', + "yen": '\U000000A5', + "yuml": '\U000000FF', +} + +// HTML entities that are two unicode codepoints. +var entity2 = map[string][2]rune{ + // TODO(nigeltao): Handle replacements that are wider than their names. + // "nLt;": {'\u226A', '\u20D2'}, + // "nGt;": {'\u226B', '\u20D2'}, + "NotEqualTilde;": {'\u2242', '\u0338'}, + "NotGreaterFullEqual;": {'\u2267', '\u0338'}, + "NotGreaterGreater;": {'\u226B', '\u0338'}, + "NotGreaterSlantEqual;": {'\u2A7E', '\u0338'}, + "NotHumpDownHump;": {'\u224E', '\u0338'}, + "NotHumpEqual;": {'\u224F', '\u0338'}, + "NotLeftTriangleBar;": {'\u29CF', '\u0338'}, + "NotLessLess;": {'\u226A', '\u0338'}, + "NotLessSlantEqual;": {'\u2A7D', '\u0338'}, + "NotNestedGreaterGreater;": {'\u2AA2', '\u0338'}, + "NotNestedLessLess;": {'\u2AA1', '\u0338'}, + "NotPrecedesEqual;": {'\u2AAF', '\u0338'}, + "NotRightTriangleBar;": {'\u29D0', '\u0338'}, + "NotSquareSubset;": {'\u228F', '\u0338'}, + "NotSquareSuperset;": {'\u2290', '\u0338'}, + "NotSubset;": {'\u2282', '\u20D2'}, + "NotSucceedsEqual;": {'\u2AB0', '\u0338'}, + "NotSucceedsTilde;": {'\u227F', '\u0338'}, + "NotSuperset;": {'\u2283', '\u20D2'}, + "ThickSpace;": {'\u205F', '\u200A'}, + "acE;": {'\u223E', '\u0333'}, + "bne;": {'\u003D', '\u20E5'}, + "bnequiv;": {'\u2261', '\u20E5'}, + "caps;": {'\u2229', '\uFE00'}, + "cups;": {'\u222A', '\uFE00'}, + "fjlig;": {'\u0066', '\u006A'}, + "gesl;": {'\u22DB', '\uFE00'}, + "gvertneqq;": {'\u2269', '\uFE00'}, + "gvnE;": {'\u2269', '\uFE00'}, + "lates;": {'\u2AAD', '\uFE00'}, + "lesg;": {'\u22DA', '\uFE00'}, + "lvertneqq;": {'\u2268', '\uFE00'}, + "lvnE;": {'\u2268', '\uFE00'}, + "nGg;": {'\u22D9', '\u0338'}, + "nGtv;": {'\u226B', '\u0338'}, + "nLl;": {'\u22D8', '\u0338'}, + "nLtv;": {'\u226A', '\u0338'}, + "nang;": {'\u2220', '\u20D2'}, + "napE;": {'\u2A70', '\u0338'}, + "napid;": {'\u224B', '\u0338'}, + "nbump;": {'\u224E', '\u0338'}, + "nbumpe;": {'\u224F', '\u0338'}, + "ncongdot;": {'\u2A6D', '\u0338'}, + "nedot;": {'\u2250', '\u0338'}, + "nesim;": {'\u2242', '\u0338'}, + "ngE;": {'\u2267', '\u0338'}, + "ngeqq;": {'\u2267', '\u0338'}, + "ngeqslant;": {'\u2A7E', '\u0338'}, + "nges;": {'\u2A7E', '\u0338'}, + "nlE;": {'\u2266', '\u0338'}, + "nleqq;": {'\u2266', '\u0338'}, + "nleqslant;": {'\u2A7D', '\u0338'}, + "nles;": {'\u2A7D', '\u0338'}, + "notinE;": {'\u22F9', '\u0338'}, + "notindot;": {'\u22F5', '\u0338'}, + "nparsl;": {'\u2AFD', '\u20E5'}, + "npart;": {'\u2202', '\u0338'}, + "npre;": {'\u2AAF', '\u0338'}, + "npreceq;": {'\u2AAF', '\u0338'}, + "nrarrc;": {'\u2933', '\u0338'}, + "nrarrw;": {'\u219D', '\u0338'}, + "nsce;": {'\u2AB0', '\u0338'}, + "nsubE;": {'\u2AC5', '\u0338'}, + "nsubset;": {'\u2282', '\u20D2'}, + "nsubseteqq;": {'\u2AC5', '\u0338'}, + "nsucceq;": {'\u2AB0', '\u0338'}, + "nsupE;": {'\u2AC6', '\u0338'}, + "nsupset;": {'\u2283', '\u20D2'}, + "nsupseteqq;": {'\u2AC6', '\u0338'}, + "nvap;": {'\u224D', '\u20D2'}, + "nvge;": {'\u2265', '\u20D2'}, + "nvgt;": {'\u003E', '\u20D2'}, + "nvle;": {'\u2264', '\u20D2'}, + "nvlt;": {'\u003C', '\u20D2'}, + "nvltrie;": {'\u22B4', '\u20D2'}, + "nvrtrie;": {'\u22B5', '\u20D2'}, + "nvsim;": {'\u223C', '\u20D2'}, + "race;": {'\u223D', '\u0331'}, + "smtes;": {'\u2AAC', '\uFE00'}, + "sqcaps;": {'\u2293', '\uFE00'}, + "sqcups;": {'\u2294', '\uFE00'}, + "varsubsetneq;": {'\u228A', '\uFE00'}, + "varsubsetneqq;": {'\u2ACB', '\uFE00'}, + "varsupsetneq;": {'\u228B', '\uFE00'}, + "varsupsetneqq;": {'\u2ACC', '\uFE00'}, + "vnsub;": {'\u2282', '\u20D2'}, + "vnsup;": {'\u2283', '\u20D2'}, + "vsubnE;": {'\u2ACB', '\uFE00'}, + "vsubne;": {'\u228A', '\uFE00'}, + "vsupnE;": {'\u2ACC', '\uFE00'}, + "vsupne;": {'\u228B', '\uFE00'}, +} diff --git a/vendor/golang.org/x/net/html/escape.go b/vendor/golang.org/x/net/html/escape.go new file mode 100644 index 000000000..d85613962 --- /dev/null +++ b/vendor/golang.org/x/net/html/escape.go @@ -0,0 +1,258 @@ +// Copyright 2010 The Go 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 html + +import ( + "bytes" + "strings" + "unicode/utf8" +) + +// These replacements permit compatibility with old numeric entities that +// assumed Windows-1252 encoding. +// https://html.spec.whatwg.org/multipage/syntax.html#consume-a-character-reference +var replacementTable = [...]rune{ + '\u20AC', // First entry is what 0x80 should be replaced with. + '\u0081', + '\u201A', + '\u0192', + '\u201E', + '\u2026', + '\u2020', + '\u2021', + '\u02C6', + '\u2030', + '\u0160', + '\u2039', + '\u0152', + '\u008D', + '\u017D', + '\u008F', + '\u0090', + '\u2018', + '\u2019', + '\u201C', + '\u201D', + '\u2022', + '\u2013', + '\u2014', + '\u02DC', + '\u2122', + '\u0161', + '\u203A', + '\u0153', + '\u009D', + '\u017E', + '\u0178', // Last entry is 0x9F. + // 0x00->'\uFFFD' is handled programmatically. + // 0x0D->'\u000D' is a no-op. +} + +// unescapeEntity reads an entity like "<" from b[src:] and writes the +// corresponding "<" to b[dst:], returning the incremented dst and src cursors. +// Precondition: b[src] == '&' && dst <= src. +// attribute should be true if parsing an attribute value. +func unescapeEntity(b []byte, dst, src int, attribute bool) (dst1, src1 int) { + // https://html.spec.whatwg.org/multipage/syntax.html#consume-a-character-reference + + // i starts at 1 because we already know that s[0] == '&'. + i, s := 1, b[src:] + + if len(s) <= 1 { + b[dst] = b[src] + return dst + 1, src + 1 + } + + if s[i] == '#' { + if len(s) <= 3 { // We need to have at least "&#.". + b[dst] = b[src] + return dst + 1, src + 1 + } + i++ + c := s[i] + hex := false + if c == 'x' || c == 'X' { + hex = true + i++ + } + + x := '\x00' + for i < len(s) { + c = s[i] + i++ + if hex { + if '0' <= c && c <= '9' { + x = 16*x + rune(c) - '0' + continue + } else if 'a' <= c && c <= 'f' { + x = 16*x + rune(c) - 'a' + 10 + continue + } else if 'A' <= c && c <= 'F' { + x = 16*x + rune(c) - 'A' + 10 + continue + } + } else if '0' <= c && c <= '9' { + x = 10*x + rune(c) - '0' + continue + } + if c != ';' { + i-- + } + break + } + + if i <= 3 { // No characters matched. + b[dst] = b[src] + return dst + 1, src + 1 + } + + if 0x80 <= x && x <= 0x9F { + // Replace characters from Windows-1252 with UTF-8 equivalents. + x = replacementTable[x-0x80] + } else if x == 0 || (0xD800 <= x && x <= 0xDFFF) || x > 0x10FFFF { + // Replace invalid characters with the replacement character. + x = '\uFFFD' + } + + return dst + utf8.EncodeRune(b[dst:], x), src + i + } + + // Consume the maximum number of characters possible, with the + // consumed characters matching one of the named references. + + for i < len(s) { + c := s[i] + i++ + // Lower-cased characters are more common in entities, so we check for them first. + if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' { + continue + } + if c != ';' { + i-- + } + break + } + + entityName := string(s[1:i]) + if entityName == "" { + // No-op. + } else if attribute && entityName[len(entityName)-1] != ';' && len(s) > i && s[i] == '=' { + // No-op. + } else if x := entity[entityName]; x != 0 { + return dst + utf8.EncodeRune(b[dst:], x), src + i + } else if x := entity2[entityName]; x[0] != 0 { + dst1 := dst + utf8.EncodeRune(b[dst:], x[0]) + return dst1 + utf8.EncodeRune(b[dst1:], x[1]), src + i + } else if !attribute { + maxLen := len(entityName) - 1 + if maxLen > longestEntityWithoutSemicolon { + maxLen = longestEntityWithoutSemicolon + } + for j := maxLen; j > 1; j-- { + if x := entity[entityName[:j]]; x != 0 { + return dst + utf8.EncodeRune(b[dst:], x), src + j + 1 + } + } + } + + dst1, src1 = dst+i, src+i + copy(b[dst:dst1], b[src:src1]) + return dst1, src1 +} + +// unescape unescapes b's entities in-place, so that "a<b" becomes "a': + esc = ">" + case '"': + // """ is shorter than """. + esc = """ + case '\r': + esc = " " + default: + panic("unrecognized escape character") + } + s = s[i+1:] + if _, err := w.WriteString(esc); err != nil { + return err + } + i = strings.IndexAny(s, escapedChars) + } + _, err := w.WriteString(s) + return err +} + +// EscapeString escapes special characters like "<" to become "<". It +// escapes only five such characters: <, >, &, ' and ". +// UnescapeString(EscapeString(s)) == s always holds, but the converse isn't +// always true. +func EscapeString(s string) string { + if strings.IndexAny(s, escapedChars) == -1 { + return s + } + var buf bytes.Buffer + escape(&buf, s) + return buf.String() +} + +// UnescapeString unescapes entities like "<" to become "<". It unescapes a +// larger range of entities than EscapeString escapes. For example, "á" +// unescapes to "á", as does "á" and "&xE1;". +// UnescapeString(EscapeString(s)) == s always holds, but the converse isn't +// always true. +func UnescapeString(s string) string { + for _, c := range s { + if c == '&' { + return string(unescape([]byte(s), false)) + } + } + return s +} diff --git a/vendor/golang.org/x/net/html/foreign.go b/vendor/golang.org/x/net/html/foreign.go new file mode 100644 index 000000000..d3b384409 --- /dev/null +++ b/vendor/golang.org/x/net/html/foreign.go @@ -0,0 +1,226 @@ +// Copyright 2011 The Go 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 html + +import ( + "strings" +) + +func adjustAttributeNames(aa []Attribute, nameMap map[string]string) { + for i := range aa { + if newName, ok := nameMap[aa[i].Key]; ok { + aa[i].Key = newName + } + } +} + +func adjustForeignAttributes(aa []Attribute) { + for i, a := range aa { + if a.Key == "" || a.Key[0] != 'x' { + continue + } + switch a.Key { + case "xlink:actuate", "xlink:arcrole", "xlink:href", "xlink:role", "xlink:show", + "xlink:title", "xlink:type", "xml:base", "xml:lang", "xml:space", "xmlns:xlink": + j := strings.Index(a.Key, ":") + aa[i].Namespace = a.Key[:j] + aa[i].Key = a.Key[j+1:] + } + } +} + +func htmlIntegrationPoint(n *Node) bool { + if n.Type != ElementNode { + return false + } + switch n.Namespace { + case "math": + if n.Data == "annotation-xml" { + for _, a := range n.Attr { + if a.Key == "encoding" { + val := strings.ToLower(a.Val) + if val == "text/html" || val == "application/xhtml+xml" { + return true + } + } + } + } + case "svg": + switch n.Data { + case "desc", "foreignObject", "title": + return true + } + } + return false +} + +func mathMLTextIntegrationPoint(n *Node) bool { + if n.Namespace != "math" { + return false + } + switch n.Data { + case "mi", "mo", "mn", "ms", "mtext": + return true + } + return false +} + +// Section 12.2.5.5. +var breakout = map[string]bool{ + "b": true, + "big": true, + "blockquote": true, + "body": true, + "br": true, + "center": true, + "code": true, + "dd": true, + "div": true, + "dl": true, + "dt": true, + "em": true, + "embed": true, + "h1": true, + "h2": true, + "h3": true, + "h4": true, + "h5": true, + "h6": true, + "head": true, + "hr": true, + "i": true, + "img": true, + "li": true, + "listing": true, + "menu": true, + "meta": true, + "nobr": true, + "ol": true, + "p": true, + "pre": true, + "ruby": true, + "s": true, + "small": true, + "span": true, + "strong": true, + "strike": true, + "sub": true, + "sup": true, + "table": true, + "tt": true, + "u": true, + "ul": true, + "var": true, +} + +// Section 12.2.5.5. +var svgTagNameAdjustments = map[string]string{ + "altglyph": "altGlyph", + "altglyphdef": "altGlyphDef", + "altglyphitem": "altGlyphItem", + "animatecolor": "animateColor", + "animatemotion": "animateMotion", + "animatetransform": "animateTransform", + "clippath": "clipPath", + "feblend": "feBlend", + "fecolormatrix": "feColorMatrix", + "fecomponenttransfer": "feComponentTransfer", + "fecomposite": "feComposite", + "feconvolvematrix": "feConvolveMatrix", + "fediffuselighting": "feDiffuseLighting", + "fedisplacementmap": "feDisplacementMap", + "fedistantlight": "feDistantLight", + "feflood": "feFlood", + "fefunca": "feFuncA", + "fefuncb": "feFuncB", + "fefuncg": "feFuncG", + "fefuncr": "feFuncR", + "fegaussianblur": "feGaussianBlur", + "feimage": "feImage", + "femerge": "feMerge", + "femergenode": "feMergeNode", + "femorphology": "feMorphology", + "feoffset": "feOffset", + "fepointlight": "fePointLight", + "fespecularlighting": "feSpecularLighting", + "fespotlight": "feSpotLight", + "fetile": "feTile", + "feturbulence": "feTurbulence", + "foreignobject": "foreignObject", + "glyphref": "glyphRef", + "lineargradient": "linearGradient", + "radialgradient": "radialGradient", + "textpath": "textPath", +} + +// Section 12.2.5.1 +var mathMLAttributeAdjustments = map[string]string{ + "definitionurl": "definitionURL", +} + +var svgAttributeAdjustments = map[string]string{ + "attributename": "attributeName", + "attributetype": "attributeType", + "basefrequency": "baseFrequency", + "baseprofile": "baseProfile", + "calcmode": "calcMode", + "clippathunits": "clipPathUnits", + "contentscripttype": "contentScriptType", + "contentstyletype": "contentStyleType", + "diffuseconstant": "diffuseConstant", + "edgemode": "edgeMode", + "externalresourcesrequired": "externalResourcesRequired", + "filterres": "filterRes", + "filterunits": "filterUnits", + "glyphref": "glyphRef", + "gradienttransform": "gradientTransform", + "gradientunits": "gradientUnits", + "kernelmatrix": "kernelMatrix", + "kernelunitlength": "kernelUnitLength", + "keypoints": "keyPoints", + "keysplines": "keySplines", + "keytimes": "keyTimes", + "lengthadjust": "lengthAdjust", + "limitingconeangle": "limitingConeAngle", + "markerheight": "markerHeight", + "markerunits": "markerUnits", + "markerwidth": "markerWidth", + "maskcontentunits": "maskContentUnits", + "maskunits": "maskUnits", + "numoctaves": "numOctaves", + "pathlength": "pathLength", + "patterncontentunits": "patternContentUnits", + "patterntransform": "patternTransform", + "patternunits": "patternUnits", + "pointsatx": "pointsAtX", + "pointsaty": "pointsAtY", + "pointsatz": "pointsAtZ", + "preservealpha": "preserveAlpha", + "preserveaspectratio": "preserveAspectRatio", + "primitiveunits": "primitiveUnits", + "refx": "refX", + "refy": "refY", + "repeatcount": "repeatCount", + "repeatdur": "repeatDur", + "requiredextensions": "requiredExtensions", + "requiredfeatures": "requiredFeatures", + "specularconstant": "specularConstant", + "specularexponent": "specularExponent", + "spreadmethod": "spreadMethod", + "startoffset": "startOffset", + "stddeviation": "stdDeviation", + "stitchtiles": "stitchTiles", + "surfacescale": "surfaceScale", + "systemlanguage": "systemLanguage", + "tablevalues": "tableValues", + "targetx": "targetX", + "targety": "targetY", + "textlength": "textLength", + "viewbox": "viewBox", + "viewtarget": "viewTarget", + "xchannelselector": "xChannelSelector", + "ychannelselector": "yChannelSelector", + "zoomandpan": "zoomAndPan", +} diff --git a/vendor/golang.org/x/net/html/node.go b/vendor/golang.org/x/net/html/node.go new file mode 100644 index 000000000..26b657aec --- /dev/null +++ b/vendor/golang.org/x/net/html/node.go @@ -0,0 +1,193 @@ +// Copyright 2011 The Go 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 html + +import ( + "golang.org/x/net/html/atom" +) + +// A NodeType is the type of a Node. +type NodeType uint32 + +const ( + ErrorNode NodeType = iota + TextNode + DocumentNode + ElementNode + CommentNode + DoctypeNode + scopeMarkerNode +) + +// Section 12.2.3.3 says "scope markers are inserted when entering applet +// elements, buttons, object elements, marquees, table cells, and table +// captions, and are used to prevent formatting from 'leaking'". +var scopeMarker = Node{Type: scopeMarkerNode} + +// A Node consists of a NodeType and some Data (tag name for element nodes, +// content for text) and are part of a tree of Nodes. Element nodes may also +// have a Namespace and contain a slice of Attributes. Data is unescaped, so +// that it looks like "a 0 { + return (*s)[i-1] + } + return nil +} + +// index returns the index of the top-most occurrence of n in the stack, or -1 +// if n is not present. +func (s *nodeStack) index(n *Node) int { + for i := len(*s) - 1; i >= 0; i-- { + if (*s)[i] == n { + return i + } + } + return -1 +} + +// insert inserts a node at the given index. +func (s *nodeStack) insert(i int, n *Node) { + (*s) = append(*s, nil) + copy((*s)[i+1:], (*s)[i:]) + (*s)[i] = n +} + +// remove removes a node from the stack. It is a no-op if n is not present. +func (s *nodeStack) remove(n *Node) { + i := s.index(n) + if i == -1 { + return + } + copy((*s)[i:], (*s)[i+1:]) + j := len(*s) - 1 + (*s)[j] = nil + *s = (*s)[:j] +} diff --git a/vendor/golang.org/x/net/html/parse.go b/vendor/golang.org/x/net/html/parse.go new file mode 100644 index 000000000..be4b2bf5a --- /dev/null +++ b/vendor/golang.org/x/net/html/parse.go @@ -0,0 +1,2094 @@ +// Copyright 2010 The Go 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 html + +import ( + "errors" + "fmt" + "io" + "strings" + + a "golang.org/x/net/html/atom" +) + +// A parser implements the HTML5 parsing algorithm: +// https://html.spec.whatwg.org/multipage/syntax.html#tree-construction +type parser struct { + // tokenizer provides the tokens for the parser. + tokenizer *Tokenizer + // tok is the most recently read token. + tok Token + // Self-closing tags like
    are treated as start tags, except that + // hasSelfClosingToken is set while they are being processed. + hasSelfClosingToken bool + // doc is the document root element. + doc *Node + // The stack of open elements (section 12.2.3.2) and active formatting + // elements (section 12.2.3.3). + oe, afe nodeStack + // Element pointers (section 12.2.3.4). + head, form *Node + // Other parsing state flags (section 12.2.3.5). + scripting, framesetOK bool + // im is the current insertion mode. + im insertionMode + // originalIM is the insertion mode to go back to after completing a text + // or inTableText insertion mode. + originalIM insertionMode + // fosterParenting is whether new elements should be inserted according to + // the foster parenting rules (section 12.2.5.3). + fosterParenting bool + // quirks is whether the parser is operating in "quirks mode." + quirks bool + // fragment is whether the parser is parsing an HTML fragment. + fragment bool + // context is the context element when parsing an HTML fragment + // (section 12.4). + context *Node +} + +func (p *parser) top() *Node { + if n := p.oe.top(); n != nil { + return n + } + return p.doc +} + +// Stop tags for use in popUntil. These come from section 12.2.3.2. +var ( + defaultScopeStopTags = map[string][]a.Atom{ + "": {a.Applet, a.Caption, a.Html, a.Table, a.Td, a.Th, a.Marquee, a.Object, a.Template}, + "math": {a.AnnotationXml, a.Mi, a.Mn, a.Mo, a.Ms, a.Mtext}, + "svg": {a.Desc, a.ForeignObject, a.Title}, + } +) + +type scope int + +const ( + defaultScope scope = iota + listItemScope + buttonScope + tableScope + tableRowScope + tableBodyScope + selectScope +) + +// popUntil pops the stack of open elements at the highest element whose tag +// is in matchTags, provided there is no higher element in the scope's stop +// tags (as defined in section 12.2.3.2). It returns whether or not there was +// such an element. If there was not, popUntil leaves the stack unchanged. +// +// For example, the set of stop tags for table scope is: "html", "table". If +// the stack was: +// ["html", "body", "font", "table", "b", "i", "u"] +// then popUntil(tableScope, "font") would return false, but +// popUntil(tableScope, "i") would return true and the stack would become: +// ["html", "body", "font", "table", "b"] +// +// If an element's tag is in both the stop tags and matchTags, then the stack +// will be popped and the function returns true (provided, of course, there was +// no higher element in the stack that was also in the stop tags). For example, +// popUntil(tableScope, "table") returns true and leaves: +// ["html", "body", "font"] +func (p *parser) popUntil(s scope, matchTags ...a.Atom) bool { + if i := p.indexOfElementInScope(s, matchTags...); i != -1 { + p.oe = p.oe[:i] + return true + } + return false +} + +// indexOfElementInScope returns the index in p.oe of the highest element whose +// tag is in matchTags that is in scope. If no matching element is in scope, it +// returns -1. +func (p *parser) indexOfElementInScope(s scope, matchTags ...a.Atom) int { + for i := len(p.oe) - 1; i >= 0; i-- { + tagAtom := p.oe[i].DataAtom + if p.oe[i].Namespace == "" { + for _, t := range matchTags { + if t == tagAtom { + return i + } + } + switch s { + case defaultScope: + // No-op. + case listItemScope: + if tagAtom == a.Ol || tagAtom == a.Ul { + return -1 + } + case buttonScope: + if tagAtom == a.Button { + return -1 + } + case tableScope: + if tagAtom == a.Html || tagAtom == a.Table { + return -1 + } + case selectScope: + if tagAtom != a.Optgroup && tagAtom != a.Option { + return -1 + } + default: + panic("unreachable") + } + } + switch s { + case defaultScope, listItemScope, buttonScope: + for _, t := range defaultScopeStopTags[p.oe[i].Namespace] { + if t == tagAtom { + return -1 + } + } + } + } + return -1 +} + +// elementInScope is like popUntil, except that it doesn't modify the stack of +// open elements. +func (p *parser) elementInScope(s scope, matchTags ...a.Atom) bool { + return p.indexOfElementInScope(s, matchTags...) != -1 +} + +// clearStackToContext pops elements off the stack of open elements until a +// scope-defined element is found. +func (p *parser) clearStackToContext(s scope) { + for i := len(p.oe) - 1; i >= 0; i-- { + tagAtom := p.oe[i].DataAtom + switch s { + case tableScope: + if tagAtom == a.Html || tagAtom == a.Table { + p.oe = p.oe[:i+1] + return + } + case tableRowScope: + if tagAtom == a.Html || tagAtom == a.Tr { + p.oe = p.oe[:i+1] + return + } + case tableBodyScope: + if tagAtom == a.Html || tagAtom == a.Tbody || tagAtom == a.Tfoot || tagAtom == a.Thead { + p.oe = p.oe[:i+1] + return + } + default: + panic("unreachable") + } + } +} + +// generateImpliedEndTags pops nodes off the stack of open elements as long as +// the top node has a tag name of dd, dt, li, option, optgroup, p, rp, or rt. +// If exceptions are specified, nodes with that name will not be popped off. +func (p *parser) generateImpliedEndTags(exceptions ...string) { + var i int +loop: + for i = len(p.oe) - 1; i >= 0; i-- { + n := p.oe[i] + if n.Type == ElementNode { + switch n.DataAtom { + case a.Dd, a.Dt, a.Li, a.Option, a.Optgroup, a.P, a.Rp, a.Rt: + for _, except := range exceptions { + if n.Data == except { + break loop + } + } + continue + } + } + break + } + + p.oe = p.oe[:i+1] +} + +// addChild adds a child node n to the top element, and pushes n onto the stack +// of open elements if it is an element node. +func (p *parser) addChild(n *Node) { + if p.shouldFosterParent() { + p.fosterParent(n) + } else { + p.top().AppendChild(n) + } + + if n.Type == ElementNode { + p.oe = append(p.oe, n) + } +} + +// shouldFosterParent returns whether the next node to be added should be +// foster parented. +func (p *parser) shouldFosterParent() bool { + if p.fosterParenting { + switch p.top().DataAtom { + case a.Table, a.Tbody, a.Tfoot, a.Thead, a.Tr: + return true + } + } + return false +} + +// fosterParent adds a child node according to the foster parenting rules. +// Section 12.2.5.3, "foster parenting". +func (p *parser) fosterParent(n *Node) { + var table, parent, prev *Node + var i int + for i = len(p.oe) - 1; i >= 0; i-- { + if p.oe[i].DataAtom == a.Table { + table = p.oe[i] + break + } + } + + if table == nil { + // The foster parent is the html element. + parent = p.oe[0] + } else { + parent = table.Parent + } + if parent == nil { + parent = p.oe[i-1] + } + + if table != nil { + prev = table.PrevSibling + } else { + prev = parent.LastChild + } + if prev != nil && prev.Type == TextNode && n.Type == TextNode { + prev.Data += n.Data + return + } + + parent.InsertBefore(n, table) +} + +// addText adds text to the preceding node if it is a text node, or else it +// calls addChild with a new text node. +func (p *parser) addText(text string) { + if text == "" { + return + } + + if p.shouldFosterParent() { + p.fosterParent(&Node{ + Type: TextNode, + Data: text, + }) + return + } + + t := p.top() + if n := t.LastChild; n != nil && n.Type == TextNode { + n.Data += text + return + } + p.addChild(&Node{ + Type: TextNode, + Data: text, + }) +} + +// addElement adds a child element based on the current token. +func (p *parser) addElement() { + p.addChild(&Node{ + Type: ElementNode, + DataAtom: p.tok.DataAtom, + Data: p.tok.Data, + Attr: p.tok.Attr, + }) +} + +// Section 12.2.3.3. +func (p *parser) addFormattingElement() { + tagAtom, attr := p.tok.DataAtom, p.tok.Attr + p.addElement() + + // Implement the Noah's Ark clause, but with three per family instead of two. + identicalElements := 0 +findIdenticalElements: + for i := len(p.afe) - 1; i >= 0; i-- { + n := p.afe[i] + if n.Type == scopeMarkerNode { + break + } + if n.Type != ElementNode { + continue + } + if n.Namespace != "" { + continue + } + if n.DataAtom != tagAtom { + continue + } + if len(n.Attr) != len(attr) { + continue + } + compareAttributes: + for _, t0 := range n.Attr { + for _, t1 := range attr { + if t0.Key == t1.Key && t0.Namespace == t1.Namespace && t0.Val == t1.Val { + // Found a match for this attribute, continue with the next attribute. + continue compareAttributes + } + } + // If we get here, there is no attribute that matches a. + // Therefore the element is not identical to the new one. + continue findIdenticalElements + } + + identicalElements++ + if identicalElements >= 3 { + p.afe.remove(n) + } + } + + p.afe = append(p.afe, p.top()) +} + +// Section 12.2.3.3. +func (p *parser) clearActiveFormattingElements() { + for { + n := p.afe.pop() + if len(p.afe) == 0 || n.Type == scopeMarkerNode { + return + } + } +} + +// Section 12.2.3.3. +func (p *parser) reconstructActiveFormattingElements() { + n := p.afe.top() + if n == nil { + return + } + if n.Type == scopeMarkerNode || p.oe.index(n) != -1 { + return + } + i := len(p.afe) - 1 + for n.Type != scopeMarkerNode && p.oe.index(n) == -1 { + if i == 0 { + i = -1 + break + } + i-- + n = p.afe[i] + } + for { + i++ + clone := p.afe[i].clone() + p.addChild(clone) + p.afe[i] = clone + if i == len(p.afe)-1 { + break + } + } +} + +// Section 12.2.4. +func (p *parser) acknowledgeSelfClosingTag() { + p.hasSelfClosingToken = false +} + +// An insertion mode (section 12.2.3.1) is the state transition function from +// a particular state in the HTML5 parser's state machine. It updates the +// parser's fields depending on parser.tok (where ErrorToken means EOF). +// It returns whether the token was consumed. +type insertionMode func(*parser) bool + +// setOriginalIM sets the insertion mode to return to after completing a text or +// inTableText insertion mode. +// Section 12.2.3.1, "using the rules for". +func (p *parser) setOriginalIM() { + if p.originalIM != nil { + panic("html: bad parser state: originalIM was set twice") + } + p.originalIM = p.im +} + +// Section 12.2.3.1, "reset the insertion mode". +func (p *parser) resetInsertionMode() { + for i := len(p.oe) - 1; i >= 0; i-- { + n := p.oe[i] + if i == 0 && p.context != nil { + n = p.context + } + + switch n.DataAtom { + case a.Select: + p.im = inSelectIM + case a.Td, a.Th: + p.im = inCellIM + case a.Tr: + p.im = inRowIM + case a.Tbody, a.Thead, a.Tfoot: + p.im = inTableBodyIM + case a.Caption: + p.im = inCaptionIM + case a.Colgroup: + p.im = inColumnGroupIM + case a.Table: + p.im = inTableIM + case a.Head: + p.im = inBodyIM + case a.Body: + p.im = inBodyIM + case a.Frameset: + p.im = inFramesetIM + case a.Html: + p.im = beforeHeadIM + default: + continue + } + return + } + p.im = inBodyIM +} + +const whitespace = " \t\r\n\f" + +// Section 12.2.5.4.1. +func initialIM(p *parser) bool { + switch p.tok.Type { + case TextToken: + p.tok.Data = strings.TrimLeft(p.tok.Data, whitespace) + if len(p.tok.Data) == 0 { + // It was all whitespace, so ignore it. + return true + } + case CommentToken: + p.doc.AppendChild(&Node{ + Type: CommentNode, + Data: p.tok.Data, + }) + return true + case DoctypeToken: + n, quirks := parseDoctype(p.tok.Data) + p.doc.AppendChild(n) + p.quirks = quirks + p.im = beforeHTMLIM + return true + } + p.quirks = true + p.im = beforeHTMLIM + return false +} + +// Section 12.2.5.4.2. +func beforeHTMLIM(p *parser) bool { + switch p.tok.Type { + case DoctypeToken: + // Ignore the token. + return true + case TextToken: + p.tok.Data = strings.TrimLeft(p.tok.Data, whitespace) + if len(p.tok.Data) == 0 { + // It was all whitespace, so ignore it. + return true + } + case StartTagToken: + if p.tok.DataAtom == a.Html { + p.addElement() + p.im = beforeHeadIM + return true + } + case EndTagToken: + switch p.tok.DataAtom { + case a.Head, a.Body, a.Html, a.Br: + p.parseImpliedToken(StartTagToken, a.Html, a.Html.String()) + return false + default: + // Ignore the token. + return true + } + case CommentToken: + p.doc.AppendChild(&Node{ + Type: CommentNode, + Data: p.tok.Data, + }) + return true + } + p.parseImpliedToken(StartTagToken, a.Html, a.Html.String()) + return false +} + +// Section 12.2.5.4.3. +func beforeHeadIM(p *parser) bool { + switch p.tok.Type { + case TextToken: + p.tok.Data = strings.TrimLeft(p.tok.Data, whitespace) + if len(p.tok.Data) == 0 { + // It was all whitespace, so ignore it. + return true + } + case StartTagToken: + switch p.tok.DataAtom { + case a.Head: + p.addElement() + p.head = p.top() + p.im = inHeadIM + return true + case a.Html: + return inBodyIM(p) + } + case EndTagToken: + switch p.tok.DataAtom { + case a.Head, a.Body, a.Html, a.Br: + p.parseImpliedToken(StartTagToken, a.Head, a.Head.String()) + return false + default: + // Ignore the token. + return true + } + case CommentToken: + p.addChild(&Node{ + Type: CommentNode, + Data: p.tok.Data, + }) + return true + case DoctypeToken: + // Ignore the token. + return true + } + + p.parseImpliedToken(StartTagToken, a.Head, a.Head.String()) + return false +} + +// Section 12.2.5.4.4. +func inHeadIM(p *parser) bool { + switch p.tok.Type { + case TextToken: + s := strings.TrimLeft(p.tok.Data, whitespace) + if len(s) < len(p.tok.Data) { + // Add the initial whitespace to the current node. + p.addText(p.tok.Data[:len(p.tok.Data)-len(s)]) + if s == "" { + return true + } + p.tok.Data = s + } + case StartTagToken: + switch p.tok.DataAtom { + case a.Html: + return inBodyIM(p) + case a.Base, a.Basefont, a.Bgsound, a.Command, a.Link, a.Meta: + p.addElement() + p.oe.pop() + p.acknowledgeSelfClosingTag() + return true + case a.Script, a.Title, a.Noscript, a.Noframes, a.Style: + p.addElement() + p.setOriginalIM() + p.im = textIM + return true + case a.Head: + // Ignore the token. + return true + } + case EndTagToken: + switch p.tok.DataAtom { + case a.Head: + n := p.oe.pop() + if n.DataAtom != a.Head { + panic("html: bad parser state: element not found, in the in-head insertion mode") + } + p.im = afterHeadIM + return true + case a.Body, a.Html, a.Br: + p.parseImpliedToken(EndTagToken, a.Head, a.Head.String()) + return false + default: + // Ignore the token. + return true + } + case CommentToken: + p.addChild(&Node{ + Type: CommentNode, + Data: p.tok.Data, + }) + return true + case DoctypeToken: + // Ignore the token. + return true + } + + p.parseImpliedToken(EndTagToken, a.Head, a.Head.String()) + return false +} + +// Section 12.2.5.4.6. +func afterHeadIM(p *parser) bool { + switch p.tok.Type { + case TextToken: + s := strings.TrimLeft(p.tok.Data, whitespace) + if len(s) < len(p.tok.Data) { + // Add the initial whitespace to the current node. + p.addText(p.tok.Data[:len(p.tok.Data)-len(s)]) + if s == "" { + return true + } + p.tok.Data = s + } + case StartTagToken: + switch p.tok.DataAtom { + case a.Html: + return inBodyIM(p) + case a.Body: + p.addElement() + p.framesetOK = false + p.im = inBodyIM + return true + case a.Frameset: + p.addElement() + p.im = inFramesetIM + return true + case a.Base, a.Basefont, a.Bgsound, a.Link, a.Meta, a.Noframes, a.Script, a.Style, a.Title: + p.oe = append(p.oe, p.head) + defer p.oe.remove(p.head) + return inHeadIM(p) + case a.Head: + // Ignore the token. + return true + } + case EndTagToken: + switch p.tok.DataAtom { + case a.Body, a.Html, a.Br: + // Drop down to creating an implied tag. + default: + // Ignore the token. + return true + } + case CommentToken: + p.addChild(&Node{ + Type: CommentNode, + Data: p.tok.Data, + }) + return true + case DoctypeToken: + // Ignore the token. + return true + } + + p.parseImpliedToken(StartTagToken, a.Body, a.Body.String()) + p.framesetOK = true + return false +} + +// copyAttributes copies attributes of src not found on dst to dst. +func copyAttributes(dst *Node, src Token) { + if len(src.Attr) == 0 { + return + } + attr := map[string]string{} + for _, t := range dst.Attr { + attr[t.Key] = t.Val + } + for _, t := range src.Attr { + if _, ok := attr[t.Key]; !ok { + dst.Attr = append(dst.Attr, t) + attr[t.Key] = t.Val + } + } +} + +// Section 12.2.5.4.7. +func inBodyIM(p *parser) bool { + switch p.tok.Type { + case TextToken: + d := p.tok.Data + switch n := p.oe.top(); n.DataAtom { + case a.Pre, a.Listing: + if n.FirstChild == nil { + // Ignore a newline at the start of a
     block.
    +				if d != "" && d[0] == '\r' {
    +					d = d[1:]
    +				}
    +				if d != "" && d[0] == '\n' {
    +					d = d[1:]
    +				}
    +			}
    +		}
    +		d = strings.Replace(d, "\x00", "", -1)
    +		if d == "" {
    +			return true
    +		}
    +		p.reconstructActiveFormattingElements()
    +		p.addText(d)
    +		if p.framesetOK && strings.TrimLeft(d, whitespace) != "" {
    +			// There were non-whitespace characters inserted.
    +			p.framesetOK = false
    +		}
    +	case StartTagToken:
    +		switch p.tok.DataAtom {
    +		case a.Html:
    +			copyAttributes(p.oe[0], p.tok)
    +		case a.Base, a.Basefont, a.Bgsound, a.Command, a.Link, a.Meta, a.Noframes, a.Script, a.Style, a.Title:
    +			return inHeadIM(p)
    +		case a.Body:
    +			if len(p.oe) >= 2 {
    +				body := p.oe[1]
    +				if body.Type == ElementNode && body.DataAtom == a.Body {
    +					p.framesetOK = false
    +					copyAttributes(body, p.tok)
    +				}
    +			}
    +		case a.Frameset:
    +			if !p.framesetOK || len(p.oe) < 2 || p.oe[1].DataAtom != a.Body {
    +				// Ignore the token.
    +				return true
    +			}
    +			body := p.oe[1]
    +			if body.Parent != nil {
    +				body.Parent.RemoveChild(body)
    +			}
    +			p.oe = p.oe[:1]
    +			p.addElement()
    +			p.im = inFramesetIM
    +			return true
    +		case a.Address, a.Article, a.Aside, a.Blockquote, a.Center, a.Details, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Menu, a.Nav, a.Ol, a.P, a.Section, a.Summary, a.Ul:
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +		case a.H1, a.H2, a.H3, a.H4, a.H5, a.H6:
    +			p.popUntil(buttonScope, a.P)
    +			switch n := p.top(); n.DataAtom {
    +			case a.H1, a.H2, a.H3, a.H4, a.H5, a.H6:
    +				p.oe.pop()
    +			}
    +			p.addElement()
    +		case a.Pre, a.Listing:
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +			// The newline, if any, will be dealt with by the TextToken case.
    +			p.framesetOK = false
    +		case a.Form:
    +			if p.form == nil {
    +				p.popUntil(buttonScope, a.P)
    +				p.addElement()
    +				p.form = p.top()
    +			}
    +		case a.Li:
    +			p.framesetOK = false
    +			for i := len(p.oe) - 1; i >= 0; i-- {
    +				node := p.oe[i]
    +				switch node.DataAtom {
    +				case a.Li:
    +					p.oe = p.oe[:i]
    +				case a.Address, a.Div, a.P:
    +					continue
    +				default:
    +					if !isSpecialElement(node) {
    +						continue
    +					}
    +				}
    +				break
    +			}
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +		case a.Dd, a.Dt:
    +			p.framesetOK = false
    +			for i := len(p.oe) - 1; i >= 0; i-- {
    +				node := p.oe[i]
    +				switch node.DataAtom {
    +				case a.Dd, a.Dt:
    +					p.oe = p.oe[:i]
    +				case a.Address, a.Div, a.P:
    +					continue
    +				default:
    +					if !isSpecialElement(node) {
    +						continue
    +					}
    +				}
    +				break
    +			}
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +		case a.Plaintext:
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +		case a.Button:
    +			p.popUntil(defaultScope, a.Button)
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +			p.framesetOK = false
    +		case a.A:
    +			for i := len(p.afe) - 1; i >= 0 && p.afe[i].Type != scopeMarkerNode; i-- {
    +				if n := p.afe[i]; n.Type == ElementNode && n.DataAtom == a.A {
    +					p.inBodyEndTagFormatting(a.A)
    +					p.oe.remove(n)
    +					p.afe.remove(n)
    +					break
    +				}
    +			}
    +			p.reconstructActiveFormattingElements()
    +			p.addFormattingElement()
    +		case a.B, a.Big, a.Code, a.Em, a.Font, a.I, a.S, a.Small, a.Strike, a.Strong, a.Tt, a.U:
    +			p.reconstructActiveFormattingElements()
    +			p.addFormattingElement()
    +		case a.Nobr:
    +			p.reconstructActiveFormattingElements()
    +			if p.elementInScope(defaultScope, a.Nobr) {
    +				p.inBodyEndTagFormatting(a.Nobr)
    +				p.reconstructActiveFormattingElements()
    +			}
    +			p.addFormattingElement()
    +		case a.Applet, a.Marquee, a.Object:
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +			p.afe = append(p.afe, &scopeMarker)
    +			p.framesetOK = false
    +		case a.Table:
    +			if !p.quirks {
    +				p.popUntil(buttonScope, a.P)
    +			}
    +			p.addElement()
    +			p.framesetOK = false
    +			p.im = inTableIM
    +			return true
    +		case a.Area, a.Br, a.Embed, a.Img, a.Input, a.Keygen, a.Wbr:
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +			p.oe.pop()
    +			p.acknowledgeSelfClosingTag()
    +			if p.tok.DataAtom == a.Input {
    +				for _, t := range p.tok.Attr {
    +					if t.Key == "type" {
    +						if strings.ToLower(t.Val) == "hidden" {
    +							// Skip setting framesetOK = false
    +							return true
    +						}
    +					}
    +				}
    +			}
    +			p.framesetOK = false
    +		case a.Param, a.Source, a.Track:
    +			p.addElement()
    +			p.oe.pop()
    +			p.acknowledgeSelfClosingTag()
    +		case a.Hr:
    +			p.popUntil(buttonScope, a.P)
    +			p.addElement()
    +			p.oe.pop()
    +			p.acknowledgeSelfClosingTag()
    +			p.framesetOK = false
    +		case a.Image:
    +			p.tok.DataAtom = a.Img
    +			p.tok.Data = a.Img.String()
    +			return false
    +		case a.Isindex:
    +			if p.form != nil {
    +				// Ignore the token.
    +				return true
    +			}
    +			action := ""
    +			prompt := "This is a searchable index. Enter search keywords: "
    +			attr := []Attribute{{Key: "name", Val: "isindex"}}
    +			for _, t := range p.tok.Attr {
    +				switch t.Key {
    +				case "action":
    +					action = t.Val
    +				case "name":
    +					// Ignore the attribute.
    +				case "prompt":
    +					prompt = t.Val
    +				default:
    +					attr = append(attr, t)
    +				}
    +			}
    +			p.acknowledgeSelfClosingTag()
    +			p.popUntil(buttonScope, a.P)
    +			p.parseImpliedToken(StartTagToken, a.Form, a.Form.String())
    +			if action != "" {
    +				p.form.Attr = []Attribute{{Key: "action", Val: action}}
    +			}
    +			p.parseImpliedToken(StartTagToken, a.Hr, a.Hr.String())
    +			p.parseImpliedToken(StartTagToken, a.Label, a.Label.String())
    +			p.addText(prompt)
    +			p.addChild(&Node{
    +				Type:     ElementNode,
    +				DataAtom: a.Input,
    +				Data:     a.Input.String(),
    +				Attr:     attr,
    +			})
    +			p.oe.pop()
    +			p.parseImpliedToken(EndTagToken, a.Label, a.Label.String())
    +			p.parseImpliedToken(StartTagToken, a.Hr, a.Hr.String())
    +			p.parseImpliedToken(EndTagToken, a.Form, a.Form.String())
    +		case a.Textarea:
    +			p.addElement()
    +			p.setOriginalIM()
    +			p.framesetOK = false
    +			p.im = textIM
    +		case a.Xmp:
    +			p.popUntil(buttonScope, a.P)
    +			p.reconstructActiveFormattingElements()
    +			p.framesetOK = false
    +			p.addElement()
    +			p.setOriginalIM()
    +			p.im = textIM
    +		case a.Iframe:
    +			p.framesetOK = false
    +			p.addElement()
    +			p.setOriginalIM()
    +			p.im = textIM
    +		case a.Noembed, a.Noscript:
    +			p.addElement()
    +			p.setOriginalIM()
    +			p.im = textIM
    +		case a.Select:
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +			p.framesetOK = false
    +			p.im = inSelectIM
    +			return true
    +		case a.Optgroup, a.Option:
    +			if p.top().DataAtom == a.Option {
    +				p.oe.pop()
    +			}
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +		case a.Rp, a.Rt:
    +			if p.elementInScope(defaultScope, a.Ruby) {
    +				p.generateImpliedEndTags()
    +			}
    +			p.addElement()
    +		case a.Math, a.Svg:
    +			p.reconstructActiveFormattingElements()
    +			if p.tok.DataAtom == a.Math {
    +				adjustAttributeNames(p.tok.Attr, mathMLAttributeAdjustments)
    +			} else {
    +				adjustAttributeNames(p.tok.Attr, svgAttributeAdjustments)
    +			}
    +			adjustForeignAttributes(p.tok.Attr)
    +			p.addElement()
    +			p.top().Namespace = p.tok.Data
    +			if p.hasSelfClosingToken {
    +				p.oe.pop()
    +				p.acknowledgeSelfClosingTag()
    +			}
    +			return true
    +		case a.Caption, a.Col, a.Colgroup, a.Frame, a.Head, a.Tbody, a.Td, a.Tfoot, a.Th, a.Thead, a.Tr:
    +			// Ignore the token.
    +		default:
    +			p.reconstructActiveFormattingElements()
    +			p.addElement()
    +		}
    +	case EndTagToken:
    +		switch p.tok.DataAtom {
    +		case a.Body:
    +			if p.elementInScope(defaultScope, a.Body) {
    +				p.im = afterBodyIM
    +			}
    +		case a.Html:
    +			if p.elementInScope(defaultScope, a.Body) {
    +				p.parseImpliedToken(EndTagToken, a.Body, a.Body.String())
    +				return false
    +			}
    +			return true
    +		case a.Address, a.Article, a.Aside, a.Blockquote, a.Button, a.Center, a.Details, a.Dir, a.Div, a.Dl, a.Fieldset, a.Figcaption, a.Figure, a.Footer, a.Header, a.Hgroup, a.Listing, a.Menu, a.Nav, a.Ol, a.Pre, a.Section, a.Summary, a.Ul:
    +			p.popUntil(defaultScope, p.tok.DataAtom)
    +		case a.Form:
    +			node := p.form
    +			p.form = nil
    +			i := p.indexOfElementInScope(defaultScope, a.Form)
    +			if node == nil || i == -1 || p.oe[i] != node {
    +				// Ignore the token.
    +				return true
    +			}
    +			p.generateImpliedEndTags()
    +			p.oe.remove(node)
    +		case a.P:
    +			if !p.elementInScope(buttonScope, a.P) {
    +				p.parseImpliedToken(StartTagToken, a.P, a.P.String())
    +			}
    +			p.popUntil(buttonScope, a.P)
    +		case a.Li:
    +			p.popUntil(listItemScope, a.Li)
    +		case a.Dd, a.Dt:
    +			p.popUntil(defaultScope, p.tok.DataAtom)
    +		case a.H1, a.H2, a.H3, a.H4, a.H5, a.H6:
    +			p.popUntil(defaultScope, a.H1, a.H2, a.H3, a.H4, a.H5, a.H6)
    +		case a.A, a.B, a.Big, a.Code, a.Em, a.Font, a.I, a.Nobr, a.S, a.Small, a.Strike, a.Strong, a.Tt, a.U:
    +			p.inBodyEndTagFormatting(p.tok.DataAtom)
    +		case a.Applet, a.Marquee, a.Object:
    +			if p.popUntil(defaultScope, p.tok.DataAtom) {
    +				p.clearActiveFormattingElements()
    +			}
    +		case a.Br:
    +			p.tok.Type = StartTagToken
    +			return false
    +		default:
    +			p.inBodyEndTagOther(p.tok.DataAtom)
    +		}
    +	case CommentToken:
    +		p.addChild(&Node{
    +			Type: CommentNode,
    +			Data: p.tok.Data,
    +		})
    +	}
    +
    +	return true
    +}
    +
    +func (p *parser) inBodyEndTagFormatting(tagAtom a.Atom) {
    +	// This is the "adoption agency" algorithm, described at
    +	// https://html.spec.whatwg.org/multipage/syntax.html#adoptionAgency
    +
    +	// TODO: this is a fairly literal line-by-line translation of that algorithm.
    +	// Once the code successfully parses the comprehensive test suite, we should
    +	// refactor this code to be more idiomatic.
    +
    +	// Steps 1-4. The outer loop.
    +	for i := 0; i < 8; i++ {
    +		// Step 5. Find the formatting element.
    +		var formattingElement *Node
    +		for j := len(p.afe) - 1; j >= 0; j-- {
    +			if p.afe[j].Type == scopeMarkerNode {
    +				break
    +			}
    +			if p.afe[j].DataAtom == tagAtom {
    +				formattingElement = p.afe[j]
    +				break
    +			}
    +		}
    +		if formattingElement == nil {
    +			p.inBodyEndTagOther(tagAtom)
    +			return
    +		}
    +		feIndex := p.oe.index(formattingElement)
    +		if feIndex == -1 {
    +			p.afe.remove(formattingElement)
    +			return
    +		}
    +		if !p.elementInScope(defaultScope, tagAtom) {
    +			// Ignore the tag.
    +			return
    +		}
    +
    +		// Steps 9-10. Find the furthest block.
    +		var furthestBlock *Node
    +		for _, e := range p.oe[feIndex:] {
    +			if isSpecialElement(e) {
    +				furthestBlock = e
    +				break
    +			}
    +		}
    +		if furthestBlock == nil {
    +			e := p.oe.pop()
    +			for e != formattingElement {
    +				e = p.oe.pop()
    +			}
    +			p.afe.remove(e)
    +			return
    +		}
    +
    +		// Steps 11-12. Find the common ancestor and bookmark node.
    +		commonAncestor := p.oe[feIndex-1]
    +		bookmark := p.afe.index(formattingElement)
    +
    +		// Step 13. The inner loop. Find the lastNode to reparent.
    +		lastNode := furthestBlock
    +		node := furthestBlock
    +		x := p.oe.index(node)
    +		// Steps 13.1-13.2
    +		for j := 0; j < 3; j++ {
    +			// Step 13.3.
    +			x--
    +			node = p.oe[x]
    +			// Step 13.4 - 13.5.
    +			if p.afe.index(node) == -1 {
    +				p.oe.remove(node)
    +				continue
    +			}
    +			// Step 13.6.
    +			if node == formattingElement {
    +				break
    +			}
    +			// Step 13.7.
    +			clone := node.clone()
    +			p.afe[p.afe.index(node)] = clone
    +			p.oe[p.oe.index(node)] = clone
    +			node = clone
    +			// Step 13.8.
    +			if lastNode == furthestBlock {
    +				bookmark = p.afe.index(node) + 1
    +			}
    +			// Step 13.9.
    +			if lastNode.Parent != nil {
    +				lastNode.Parent.RemoveChild(lastNode)
    +			}
    +			node.AppendChild(lastNode)
    +			// Step 13.10.
    +			lastNode = node
    +		}
    +
    +		// Step 14. Reparent lastNode to the common ancestor,
    +		// or for misnested table nodes, to the foster parent.
    +		if lastNode.Parent != nil {
    +			lastNode.Parent.RemoveChild(lastNode)
    +		}
    +		switch commonAncestor.DataAtom {
    +		case a.Table, a.Tbody, a.Tfoot, a.Thead, a.Tr:
    +			p.fosterParent(lastNode)
    +		default:
    +			commonAncestor.AppendChild(lastNode)
    +		}
    +
    +		// Steps 15-17. Reparent nodes from the furthest block's children
    +		// to a clone of the formatting element.
    +		clone := formattingElement.clone()
    +		reparentChildren(clone, furthestBlock)
    +		furthestBlock.AppendChild(clone)
    +
    +		// Step 18. Fix up the list of active formatting elements.
    +		if oldLoc := p.afe.index(formattingElement); oldLoc != -1 && oldLoc < bookmark {
    +			// Move the bookmark with the rest of the list.
    +			bookmark--
    +		}
    +		p.afe.remove(formattingElement)
    +		p.afe.insert(bookmark, clone)
    +
    +		// Step 19. Fix up the stack of open elements.
    +		p.oe.remove(formattingElement)
    +		p.oe.insert(p.oe.index(furthestBlock)+1, clone)
    +	}
    +}
    +
    +// inBodyEndTagOther performs the "any other end tag" algorithm for inBodyIM.
    +// "Any other end tag" handling from 12.2.5.5 The rules for parsing tokens in foreign content
    +// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inforeign
    +func (p *parser) inBodyEndTagOther(tagAtom a.Atom) {
    +	for i := len(p.oe) - 1; i >= 0; i-- {
    +		if p.oe[i].DataAtom == tagAtom {
    +			p.oe = p.oe[:i]
    +			break
    +		}
    +		if isSpecialElement(p.oe[i]) {
    +			break
    +		}
    +	}
    +}
    +
    +// Section 12.2.5.4.8.
    +func textIM(p *parser) bool {
    +	switch p.tok.Type {
    +	case ErrorToken:
    +		p.oe.pop()
    +	case TextToken:
    +		d := p.tok.Data
    +		if n := p.oe.top(); n.DataAtom == a.Textarea && n.FirstChild == nil {
    +			// Ignore a newline at the start of a