From f50564af5f35b3bfb5064d6bf51ea65fc91c562a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thor=20Anker=20Kvisg=C3=A5rd=20Lange?= Date: Tue, 25 Apr 2023 13:26:50 +0200 Subject: [PATCH] feat: Extending API implementation around webhooks, pull-requests, users, and build-status --- bitbucket/BUILD | 13 + bitbucket/access_tokens.go | 6 - bitbucket/bitbucket.go | 25 ++ bitbucket/events.go | 92 ++++ bitbucket/events_test.go | 405 ++++++++++++++++++ bitbucket/projects_repos_branches.go | 57 +++ bitbucket/projects_repos_branches_test.go | 269 ++++++++++++ bitbucket/projects_repos_commits_builds.go | 48 +++ .../projects_repos_commits_builds_test.go | 35 ++ bitbucket/projects_repos_prs.go | 57 +++ bitbucket/projects_repos_webhooks.go | 70 +++ bitbucket/projects_repos_webhooks_test.go | 118 +++++ bitbucket/users.go | 37 ++ bitbucket/users_test.go | 39 ++ bitbucket/webhook.go | 84 ++++ bitbucket/webhook_test.go | 33 ++ mock/endpoints.go | 19 +- 17 files changed, 1397 insertions(+), 10 deletions(-) create mode 100644 bitbucket/events.go create mode 100644 bitbucket/events_test.go create mode 100644 bitbucket/projects_repos_branches.go create mode 100644 bitbucket/projects_repos_branches_test.go create mode 100644 bitbucket/projects_repos_commits_builds.go create mode 100644 bitbucket/projects_repos_commits_builds_test.go create mode 100644 bitbucket/projects_repos_prs.go create mode 100644 bitbucket/projects_repos_webhooks.go create mode 100644 bitbucket/projects_repos_webhooks_test.go create mode 100644 bitbucket/users.go create mode 100644 bitbucket/users_test.go create mode 100644 bitbucket/webhook.go create mode 100644 bitbucket/webhook_test.go diff --git a/bitbucket/BUILD b/bitbucket/BUILD index 80ed1c7..5a45436 100644 --- a/bitbucket/BUILD +++ b/bitbucket/BUILD @@ -7,10 +7,17 @@ go_library( "access_tokens_repos.go", "access_tokens_users.go", "bitbucket.go", + "events.go", "keys.go", "keys_repos.go", "projects.go", "projects_repos.go", + "projects_repos_branches.go", + "projects_repos_commits_builds.go", + "projects_repos_prs.go", + "projects_repos_webhooks.go", + "users.go", + "webhook.go", ], importpath = "github.com/neticdk/go-bitbucket/bitbucket", visibility = ["//visibility:public"], @@ -23,8 +30,14 @@ go_test( "access_tokens_repos_test.go", "access_tokens_users_test.go", "bitbucket_test.go", + "events_test.go", "keys_repos_test.go", + "projects_repos_branches_test.go", + "projects_repos_commits_builds_test.go", "projects_repos_test.go", + "projects_repos_webhooks_test.go", + "users_test.go", + "webhook_test.go", ], embed = [":go_default_library"], deps = ["@com_github_stretchr_testify//assert:go_default_library"], diff --git a/bitbucket/access_tokens.go b/bitbucket/access_tokens.go index 0b95499..aab00be 100644 --- a/bitbucket/access_tokens.go +++ b/bitbucket/access_tokens.go @@ -19,9 +19,3 @@ type AccessToken struct { Token string `json:"token,omitempty"` User *User `json:"user,omitempty"` } - -type User struct { - ID uint64 `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} diff --git a/bitbucket/bitbucket.go b/bitbucket/bitbucket.go index a4e756c..8f45d27 100644 --- a/bitbucket/bitbucket.go +++ b/bitbucket/bitbucket.go @@ -34,6 +34,7 @@ type Client struct { AccessTokens *AccessTokensService Keys *KeysService Projects *ProjectsService + Users *UsersService } type service struct { @@ -96,6 +97,29 @@ func (t DateTime) MarshalJSON() ([]byte, error) { return json.Marshal(time.Time(t).Unix() * 1000) } +type ISOTime time.Time + +const isoLayout = "2006-01-02T15:04:05Z0700" + +func (t *ISOTime) UnmarshalJSON(bytes []byte) error { + var raw string + err := json.Unmarshal(bytes, &raw) + if err != nil { + return err + } + parsed, err := time.Parse(isoLayout, raw) + if err != nil { + return err + } + *t = ISOTime(parsed) + return nil +} + +func (t ISOTime) MarshalJSON() ([]byte, error) { + s := time.Time(t).Format(isoLayout) + return []byte(s), nil +} + type Permission string const ( @@ -156,6 +180,7 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { c.AccessTokens = (*AccessTokensService)(&c.common) c.Keys = (*KeysService)(&c.common) c.Projects = (*ProjectsService)(&c.common) + c.Users = (*UsersService)(&c.common) return c, nil } diff --git a/bitbucket/events.go b/bitbucket/events.go new file mode 100644 index 0000000..173e809 --- /dev/null +++ b/bitbucket/events.go @@ -0,0 +1,92 @@ +package bitbucket + +type GitUser struct { + Name string `json:"name"` + Email string `json:"emailAddress"` +} + +type CommitData struct { + ID string `json:"id"` + DisplayID string `json:"displayId"` + Message string `json:"message"` + Author GitUser `json:"author"` + Authored DateTime `json:"authorTimestamp"` + Comitter GitUser `json:"committer"` + Comitted DateTime `json:"committerTimestamp"` +} + +type Commit struct { + CommitData + Parents []CommitData `json:"parents"` +} + +type RepositoryPushEvent struct { + EventKey EventKey `json:"eventKey"` + Date ISOTime `json:"date"` + Actor User `json:"actor"` + Repository Repository `json:"repository"` + Changes []RepositoryPushEventChange `json:"changes"` + Commits []Commit `json:"commits"` + ToCommit Commit `json:"toCommit"` +} + +type RepositoryPushEventChange struct { + Ref RepositoryPushEventRef `json:"ref"` + RefId string `json:"refId"` + FromHash string `json:"fromHash"` + ToHash string `json:"toHash"` + Type RepositoryPushEventChangeType `json:"type"` +} + +type RepositoryPushEventChangeType string + +const ( + RepositoryPushEventChangeTypeAdd RepositoryPushEventChangeType = "ADD" + RepositoryPushEventChangeTypeUpdate RepositoryPushEventChangeType = "UPDATE" + RepositoryPushEventChangeTypeDelete RepositoryPushEventChangeType = "DELETE" +) + +type RepositoryPushEventRef struct { + ID string `json:"id"` + DisplayID string `json:"displayId"` + Type RepositoryPushEventRefType `json:"type"` +} + +type RepositoryPushEventRefType string + +const ( + RepositoryPushEventRefTypeBranch RepositoryPushEventRefType = "BRANCH" + RepositoryPushEventRefTypeTag RepositoryPushEventRefType = "TAG" +) + +type PullRequestEvent struct { + EventKey EventKey `json:"eventKey"` + Date ISOTime `json:"date"` + Actor User `json:"actor"` + PullRequest PullRequest `json:"pullRequest"` +} + +type EventKey string + +const ( + EventKeyRepoRefsChanged EventKey = "repo:refs_changed" // Repo push + EventKeyRepoModified EventKey = "repo:modified" // Repo changed (name) + EventKeyRepoFork EventKey = "repo:fork" // Repo forked + EventKeyCommentAdded EventKey = "repo:comment:added" // Repo comment on commit added + EventKeyCommentEdited EventKey = "repo:comment:edited" // Repo comment on commit edited + EventKeyCommentDeleted EventKey = "repo:comment:deleted" // Repo comment on commit deleted + EventKeyPullRequestOpened EventKey = "pr:opened" // Pull request opened + EventKeyPullRequestFrom EventKey = "pr:from_ref_updated" // Pull request source ref updated + EventKeyPullRequestTo EventKey = "pr:to_ref_updated" // Pull request target ref updated + EventkeyPullRequestModified EventKey = "pr:modified" // Pull request modified (title, description, target) + EventKeyPullRequestReviewer EventKey = "pr:reviewer:updated" // Pull request reviewers updated + EventKeyPullRequestApproved EventKey = "pr:reviewer:approved" // Pull request approved by reviewer + EventKeyPullRequestUnapproved EventKey = "pr:reviewer:unapproved" // Pull request approval withdrawn by reviewer + EventKeyPullRequestNeedsWork EventKey = "pr:reviewer:needs_work" // Pull request reviewer marked "needs work" + EventKeyPullRequestMerged EventKey = "pr:merged" + EventKeyPullRequestDeclined EventKey = "pr:declined" + EventKeyPullRequestDeleted EventKey = "pr:deleted" + EventKeyPullRequestCommentAdded EventKey = "pr:comment:added" + EventKeyPullRequestCommentEdited EventKey = "pr:comment:edited" + EventKeyPullRequestCommentDeleted EventKey = "pr:comment:deleted" +) diff --git a/bitbucket/events_test.go b/bitbucket/events_test.go new file mode 100644 index 0000000..d7f4d03 --- /dev/null +++ b/bitbucket/events_test.go @@ -0,0 +1,405 @@ +package bitbucket + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseRepoPushEvent(t *testing.T) { + ts, _ := time.Parse(time.RFC3339, "2023-01-13T22:26:25+11:00") + + var ev RepositoryPushEvent + err := json.Unmarshal([]byte(repoPushEvent), &ev) + assert.NoError(t, err) + assert.Equal(t, EventKeyRepoRefsChanged, ev.EventKey) + assert.Equal(t, ISOTime(ts), ev.Date) + assert.Equal(t, "rep_1", ev.Repository.Slug) + assert.Equal(t, "a00945762949b7b787ecabc388c0e20b1b85f0b4", ev.Commits[0].ID) + assert.Equal(t, "My commit message", ev.Commits[0].Message) + assert.Equal(t, "197a3e0d2f9a2b3ed1c4fe5923d5dd701bee9fdd", ev.Commits[0].Parents[0].ID) + assert.Equal(t, "a00945762949b7b787ecabc388c0e20b1b85f0b4", ev.Commits[0].ID) + assert.Equal(t, "a00945762949b7b787ecabc388c0e20b1b85f0b4", ev.ToCommit.ID) + assert.Len(t, ev.Changes, 1) + assert.Equal(t, RepositoryPushEventChangeTypeUpdate, ev.Changes[0].Type) + assert.Equal(t, RepositoryPushEventRefTypeBranch, ev.Changes[0].Ref.Type) +} + +func TestParsePROpenedEvent(t *testing.T) { + var ev PullRequestEvent + err := json.Unmarshal([]byte(prOpened), &ev) + assert.NoError(t, err) + assert.Equal(t, "ef8755f06ee4b28c96a847a95cb8ec8ed6ddd1ca", ev.PullRequest.Source.Latest) + assert.Equal(t, "178864a7d521b6f5e720b386b2c2b0ef8563e0dc", ev.PullRequest.Target.Latest) + assert.Equal(t, "admin", ev.PullRequest.Author.Author.Name) +} + +func TestParsePRSourceChangedEvent(t *testing.T) { + var ev PullRequestEvent + err := json.Unmarshal([]byte(prSourceChange), &ev) + assert.NoError(t, err) + assert.Equal(t, "aab847db240ccae221f8036605b00f777eba95d2", ev.PullRequest.Source.Latest) + assert.Equal(t, "86448735f9dee9e1fb3d3e5cd9fbc8eb9d8400f4", ev.PullRequest.Target.Latest) + assert.Equal(t, "admin", ev.PullRequest.Author.Author.Name) +} + +const repoPushEvent = `{ + "eventKey": "repo:refs_changed", + "date": "2023-01-13T22:26:25+1100", + "actor": { + "name": "admin", + "emailAddress": "admin@example.com", + "active": true, + "displayName": "Administrator", + "id": 2, + "slug": "admin", + "type": "NORMAL" + }, + "repository": { + "slug": "rep_1", + "id": 1, + "name": "rep_1", + "hierarchyId": "af05451fc6eb4bf4e0bd", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "PROJECT_1", + "id": 1, + "name": "Project 1", + "description": "PROJECT_1", + "public": false, + "type": "NORMAL" + }, + "public": false, + "archived": false + }, + "changes": [ + { + "ref": { + "id": "refs/heads/master", + "displayId": "master", + "type": "BRANCH" + }, + "refId": "refs/heads/master", + "fromHash": "197a3e0d2f9a2b3ed1c4fe5923d5dd701bee9fdd", + "toHash": "a00945762949b7b787ecabc388c0e20b1b85f0b4", + "type": "UPDATE" + } + ], + "commits": [ + { + "id": "a00945762949b7b787ecabc388c0e20b1b85f0b4", + "displayId": "a0094576294", + "author": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "authorTimestamp": 1673403328000, + "committer": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "committerTimestamp": 1673403328000, + "message": "My commit message", + "parents": [ + { + "id": "197a3e0d2f9a2b3ed1c4fe5923d5dd701bee9fdd", + "displayId": "197a3e0d2f9" + } + ] + } + ], + "toCommit": { + "id": "a00945762949b7b787ecabc388c0e20b1b85f0b4", + "displayId": "a0094576294", + "author": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "authorTimestamp": 1673403328000, + "committer": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "committerTimestamp": 1673403328000, + "message": "My commit message", + "parents": [ + { + "id": "197a3e0d2f9a2b3ed1c4fe5923d5dd701bee9fdd", + "displayId": "197a3e0d2f9", + "author": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "authorTimestamp": 1673403292000, + "committer": { + "name": "Administrator", + "emailAddress": "admin@example.com" + }, + "committerTimestamp": 1673403292000, + "message": "My commit message", + "parents": [ + { + "id": "f870ce6bf6fe633e1a2bbe655970bde25535669f", + "displayId": "f870ce6bf6f" + } + ] + } + ] + } +}` + +const prOpened = `{ + "eventKey":"pr:opened", + "date":"2017-09-19T09:58:11+1000", + "actor":{ + "name":"admin", + "emailAddress":"admin@example.com", + "id":1, + "displayName":"Administrator", + "active":true, + "slug":"admin", + "type":"NORMAL" + }, + "pullRequest":{ + "id":1, + "version":0, + "title":"a new file added", + "state":"OPEN", + "open":true, + "closed":false, + "createdDate":1505779091796, + "updatedDate":1505779091796, + "fromRef":{ + "id":"refs/heads/a-branch", + "displayId":"a-branch", + "latestCommit":"ef8755f06ee4b28c96a847a95cb8ec8ed6ddd1ca", + "repository":{ + "slug":"repository", + "id":84, + "name":"repository", + "scmId":"git", + "state":"AVAILABLE", + "statusMessage":"Available", + "forkable":true, + "project":{ + "key":"PROJ", + "id":84, + "name":"project", + "public":false, + "type":"NORMAL" + }, + "public":false + } + }, + "toRef":{ + "id":"refs/heads/master", + "displayId":"master", + "latestCommit":"178864a7d521b6f5e720b386b2c2b0ef8563e0dc", + "repository":{ + "slug":"repository", + "id":84, + "name":"repository", + "scmId":"git", + "state":"AVAILABLE", + "statusMessage":"Available", + "forkable":true, + "project":{ + "key":"PROJ", + "id":84, + "name":"project", + "public":false, + "type":"NORMAL" + }, + "public":false + } + }, + "locked":false, + "author":{ + "user":{ + "name":"admin", + "emailAddress":"admin@example.com", + "id":1, + "displayName":"Administrator", + "active":true, + "slug":"admin", + "type":"NORMAL" + }, + "role":"AUTHOR", + "approved":false, + "status":"UNAPPROVED" + }, + "reviewers":[ + + ], + "participants":[ + + ], + "links":{ + "self":[ + null + ] + } + } + }` + +const prSourceChange = `{ + "eventKey": "pr:from_ref_updated", + "date": "2020-02-20T14:49:41+1100", + "actor": { + "name": "admin", + "emailAddress": "admin@example.com", + "id": 1, + "displayName": "Administrator", + "active": true, + "slug": "admin", + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/bitbucket/users/admin" + } + ] + } + }, + "pullRequest": { + "id": 2, + "version": 16, + "title": "Webhook", + "state": "OPEN", + "open": true, + "closed": false, + "createdDate": 1582065825700, + "updatedDate": 1582170581372, + "fromRef": { + "id": "refs/heads/pr-webhook", + "displayId": "pr-webhook", + "latestCommit": "aab847db240ccae221f8036605b00f777eba95d2", + "repository": { + "slug": "dvcs", + "id": 33, + "name": "dvcs", + "hierarchyId": "09992c6ad9e001f01120", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "GIT", + "id": 62, + "name": "Bitbucket", + "public": false, + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/bitbucket/projects/GIT" + } + ] + } + }, + "public": false, + "links": { + "clone": [ + { + "href": "ssh://git@localhost:7999/git/dvcs.git", + "name": "ssh" + }, + { + "href": "http://localhost:7990/bitbucket/scm/git/dvcs.git", + "name": "http" + } + ], + "self": [ + { + "href": "http://localhost:7990/bitbucket/projects/GIT/repos/dvcs/browse" + } + ] + } + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "86448735f9dee9e1fb3d3e5cd9fbc8eb9d8400f4", + "repository": { + "slug": "dvcs", + "id": 33, + "name": "dvcs", + "hierarchyId": "09992c6ad9e001f01120", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "GIT", + "id": 62, + "name": "Bitbucket", + "public": false, + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/bitbucket/projects/GIT" + } + ] + } + }, + "public": false, + "links": { + "clone": [ + { + "href": "ssh://git@localhost:7999/git/dvcs.git", + "name": "ssh" + }, + { + "href": "http://localhost:7990/bitbucket/scm/git/dvcs.git", + "name": "http" + } + ], + "self": [ + { + "href": "http://localhost:7990/bitbucket/projects/GIT/repos/dvcs/browse" + } + ] + } + } + }, + "locked": false, + "author": { + "user": { + "name": "admin", + "emailAddress": "admin@example.com", + "id": 1, + "displayName": "Administrator", + "active": true, + "slug": "admin", + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/bitbucket/users/admin" + } + ] + } + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [], + "participants": [], + "links": { + "self": [ + { + "href": "http://localhost:7990/bitbucket/projects/GIT/repos/dvcs/pull-requests/2" + } + ] + } + }, + "previousFromHash": "99f3ea32043ba3ecaa28de6046b420de70257d80" + }` diff --git a/bitbucket/projects_repos_branches.go b/bitbucket/projects_repos_branches.go new file mode 100644 index 0000000..68c79d4 --- /dev/null +++ b/bitbucket/projects_repos_branches.go @@ -0,0 +1,57 @@ +package bitbucket + +import ( + "context" + "fmt" +) + +type BranchList struct { + ListResponse + + Branches []*Branch `json:"values"` +} + +type Branch struct { + ID string `json:"id"` + DisplayID string `json:"displayId"` + Type BranchType `json:"type"` + LatestCommit string `json:"latestCommit"` + LatestChangeset string `json:"latestChangeset"` + Default bool `json:"isDefault"` +} + +type BranchType string + +type BranchSearchOptions struct { + ListOptions + + Filter string `url:"filterText,omitempty"` + Order BranchSearchOrder `url:"orderBy,omitempty"` +} + +type BranchSearchOrder string + +const ( + BranchSearchOrderAlpha BranchSearchOrder = "ALPHABETICAL" + BranchSearchOrderModified BranchSearchOrder = "MODIFICATION" +) + +func (s *ProjectsService) SearchBranches(ctx context.Context, projectKey, repositorySlug string, opts *BranchSearchOptions) ([]*Branch, *Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/branches", projectKey, repositorySlug) + var l BranchList + resp, err := s.client.GetPaged(ctx, projectsApiName, p, &l, opts) + if err != nil { + return nil, resp, err + } + return l.Branches, resp, nil +} + +func (s *ProjectsService) GetDefaultBranch(ctx context.Context, projectKey, repositorySlug string) (*Branch, *Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/branches/default", projectKey, repositorySlug) + var b Branch + resp, err := s.client.Get(ctx, projectsApiName, p, &b) + if err != nil { + return nil, resp, err + } + return &b, resp, nil +} diff --git a/bitbucket/projects_repos_branches_test.go b/bitbucket/projects_repos_branches_test.go new file mode 100644 index 0000000..2fe3989 --- /dev/null +++ b/bitbucket/projects_repos_branches_test.go @@ -0,0 +1,269 @@ +package bitbucket + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSearchBranches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/api/latest/projects/KEY/repos/repo/branches", req.URL.Path) + assert.Equal(t, "update", req.URL.Query().Get("filterText")) + + rw.Write([]byte(searchBranchesResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + branches, resp, err := client.Projects.SearchBranches(ctx, "KEY", "repo", &BranchSearchOptions{Filter: "update"}) + assert.NoError(t, err) + assert.Len(t, branches, 25) + assert.False(t, resp.LastPage) + assert.Equal(t, uint(25), resp.Page.NextPageStart) + assert.Equal(t, "refs/heads/upgrade/cert-manager-upgrade-v1.10.2", branches[0].ID) + assert.Equal(t, "upgrade/cert-manager-upgrade-v1.10.2", branches[0].DisplayID) +} + +func TestGetDefaultBranch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/api/latest/projects/PRJ/repos/repo/branches/default", req.URL.Path) + rw.Write([]byte(getDefaultBranchResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + branch, _, err := client.Projects.GetDefaultBranch(ctx, "PRJ", "repo") + assert.NoError(t, err) + assert.NotNil(t, branch) + assert.Equal(t, "refs/heads/main", branch.ID) + assert.Equal(t, "main", branch.DisplayID) + assert.Equal(t, "b2f97dd9d05e82b1e5308bcffbb5c013065dfeb2", branch.LatestCommit) + assert.True(t, branch.Default) +} + +const searchBranchesResponse = `{ + "size": 25, + "limit": 25, + "isLastPage": false, + "values": [ + { + "id": "refs/heads/upgrade/cert-manager-upgrade-v1.10.2", + "displayId": "upgrade/cert-manager-upgrade-v1.10.2", + "type": "BRANCH", + "latestCommit": "ae25676b718886fcc0d14fedc5ef72d1b0762c63", + "latestChangeset": "ae25676b718886fcc0d14fedc5ef72d1b0762c63", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/cert-manager-upgrade-v1.11.1", + "displayId": "upgrade/cert-manager-upgrade-v1.11.1", + "type": "BRANCH", + "latestCommit": "2f593f67a3115b2bdbdbc2cdbfe15b7d2796b0d4", + "latestChangeset": "2f593f67a3115b2bdbdbc2cdbfe15b7d2796b0d4", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-10.1.4", + "displayId": "upgrade/contour-upgrade-10.1.4", + "type": "BRANCH", + "latestCommit": "aec64e0ddf63be3a62a1b001a96f87fe2f093a2f", + "latestChangeset": "aec64e0ddf63be3a62a1b001a96f87fe2f093a2f", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-10.1.5", + "displayId": "upgrade/contour-upgrade-10.1.5", + "type": "BRANCH", + "latestCommit": "6fa13ceae3f1e6b436eb4a94fb5b47949d6813ec", + "latestChangeset": "6fa13ceae3f1e6b436eb4a94fb5b47949d6813ec", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-10.2.0", + "displayId": "upgrade/contour-upgrade-10.2.0", + "type": "BRANCH", + "latestCommit": "1b14764f352b09b3267221b364726887b6b18354", + "latestChangeset": "1b14764f352b09b3267221b364726887b6b18354", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-10.2.2", + "displayId": "upgrade/contour-upgrade-10.2.2", + "type": "BRANCH", + "latestCommit": "aa4d4dde2865b27e26e2622594d20a11da05f82a", + "latestChangeset": "aa4d4dde2865b27e26e2622594d20a11da05f82a", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.0.0", + "displayId": "upgrade/contour-upgrade-11.0.0", + "type": "BRANCH", + "latestCommit": "d3ade4cd65a0c42304c3482e1ef98a6c95c278b1", + "latestChangeset": "d3ade4cd65a0c42304c3482e1ef98a6c95c278b1", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.0.1", + "displayId": "upgrade/contour-upgrade-11.0.1", + "type": "BRANCH", + "latestCommit": "7b2f2bbf9111b11180dc7ddae53aa97fc8818845", + "latestChangeset": "7b2f2bbf9111b11180dc7ddae53aa97fc8818845", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.0.2", + "displayId": "upgrade/contour-upgrade-11.0.2", + "type": "BRANCH", + "latestCommit": "cfe52f22f6d37632f875771da9dbbd78da8a4c56", + "latestChangeset": "cfe52f22f6d37632f875771da9dbbd78da8a4c56", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.0.3", + "displayId": "upgrade/contour-upgrade-11.0.3", + "type": "BRANCH", + "latestCommit": "a39c8dbb723cede1854de6b05f88ac357c9537b7", + "latestChangeset": "a39c8dbb723cede1854de6b05f88ac357c9537b7", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.0.4", + "displayId": "upgrade/contour-upgrade-11.0.4", + "type": "BRANCH", + "latestCommit": "eafc8266c59513bf578dae3456ae78965202c42d", + "latestChangeset": "eafc8266c59513bf578dae3456ae78965202c42d", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.1.0", + "displayId": "upgrade/contour-upgrade-11.1.0", + "type": "BRANCH", + "latestCommit": "aad635d787f83a3079c1c516cc9fa3e5d81035d9", + "latestChangeset": "aad635d787f83a3079c1c516cc9fa3e5d81035d9", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.1.1", + "displayId": "upgrade/contour-upgrade-11.1.1", + "type": "BRANCH", + "latestCommit": "d9ae8522bc71070f71d9ea2f9cf0eb5bb30c1e57", + "latestChangeset": "d9ae8522bc71070f71d9ea2f9cf0eb5bb30c1e57", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.1.2", + "displayId": "upgrade/contour-upgrade-11.1.2", + "type": "BRANCH", + "latestCommit": "9f42af0cd0dd782b776891c32c6bda58a1489792", + "latestChangeset": "9f42af0cd0dd782b776891c32c6bda58a1489792", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.1.3", + "displayId": "upgrade/contour-upgrade-11.1.3", + "type": "BRANCH", + "latestCommit": "981dd5bd4dad1ce8709358cd890cd79f271db90c", + "latestChangeset": "981dd5bd4dad1ce8709358cd890cd79f271db90c", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/contour-upgrade-11.3.0", + "displayId": "upgrade/contour-upgrade-11.3.0", + "type": "BRANCH", + "latestCommit": "ffb23a874d4a9a49efbf4526769b271bea3b7cae", + "latestChangeset": "ffb23a874d4a9a49efbf4526769b271bea3b7cae", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.5.7", + "displayId": "upgrade/external-secrets-upgrade-0.5.7", + "type": "BRANCH", + "latestCommit": "dd08d6e495b6bb7c17fe461038c7e6a7fc1ecc83", + "latestChangeset": "dd08d6e495b6bb7c17fe461038c7e6a7fc1ecc83", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.5.8", + "displayId": "upgrade/external-secrets-upgrade-0.5.8", + "type": "BRANCH", + "latestCommit": "62cdc55c0999e2f1510b7ab546833df7dc93fba7", + "latestChangeset": "62cdc55c0999e2f1510b7ab546833df7dc93fba7", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.5.9", + "displayId": "upgrade/external-secrets-upgrade-0.5.9", + "type": "BRANCH", + "latestCommit": "7a257ecca24d22f6faeb7bfb957906da72619d29", + "latestChangeset": "7a257ecca24d22f6faeb7bfb957906da72619d29", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.6.0", + "displayId": "upgrade/external-secrets-upgrade-0.6.0", + "type": "BRANCH", + "latestCommit": "5e4971e93aedd530b8291b5c909e57ccedf06127", + "latestChangeset": "5e4971e93aedd530b8291b5c909e57ccedf06127", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.6.1", + "displayId": "upgrade/external-secrets-upgrade-0.6.1", + "type": "BRANCH", + "latestCommit": "c3962f1ef504ba19d7c9e5b84bbe09b504a1638b", + "latestChangeset": "c3962f1ef504ba19d7c9e5b84bbe09b504a1638b", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.7.0", + "displayId": "upgrade/external-secrets-upgrade-0.7.0", + "type": "BRANCH", + "latestCommit": "05c2cd9b57959ed25d330f8f5419aa1ce91b4f77", + "latestChangeset": "05c2cd9b57959ed25d330f8f5419aa1ce91b4f77", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.7.1", + "displayId": "upgrade/external-secrets-upgrade-0.7.1", + "type": "BRANCH", + "latestCommit": "821fd66273cd80b7abaa9e248baf12d5cee8e7b6", + "latestChangeset": "821fd66273cd80b7abaa9e248baf12d5cee8e7b6", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.8.0", + "displayId": "upgrade/external-secrets-upgrade-0.8.0", + "type": "BRANCH", + "latestCommit": "7d295860e6f68213efda935c6647866af97561d2", + "latestChangeset": "7d295860e6f68213efda935c6647866af97561d2", + "isDefault": false + }, + { + "id": "refs/heads/upgrade/external-secrets-upgrade-0.8.1", + "displayId": "upgrade/external-secrets-upgrade-0.8.1", + "type": "BRANCH", + "latestCommit": "536e0a6499b97a4d60dfade62dd178f366438c47", + "latestChangeset": "536e0a6499b97a4d60dfade62dd178f366438c47", + "isDefault": false + } + ], + "start": 0, + "nextPageStart": 25 + }` + +const getDefaultBranchResponse = `{ + "id": "refs/heads/main", + "displayId": "main", + "type": "BRANCH", + "latestCommit": "b2f97dd9d05e82b1e5308bcffbb5c013065dfeb2", + "latestChangeset": "b2f97dd9d05e82b1e5308bcffbb5c013065dfeb2", + "isDefault": true + }` diff --git a/bitbucket/projects_repos_commits_builds.go b/bitbucket/projects_repos_commits_builds.go new file mode 100644 index 0000000..616c6ef --- /dev/null +++ b/bitbucket/projects_repos_commits_builds.go @@ -0,0 +1,48 @@ +package bitbucket + +import ( + "context" + "fmt" +) + +type BuildStatus struct { + Key string `json:"key"` + State BuildStatusState `json:"state"` + URL string `json:"url"` + BuildNumber string `json:"buildNumber,omitempty"` + Description string `json:"description,omitempty"` + Duration uint64 `json:"duration,omitempty"` + Ref string `json:"ref,omitempty"` + TestResult *BuildStatusTestResult `json:"testResults,omitempty"` +} + +type BuildStatusTestResult struct { + Failed uint32 `json:"failed"` + Skipped uint32 `json:"skipped"` + Successful uint32 `json:"successful"` +} + +type BuildStatusState string + +const ( + BuildStatusStateCancelled BuildStatusState = "CANCELLED" + BuildStatusStateFailed BuildStatusState = "FAILED" + BuildStatusStateInProgress BuildStatusState = "INPROGRESS" + BuildStatusStateSuccessful BuildStatusState = "SUCCESSFUL" + BuildStatusStateUnknown BuildStatusState = "UNKNOWN" +) + +func (s *ProjectsService) CreateBuildStatus(ctx context.Context, projectKey, repositorySlug, commitId string, status *BuildStatus) (*Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/commits/%s/builds", projectKey, repositorySlug, commitId) + req, err := s.client.NewRequest("POST", projectsApiName, p, status) + if err != nil { + return nil, err + } + + var r Repository + resp, err := s.client.Do(ctx, req, &r) + if err != nil { + return resp, err + } + return resp, nil +} diff --git a/bitbucket/projects_repos_commits_builds_test.go b/bitbucket/projects_repos_commits_builds_test.go new file mode 100644 index 0000000..e3b4d96 --- /dev/null +++ b/bitbucket/projects_repos_commits_builds_test.go @@ -0,0 +1,35 @@ +package bitbucket + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateBuildStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "POST", req.Method) + b, _ := io.ReadAll(req.Body) + assert.Equal(t, "{\"key\":\"BUILD-ID\",\"state\":\"INPROGRESS\",\"url\":\"https://ci.domain.com/builds/BUILD-ID\",\"buildNumber\":\"number\",\"duration\":10000,\"ref\":\"refs/head\"}\n", string(b)) + assert.Equal(t, "/api/latest/projects/PRJ/repos/repo/commits/commit/builds", req.URL.Path) + rw.Write([]byte(getWebhookResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + in := &BuildStatus{ + Key: "BUILD-ID", + State: BuildStatusStateInProgress, + URL: "https://ci.domain.com/builds/BUILD-ID", + BuildNumber: "number", + Duration: 10000, + Ref: "refs/head", + } + _, err := client.Projects.CreateBuildStatus(ctx, "PRJ", "repo", "commit", in) + assert.NoError(t, err) +} diff --git a/bitbucket/projects_repos_prs.go b/bitbucket/projects_repos_prs.go new file mode 100644 index 0000000..14c995b --- /dev/null +++ b/bitbucket/projects_repos_prs.go @@ -0,0 +1,57 @@ +package bitbucket + +type PullRequest struct { + ID uint64 `json:"id,omitempty"` + Version uint64 `json:"version,omitempty"` + Title string `json:"title"` + State PullRequestState `json:"state"` + Open bool `json:"open"` + Closed bool `json:"closed"` + Created *DateTime `json:"createdDate"` + Updated *DateTime `json:"updatedDate"` + Source PullRequestRef `json:"fromRef"` + Target PullRequestRef `json:"toRef"` + Locked bool `json:"locked"` + Author PullRequestParticipant `json:"author"` + Reviewers []PullRequestParticipant `json:"reviewers"` + Participants []PullRequestParticipant `json:"participants"` +} + +type PullRequestRef struct { + ID string `json:"id"` + DisplayID string `json:"displayId"` + Latest string `json:"latestCommit"` + Repository Repository `json:"repository"` +} + +type PullRequestParticipant struct { + Author User `json:"user"` + Role PullRequestAuthorRole `json:"role"` + Approved bool `json:"approved"` + Status PullRequestAuthorStatus `json:"status"` + Commit string `json:"lastReviewedCommit,omitempty"` +} + +type PullRequestState string + +const ( + PullRequestStateDeclined PullRequestState = "DECLINED" + PullRequestStateMerged PullRequestState = "MERGED" + PullRequestStateOpen PullRequestState = "MERGED" +) + +type PullRequestAuthorRole string + +const ( + PullRequestAuthorRoleAuthor PullRequestAuthorRole = "AUTHOR" + PullRequestAuthorRoleReviewer PullRequestAuthorRole = "REVIEWER" + PullRequestAuthorRoleParticipant PullRequestAuthorRole = "PARTICIPANT" +) + +type PullRequestAuthorStatus string + +const ( + PullRequestAuthorStatusApproved PullRequestAuthorStatus = "APPROVED" + PullRequestAuthorStatusUnapproved PullRequestAuthorStatus = "UNAPPROVED" + PullRequestAuthorStatusNeedsWork PullRequestAuthorStatus = "NEEDS_WORK" +) diff --git a/bitbucket/projects_repos_webhooks.go b/bitbucket/projects_repos_webhooks.go new file mode 100644 index 0000000..d0e641f --- /dev/null +++ b/bitbucket/projects_repos_webhooks.go @@ -0,0 +1,70 @@ +package bitbucket + +import ( + "context" + "fmt" +) + +type WebhookList struct { + ListResponse + Webhooks []*Webhook `json:"values"` +} + +type Webhook struct { + ID uint64 `json:"id,omitempty"` + Name string `json:"name"` + Created *DateTime `json:"createdDate,omitempty"` + Updated *DateTime `json:"updatedDate,omitempty"` + Events []EventKey `json:"events"` + Config *WebhookConfiguration `json:"configuration,omitempty"` + URL string `json:"url"` + Active bool `json:"active"` +} + +type WebhookConfiguration struct { + Secret string `json:"secret,omitempty"` +} + +func (s *ProjectsService) ListWebhooks(ctx context.Context, projectKey, repositorySlug string, opts *ListOptions) ([]*Webhook, *Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/webhooks", projectKey, repositorySlug) + var l WebhookList + resp, err := s.client.GetPaged(ctx, projectsApiName, p, &l, opts) + if err != nil { + return nil, resp, err + } + return l.Webhooks, resp, nil +} + +func (s *ProjectsService) GetWebhook(ctx context.Context, projectKey, repositorySlug string, id uint64) (*Webhook, *Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/webhooks/%d", projectKey, repositorySlug, id) + var w Webhook + resp, err := s.client.Get(ctx, projectsApiName, p, &w) + if err != nil { + return nil, resp, err + } + return &w, resp, nil +} + +func (s *ProjectsService) CreateWebhook(ctx context.Context, projectKey, repositorySlug string, webhook *Webhook) (*Webhook, *Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/webhooks", projectKey, repositorySlug) + req, err := s.client.NewRequest("POST", projectsApiName, p, webhook) + if err != nil { + return nil, nil, err + } + + var w Webhook + resp, err := s.client.Do(ctx, req, &w) + if err != nil { + return nil, resp, err + } + return &w, resp, nil +} + +func (s *ProjectsService) DeleteWebhook(ctx context.Context, projectKey, repositorySlug string, id uint64) (*Response, error) { + p := fmt.Sprintf("projects/%s/repos/%s/webhooks/%d", projectKey, repositorySlug, id) + req, err := s.client.NewRequest("DELETE", projectsApiName, p, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} diff --git a/bitbucket/projects_repos_webhooks_test.go b/bitbucket/projects_repos_webhooks_test.go new file mode 100644 index 0000000..d72f581 --- /dev/null +++ b/bitbucket/projects_repos_webhooks_test.go @@ -0,0 +1,118 @@ +package bitbucket + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListWebhooks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/api/latest/projects/PRJ/repos/repo/webhooks", req.URL.Path) + rw.Write([]byte(listWebhooksResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + hooks, _, err := client.Projects.ListWebhooks(ctx, "PRJ", "repo", &ListOptions{}) + assert.NoError(t, err) + assert.NotNil(t, hooks) + assert.Len(t, hooks, 1) + assert.Len(t, hooks[0].Events, 7) +} + +func TestGetWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/api/latest/projects/PRJ/repos/repo/webhooks/10", req.URL.Path) + rw.Write([]byte(getWebhookResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + hook, _, err := client.Projects.GetWebhook(ctx, "PRJ", "repo", 10) + assert.NoError(t, err) + assert.NotNil(t, hook) + assert.Len(t, hook.Events, 7) +} + +func TestCreateWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "POST", req.Method) + b, _ := io.ReadAll(req.Body) + assert.Equal(t, "{\"name\":\"go-bitbucket-demo\",\"events\":[\"repo:refs_changed\"],\"url\":\"https://domain.com/webhook\",\"active\":true}\n", string(b)) + assert.Equal(t, "/api/latest/projects/PRJ/repos/repo/webhooks", req.URL.Path) + rw.Write([]byte(getWebhookResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + in := &Webhook{ + Name: "go-bitbucket-demo", + Events: []EventKey{EventKeyRepoRefsChanged}, + URL: "https://domain.com/webhook", + Active: true, + } + repo, _, err := client.Projects.CreateWebhook(ctx, "PRJ", "repo", in) + assert.NoError(t, err) + assert.NotNil(t, repo) + // Response is same as "get" +} + +const listWebhooksResponse = `{ + "size": 1, + "limit": 25, + "isLastPage": true, + "values": [ + { + "id": 10, + "name": "drone", + "createdDate": 1682407778168, + "updatedDate": 1682407778168, + "events": [ + "pr:merged", + "pr:modified", + "pr:opened", + "repo:refs_changed", + "pr:declined", + "pr:deleted", + "pr:from_ref_updated" + ], + "configuration": { + "secret": "1234567890abcdefghjikl" + }, + "url": "https://ci.domain.com/hook", + "active": true + } + ], + "start": 0 + }` + +const getWebhookResponse = `{ + "id": 10, + "name": "drone", + "createdDate": 1682408715521, + "updatedDate": 1682408715521, + "events": [ + "pr:merged", + "pr:modified", + "pr:opened", + "repo:refs_changed", + "pr:declined", + "pr:deleted", + "pr:from_ref_updated" + ], + "configuration": { + "secret": "1234567890abcdefghjikl" + }, + "url": "https://ci.domain.com/hook", + "active": true + }` diff --git a/bitbucket/users.go b/bitbucket/users.go new file mode 100644 index 0000000..c5299a5 --- /dev/null +++ b/bitbucket/users.go @@ -0,0 +1,37 @@ +package bitbucket + +import ( + "context" + "fmt" +) + +type UsersService service + +const usersApiName = "api" + +type User struct { + ID uint64 `json:"id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` + Active bool `json:"active"` + DisplayName string `json:"displayName"` + Email string `json:"emailAddress,omitempty"` + Type UserType `json:"type,omitempty"` +} + +type UserType string + +const ( + UserTypeNormal UserType = "NORMAL" + UserTypeService UserType = "SERVICE" +) + +func (s *UsersService) GetUser(ctx context.Context, userSlug string) (*User, *Response, error) { + p := fmt.Sprintf("users/%s", userSlug) + var u User + resp, err := s.client.Get(ctx, usersApiName, p, &u) + if err != nil { + return nil, resp, err + } + return &u, resp, nil +} diff --git a/bitbucket/users_test.go b/bitbucket/users_test.go new file mode 100644 index 0000000..b15ba95 --- /dev/null +++ b/bitbucket/users_test.go @@ -0,0 +1,39 @@ +package bitbucket + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/api/latest/users/api-user", req.URL.Path) + rw.Write([]byte(getUserResponse)) + })) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + ctx := context.Background() + user, _, err := client.Users.GetUser(ctx, "api-user") + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, uint64(12910), user.ID) + assert.Equal(t, "apiuser", user.Slug) + assert.Equal(t, "api-user", user.Name) + assert.Equal(t, UserTypeNormal, user.Type) +} + +const getUserResponse = `{ + "name": "api-user", + "emailAddress": "api-user@e.mail", + "active": true, + "displayName": "Api User", + "id": 12910, + "slug": "apiuser", + "type": "NORMAL" +}` diff --git a/bitbucket/webhook.go b/bitbucket/webhook.go new file mode 100644 index 0000000..85021ec --- /dev/null +++ b/bitbucket/webhook.go @@ -0,0 +1,84 @@ +package bitbucket + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + EventIDHeader = "X-Request-Id" + EventKeyHeader = "X-Event-Key" + EventSignatureHeader = "X-Hub-Signature" +) + +const maxPayloadSize = 10 * 1024 * 1024 // 10 MiB + +func ParsePayload(r *http.Request, key []byte) (interface{}, error) { + p, err := validateSignature(r, key) + if err != nil { + return nil, err + } + + evk := r.Header.Get(EventKeyHeader) + if evk == "" { + return nil, fmt.Errorf("unable find event key in request") + } + k := EventKey(evk) + var event interface{} + switch k { + case EventKeyRepoRefsChanged: + event = &RepositoryPushEvent{} + case EventKeyPullRequestOpened, EventKeyPullRequestFrom, EventkeyPullRequestModified, EventKeyPullRequestDeclined, EventKeyPullRequestDeleted, EventKeyPullRequestMerged: + event = &PullRequestEvent{} + default: + return nil, fmt.Errorf("event type not supported: %s", k) + } + + err = json.Unmarshal(p, event) + if err != nil { + return nil, fmt.Errorf("unable to parse event payload: %w", err) + } + + return event, nil +} + +func validateSignature(r *http.Request, key []byte) ([]byte, error) { + sig := r.Header.Get(EventSignatureHeader) + if sig == "" { + return nil, fmt.Errorf("no signature found") + } + + payload, err := io.ReadAll(io.LimitReader(r.Body, maxPayloadSize)) + if err != nil { + return nil, fmt.Errorf("unable to parse payload: %w", err) + } + + sp := strings.Split(sig, "=") + if len(sp) != 2 { + return nil, fmt.Errorf("signatur format invalid") + } + + if sp[0] != "sha256" { + return nil, fmt.Errorf("unsupported hash algorithm: %s", sp[0]) + } + + sd, err := hex.DecodeString(sp[1]) + if err != nil { + return nil, fmt.Errorf("unable to parse signature data: %w", err) + } + + h := hmac.New(sha256.New, key) + h.Write([]byte(payload)) + + if !hmac.Equal(h.Sum(nil), sd) { + return nil, fmt.Errorf("signature does not match") + } + + return payload, nil +} diff --git a/bitbucket/webhook_test.go b/bitbucket/webhook_test.go new file mode 100644 index 0000000..ba66696 --- /dev/null +++ b/bitbucket/webhook_test.go @@ -0,0 +1,33 @@ +package bitbucket + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWebhook(t *testing.T) { + secretKey := []byte("0123456789abcdef") + + buf := bytes.NewBufferString(repoPushEvent) + req, err := http.NewRequest("POST", "http://server.io/webhook", buf) + req.Header.Add(EventSignatureHeader, "sha256=d82c0422a140fc24335536d9450538aeaa978dbc741262a161ee12b99a6bf05d") + req.Header.Add(EventKeyHeader, "repo:refs_changed") + assert.NoError(t, err) + + ev, err := ParsePayload(req, secretKey) + assert.NoError(t, err) + assert.NotNil(t, ev) + + repoEv, ok := ev.(*RepositoryPushEvent) + assert.True(t, ok) + assert.Equal(t, "rep_1", repoEv.Repository.Slug) +} + +/* + h := hmac.New(sha256.New, secretKey) + h.Write([]byte(repoPushEvent)) + fmt.Printf("%s\n", hex.EncodeToString(h.Sum(nil))) +*/ diff --git a/mock/endpoints.go b/mock/endpoints.go index 49d3b31..934519d 100644 --- a/mock/endpoints.go +++ b/mock/endpoints.go @@ -22,8 +22,19 @@ var ( ) var ( - ListRepositories = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos", Method: "GET"} - GetRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug", Method: "GET"} - CreateRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos", Method: "POST"} - DeleteRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug", Method: "DELETE"} + SearchRepositories = EndpointPattern{Pattern: "/api/latest/repos", Method: "GET"} + ListRepositories = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos", Method: "GET"} + GetRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug", Method: "GET"} + CreateRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos", Method: "POST"} + DeleteRepository = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug", Method: "DELETE"} + SearchBranches = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/branches", Method: "GET"} + GetDefaultBranch = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/branches/default", Method: "GET"} + ListWebhooks = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/webhooks", Method: "GET"} + GetWebhook = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/webhooks/:id", Method: "GET"} + CreateWebhook = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/webhooks", Method: "POST"} + DeleteWebhook = EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/webhooks/:id", Method: "DELETE"} +) + +var ( + GetUser = EndpointPattern{Pattern: "/api/latest/users/:userSlug", Method: "GET"} )