├── .github └── workflows │ └── go.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── renovate.json └── sentry ├── dashboard_widgets.go ├── dashboard_widgets_test.go ├── dashboards.go ├── dashboards_test.go ├── errors.go ├── errors_test.go ├── issue_alerts.go ├── issue_alerts_test.go ├── metric_alerts.go ├── metric_alerts_test.go ├── notification_actions.go ├── notification_actions_test.go ├── organization_code_mappings.go ├── organization_code_mappings_test.go ├── organization_integrations.go ├── organization_integrations_test.go ├── organization_members.go ├── organization_members_test.go ├── organization_projects.go ├── organization_repositories.go ├── organization_repositories_test.go ├── organizations.go ├── organizations_test.go ├── project_filters.go ├── project_filters_test.go ├── project_inbound_data_filters.go ├── project_inbound_data_filters_test.go ├── project_keys.go ├── project_keys_test.go ├── project_ownerships.go ├── project_ownerships_test.go ├── project_plugins.go ├── project_symbol_sources.go ├── project_symbol_sources_test.go ├── projects.go ├── projects_test.go ├── release_deployment.go ├── roles.go ├── sentry.go ├── sentry_test.go ├── spike_protections.go ├── spike_protections_test.go ├── team_members.go ├── team_members_test.go ├── teams.go ├── teams_test.go ├── types.go ├── users.go └── users_test.go /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go: 11 | - "1.19" 12 | - "1.20" 13 | - "1.21" 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ matrix.go }} 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: make test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/dnephin/pre-commit-golang 2 | rev: master 3 | hooks: 4 | - id: go-fmt 5 | - id: go-vet 6 | - id: go-lint 7 | - id: go-imports 8 | - id: go-cyclo 9 | args: [-over=15] 10 | - id: validate-toml 11 | - id: no-go-testing 12 | - id: gometalinter 13 | - id: golangci-lint 14 | - id: go-critic 15 | - id: go-unit-tests 16 | - id: go-build 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jian Yuan Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONE: all 2 | all: test 3 | 4 | .PHONY: deps 5 | deps: 6 | @go mod download 7 | 8 | .PHONY: test 9 | test: 10 | @go test -cover -race -v ./... 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-sentry [![Go Reference](https://pkg.go.dev/badge/github.com/jianyuan/go-sentry/v2/sentry.svg)](https://pkg.go.dev/github.com/jianyuan/go-sentry/v2/sentry) 2 | 3 | Go library for accessing the [Sentry Web API](https://docs.sentry.io/api/). 4 | 5 | ## Installation 6 | go-sentry is compatible with modern Go releases in module mode, with Go installed: 7 | 8 | ```sh 9 | go get github.com/jianyuan/go-sentry/v2/sentry 10 | ``` 11 | 12 | ## Usage 13 | 14 | ```go 15 | import "github.com/jianyuan/go-sentry/v2/sentry" 16 | ``` 17 | 18 | Create a new Sentry client. Then, use the various services on the client to access different parts of the 19 | Sentry Web API. For example: 20 | 21 | ```go 22 | client := sentry.NewClient(nil) 23 | 24 | // List all organizations 25 | orgs, _, err := client.Organizations.List(ctx, nil) 26 | ``` 27 | 28 | ### Authentication 29 | 30 | The library does not directly handle authentication. When creating a new client, pass an 31 | `http.Client` that can handle authentication for you. We recommend the [oauth2](https://pkg.go.dev/golang.org/x/oauth2) 32 | library. For example: 33 | 34 | ```go 35 | package main 36 | 37 | import ( 38 | "github.com/jianyuan/go-sentry/v2/sentry" 39 | "golang.org/x/oauth2" 40 | ) 41 | 42 | func main() { 43 | ctx := context.Background() 44 | tokenSrc := oauth2.StaticTokenSource( 45 | &oauth2.Token{AccessToken: "YOUR-API-KEY"}, 46 | ) 47 | httpClient := oauth2.NewClient(ctx, tokenSrc) 48 | 49 | client := sentry.NewClient(httpClient) 50 | 51 | // List all organizations 52 | orgs, _, err := client.Organizations.List(ctx, nil) 53 | } 54 | ``` 55 | 56 | ## Code structure 57 | The code structure was inspired by [google/go-github](https://github.com/google/go-github). 58 | 59 | ## License 60 | This library is distributed under the [MIT License](LICENSE). 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jianyuan/go-sentry/v2 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/google/go-querystring v1.1.0 7 | github.com/peterhellberg/link v1.2.0 8 | github.com/stretchr/testify v1.9.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 5 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 7 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 8 | github.com/peterhellberg/link v1.1.0 h1:s2+RH8EGuI/mI4QwrWGSYQCRz7uNgip9BaM04HKu5kc= 9 | github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFYl75keYyBB8= 10 | github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= 11 | github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 16 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 17 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 18 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 19 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 20 | github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= 21 | github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 22 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 23 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 24 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 25 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 26 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 27 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 28 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 29 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 30 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 31 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 32 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 33 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 34 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /sentry/dashboard_widgets.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // DashboardWidget represents a Dashboard Widget. 9 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/rest_framework/dashboard.py#L230-L243 10 | type DashboardWidget struct { 11 | ID *string `json:"id,omitempty"` 12 | Title *string `json:"title,omitempty"` 13 | DisplayType *string `json:"displayType,omitempty"` 14 | Interval *string `json:"interval,omitempty"` 15 | Queries []*DashboardWidgetQuery `json:"queries,omitempty"` 16 | WidgetType *string `json:"widgetType,omitempty"` 17 | Limit *int `json:"limit,omitempty"` 18 | Layout *DashboardWidgetLayout `json:"layout,omitempty"` 19 | } 20 | 21 | type DashboardWidgetLayout struct { 22 | X *int `json:"x,omitempty"` 23 | Y *int `json:"y,omitempty"` 24 | W *int `json:"w,omitempty"` 25 | H *int `json:"h,omitempty"` 26 | MinH *int `json:"minH,omitempty"` 27 | } 28 | 29 | type DashboardWidgetQuery struct { 30 | ID *string `json:"id,omitempty"` 31 | Fields []string `json:"fields,omitempty"` 32 | Aggregates []string `json:"aggregates,omitempty"` 33 | Columns []string `json:"columns,omitempty"` 34 | FieldAliases []string `json:"fieldAliases,omitempty"` 35 | Name *string `json:"name,omitempty"` 36 | Conditions *string `json:"conditions,omitempty"` 37 | OrderBy *string `json:"orderby,omitempty"` 38 | } 39 | 40 | // DashboardWidgetsService provides methods for accessing Sentry dashboard widget API endpoints. 41 | type DashboardWidgetsService service 42 | 43 | type DashboardWidgetErrors map[string][]string 44 | 45 | // Validate a dashboard widget configuration. 46 | func (s *DashboardWidgetsService) Validate(ctx context.Context, organizationSlug string, widget *DashboardWidget) (DashboardWidgetErrors, *Response, error) { 47 | u := fmt.Sprintf("0/organizations/%v/dashboards/widgets/", organizationSlug) 48 | 49 | req, err := s.client.NewRequest("POST", u, widget) 50 | if err != nil { 51 | return nil, nil, err 52 | } 53 | 54 | widgetErrors := make(DashboardWidgetErrors) 55 | resp, err := s.client.Do(ctx, req, &widgetErrors) 56 | if err != nil { 57 | return nil, resp, err 58 | } 59 | if len(widgetErrors) == 0 { 60 | return nil, resp, err 61 | } 62 | return widgetErrors, resp, err 63 | } 64 | -------------------------------------------------------------------------------- /sentry/dashboard_widgets_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDashboardWidgetsService_Validate_pass(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/widgets/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "POST", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `{}`) 20 | }) 21 | 22 | widget := &DashboardWidget{ 23 | Title: String("Number of Errors"), 24 | DisplayType: String("big_number"), 25 | Interval: String("5m"), 26 | Queries: []*DashboardWidgetQuery{ 27 | { 28 | ID: String("115037"), 29 | Fields: []string{"count()"}, 30 | Aggregates: []string{"count()"}, 31 | Columns: []string{}, 32 | FieldAliases: []string{}, 33 | Name: String(""), 34 | Conditions: String("!event.type:transaction"), 35 | OrderBy: String(""), 36 | }, 37 | }, 38 | WidgetType: String("discover"), 39 | Layout: &DashboardWidgetLayout{ 40 | X: Int(0), 41 | Y: Int(0), 42 | W: Int(2), 43 | H: Int(1), 44 | MinH: Int(1), 45 | }, 46 | } 47 | ctx := context.Background() 48 | widgetErrors, _, err := client.DashboardWidgets.Validate(ctx, "the-interstellar-jurisdiction", widget) 49 | assert.Nil(t, widgetErrors) 50 | assert.NoError(t, err) 51 | } 52 | 53 | func TestDashboardWidgetsService_Validate_fail(t *testing.T) { 54 | client, mux, _, teardown := setup() 55 | defer teardown() 56 | 57 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/widgets/", func(w http.ResponseWriter, r *http.Request) { 58 | assertMethod(t, "POST", r) 59 | w.Header().Set("Content-Type", "application/json") 60 | fmt.Fprint(w, `{"widgetType":["\"discover-invalid\" is not a valid choice."]}`) 61 | }) 62 | 63 | widget := &DashboardWidget{ 64 | Title: String("Number of Errors"), 65 | DisplayType: String("big_number"), 66 | Interval: String("5m"), 67 | Queries: []*DashboardWidgetQuery{ 68 | { 69 | ID: String("115037"), 70 | Fields: []string{"count()"}, 71 | Aggregates: []string{"count()"}, 72 | Columns: []string{}, 73 | FieldAliases: []string{}, 74 | Name: String(""), 75 | Conditions: String("!event.type:transaction"), 76 | OrderBy: String(""), 77 | }, 78 | }, 79 | WidgetType: String("discover-invalid"), 80 | Layout: &DashboardWidgetLayout{ 81 | X: Int(0), 82 | Y: Int(0), 83 | W: Int(2), 84 | H: Int(1), 85 | MinH: Int(1), 86 | }, 87 | } 88 | ctx := context.Background() 89 | widgetErrors, _, err := client.DashboardWidgets.Validate(ctx, "the-interstellar-jurisdiction", widget) 90 | expected := DashboardWidgetErrors{ 91 | "widgetType": []string{`"discover-invalid" is not a valid choice.`}, 92 | } 93 | assert.Equal(t, expected, widgetErrors) 94 | assert.NoError(t, err) 95 | } 96 | -------------------------------------------------------------------------------- /sentry/dashboards.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Dashboard represents a Dashboard. 10 | type Dashboard struct { 11 | ID *string `json:"id,omitempty"` 12 | Title *string `json:"title,omitempty"` 13 | DateCreated *time.Time `json:"dateCreated,omitempty"` 14 | Widgets []*DashboardWidget `json:"widgets,omitempty"` 15 | } 16 | 17 | // DashboardsService provides methods for accessing Sentry dashboard API endpoints. 18 | type DashboardsService service 19 | 20 | // List dashboards in an organization. 21 | func (s *DashboardsService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*Dashboard, *Response, error) { 22 | u := fmt.Sprintf("0/organizations/%v/dashboards/", organizationSlug) 23 | u, err := addQuery(u, params) 24 | if err != nil { 25 | return nil, nil, err 26 | } 27 | 28 | req, err := s.client.NewRequest("GET", u, nil) 29 | if err != nil { 30 | return nil, nil, err 31 | } 32 | 33 | var dashboards []*Dashboard 34 | resp, err := s.client.Do(ctx, req, &dashboards) 35 | if err != nil { 36 | return nil, resp, err 37 | } 38 | return dashboards, resp, nil 39 | } 40 | 41 | // Get details on a dashboard. 42 | func (s *DashboardsService) Get(ctx context.Context, organizationSlug string, id string) (*Dashboard, *Response, error) { 43 | u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) 44 | req, err := s.client.NewRequest("GET", u, nil) 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | 49 | dashboard := new(Dashboard) 50 | resp, err := s.client.Do(ctx, req, dashboard) 51 | if err != nil { 52 | return nil, resp, err 53 | } 54 | return dashboard, resp, nil 55 | } 56 | 57 | // Create a dashboard. 58 | func (s *DashboardsService) Create(ctx context.Context, organizationSlug string, params *Dashboard) (*Dashboard, *Response, error) { 59 | u := fmt.Sprintf("0/organizations/%v/dashboards/", organizationSlug) 60 | req, err := s.client.NewRequest("POST", u, params) 61 | if err != nil { 62 | return nil, nil, err 63 | } 64 | 65 | dashboard := new(Dashboard) 66 | resp, err := s.client.Do(ctx, req, dashboard) 67 | if err != nil { 68 | return nil, resp, err 69 | } 70 | return dashboard, resp, nil 71 | } 72 | 73 | // Update a dashboard. 74 | func (s *DashboardsService) Update(ctx context.Context, organizationSlug string, id string, params *Dashboard) (*Dashboard, *Response, error) { 75 | u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) 76 | req, err := s.client.NewRequest("PUT", u, params) 77 | if err != nil { 78 | return nil, nil, err 79 | } 80 | 81 | dashboard := new(Dashboard) 82 | resp, err := s.client.Do(ctx, req, dashboard) 83 | if err != nil { 84 | return nil, resp, err 85 | } 86 | return dashboard, resp, nil 87 | } 88 | 89 | // Delete a dashboard. 90 | func (s *DashboardsService) Delete(ctx context.Context, organizationSlug string, id string) (*Response, error) { 91 | u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) 92 | req, err := s.client.NewRequest("DELETE", u, nil) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return s.client.Do(ctx, req, nil) 98 | } 99 | -------------------------------------------------------------------------------- /sentry/dashboards_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDashboardsService_List(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `[ 20 | { 21 | "id": "11833", 22 | "title": "General", 23 | "dateCreated": "2022-06-07T16:48:26.255520Z" 24 | }, 25 | { 26 | "id": "11832", 27 | "title": "Mobile Template", 28 | "dateCreated": "2022-06-07T16:43:40.456607Z" 29 | } 30 | ]`) 31 | }) 32 | 33 | ctx := context.Background() 34 | widgetErrors, _, err := client.Dashboards.List(ctx, "the-interstellar-jurisdiction", nil) 35 | 36 | expected := []*Dashboard{ 37 | { 38 | ID: String("11833"), 39 | Title: String("General"), 40 | DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), 41 | }, 42 | { 43 | ID: String("11832"), 44 | Title: String("Mobile Template"), 45 | DateCreated: Time(mustParseTime("2022-06-07T16:43:40.456607Z")), 46 | }, 47 | } 48 | assert.Equal(t, expected, widgetErrors) 49 | assert.NoError(t, err) 50 | } 51 | 52 | func TestDashboardsService_Get(t *testing.T) { 53 | client, mux, _, teardown := setup() 54 | defer teardown() 55 | 56 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { 57 | assertMethod(t, "GET", r) 58 | w.Header().Set("Content-Type", "application/json") 59 | fmt.Fprint(w, `{ 60 | "id": "12072", 61 | "title": "General", 62 | "dateCreated": "2022-06-07T16:48:26.255520Z", 63 | "widgets": [ 64 | { 65 | "id": "105567", 66 | "title": "Custom Widget", 67 | "displayType": "world_map", 68 | "interval": "5m", 69 | "dateCreated": "2022-06-12T15:37:19.886736Z", 70 | "dashboardId": "12072", 71 | "queries": [ 72 | { 73 | "id": "117838", 74 | "name": "", 75 | "fields": [ 76 | "count()" 77 | ], 78 | "aggregates": [ 79 | "count()" 80 | ], 81 | "columns": [], 82 | "fieldAliases": [], 83 | "conditions": "", 84 | "orderby": "", 85 | "widgetId": "105567" 86 | } 87 | ], 88 | "limit": null, 89 | "widgetType": "discover", 90 | "layout": { 91 | "y": 0, 92 | "x": 0, 93 | "h": 2, 94 | "minH": 2, 95 | "w": 2 96 | } 97 | } 98 | ] 99 | }`) 100 | }) 101 | 102 | ctx := context.Background() 103 | dashboard, _, err := client.Dashboards.Get(ctx, "the-interstellar-jurisdiction", "12072") 104 | 105 | expected := &Dashboard{ 106 | ID: String("12072"), 107 | Title: String("General"), 108 | DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), 109 | Widgets: []*DashboardWidget{ 110 | { 111 | ID: String("105567"), 112 | Title: String("Custom Widget"), 113 | DisplayType: String("world_map"), 114 | Interval: String("5m"), 115 | Queries: []*DashboardWidgetQuery{ 116 | { 117 | ID: String("117838"), 118 | Fields: []string{"count()"}, 119 | Aggregates: []string{"count()"}, 120 | Columns: []string{}, 121 | FieldAliases: []string{}, 122 | Name: String(""), 123 | Conditions: String(""), 124 | OrderBy: String(""), 125 | }, 126 | }, 127 | WidgetType: String("discover"), 128 | Layout: &DashboardWidgetLayout{ 129 | X: Int(0), 130 | Y: Int(0), 131 | W: Int(2), 132 | H: Int(2), 133 | MinH: Int(2), 134 | }, 135 | }, 136 | }, 137 | } 138 | assert.Equal(t, expected, dashboard) 139 | assert.NoError(t, err) 140 | } 141 | 142 | func TestDashboardsService_Create(t *testing.T) { 143 | client, mux, _, teardown := setup() 144 | defer teardown() 145 | 146 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/", func(w http.ResponseWriter, r *http.Request) { 147 | assertMethod(t, "POST", r) 148 | assertPostJSONValue(t, map[string]interface{}{ 149 | "title": "General", 150 | "widgets": map[string]interface{}{}, 151 | }, r) 152 | 153 | w.Header().Set("Content-Type", "application/json") 154 | fmt.Fprint(w, `{ 155 | "id": "12072", 156 | "title": "General", 157 | "dateCreated": "2022-06-07T16:48:26.255520Z", 158 | "widgets": [] 159 | }`) 160 | }) 161 | 162 | params := &Dashboard{ 163 | Title: String("General"), 164 | Widgets: []*DashboardWidget{}, 165 | } 166 | ctx := context.Background() 167 | dashboard, _, err := client.Dashboards.Create(ctx, "the-interstellar-jurisdiction", params) 168 | 169 | expected := &Dashboard{ 170 | ID: String("12072"), 171 | Title: String("General"), 172 | DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), 173 | Widgets: []*DashboardWidget{}, 174 | } 175 | assert.Equal(t, expected, dashboard) 176 | assert.NoError(t, err) 177 | } 178 | 179 | func TestDashboardsService_Update(t *testing.T) { 180 | client, mux, _, teardown := setup() 181 | defer teardown() 182 | 183 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { 184 | assertMethod(t, "PUT", r) 185 | assertPostJSONValue(t, map[string]interface{}{ 186 | "id": "12072", 187 | "title": "General", 188 | "widgets": map[string]interface{}{}, 189 | }, r) 190 | 191 | w.Header().Set("Content-Type", "application/json") 192 | fmt.Fprint(w, `{ 193 | "id": "12072", 194 | "title": "General", 195 | "dateCreated": "2022-06-07T16:48:26.255520Z", 196 | "widgets": [] 197 | }`) 198 | }) 199 | 200 | params := &Dashboard{ 201 | ID: String("12072"), 202 | Title: String("General"), 203 | Widgets: []*DashboardWidget{}, 204 | } 205 | ctx := context.Background() 206 | dashboard, _, err := client.Dashboards.Update(ctx, "the-interstellar-jurisdiction", "12072", params) 207 | 208 | expected := &Dashboard{ 209 | ID: String("12072"), 210 | Title: String("General"), 211 | DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), 212 | Widgets: []*DashboardWidget{}, 213 | } 214 | assert.Equal(t, expected, dashboard) 215 | assert.NoError(t, err) 216 | } 217 | 218 | func TestDashboardsService_Delete(t *testing.T) { 219 | client, mux, _, teardown := setup() 220 | defer teardown() 221 | 222 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { 223 | assertMethod(t, "DELETE", r) 224 | }) 225 | 226 | ctx := context.Background() 227 | _, err := client.Dashboards.Delete(ctx, "the-interstellar-jurisdiction", "12072") 228 | assert.NoError(t, err) 229 | } 230 | -------------------------------------------------------------------------------- /sentry/errors.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // APIError represents a Sentry API Error response. 9 | // Should look like: 10 | // 11 | // type apiError struct { 12 | // Detail string `json:"detail"` 13 | // } 14 | type APIError struct { 15 | f interface{} // unknown 16 | } 17 | 18 | func (e *APIError) UnmarshalJSON(b []byte) error { 19 | if err := json.Unmarshal(b, &e.f); err != nil { 20 | e.f = string(b) 21 | } 22 | return nil 23 | } 24 | 25 | func (e *APIError) MarshalJSON() ([]byte, error) { 26 | return json.Marshal(e.f) 27 | } 28 | 29 | func (e APIError) Detail() string { 30 | switch v := e.f.(type) { 31 | case map[string]interface{}: 32 | if len(v) == 1 { 33 | if detail, ok := v["detail"].(string); ok { 34 | return detail 35 | } 36 | } 37 | return fmt.Sprintf("%v", v) 38 | default: 39 | return fmt.Sprintf("%v", v) 40 | } 41 | } 42 | 43 | func (e APIError) Error() string { 44 | return fmt.Sprintf("sentry: %s", e.Detail()) 45 | } 46 | 47 | // Empty returns true if empty. 48 | func (e APIError) Empty() bool { 49 | return e.f == nil 50 | } 51 | -------------------------------------------------------------------------------- /sentry/errors_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestAPIErrors(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | have string 13 | want string 14 | wantErr bool 15 | }{{ 16 | name: "detail", 17 | have: `{"detail": "description"}`, 18 | want: "sentry: description", 19 | }, { 20 | name: "detail+others", 21 | have: `{"detail": "description", "other": "field"}`, 22 | want: "sentry: map[detail:description other:field]", 23 | }, { 24 | name: "jsonstring", 25 | have: `"jsonstring"`, 26 | want: "sentry: jsonstring", 27 | }} 28 | 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | // https://github.com/dghubble/sling/blob/master/sling.go#L412 32 | decoder := json.NewDecoder(strings.NewReader(tt.have)) 33 | 34 | var e APIError 35 | err := decoder.Decode(&e) 36 | if err != nil { 37 | if !tt.wantErr { 38 | t.Fatal(err) 39 | } 40 | return 41 | } 42 | got := e.Error() 43 | if tt.want != got { 44 | t.Errorf("want %q, got %q", tt.want, got) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /sentry/issue_alerts.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | ) 10 | 11 | // IssueAlert represents an issue alert configured for this project. 12 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/rule.py#L131-L155 13 | type IssueAlert struct { 14 | ID *string `json:"id,omitempty"` 15 | Conditions []map[string]interface{} `json:"conditions,omitempty"` 16 | Filters []map[string]interface{} `json:"filters,omitempty"` 17 | Actions []map[string]interface{} `json:"actions,omitempty"` 18 | ActionMatch *string `json:"actionMatch,omitempty"` 19 | FilterMatch *string `json:"filterMatch,omitempty"` 20 | Frequency *json.Number `json:"frequency,omitempty"` 21 | Name *string `json:"name,omitempty"` 22 | DateCreated *time.Time `json:"dateCreated,omitempty"` 23 | Owner *string `json:"owner,omitempty"` 24 | CreatedBy *IssueAlertCreatedBy `json:"createdBy,omitempty"` 25 | Environment *string `json:"environment,omitempty"` 26 | Projects []string `json:"projects,omitempty"` 27 | TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule 28 | } 29 | 30 | // IssueAlertCreatedBy for defining the rule creator. 31 | type IssueAlertCreatedBy struct { 32 | ID *int `json:"id,omitempty"` 33 | Name *string `json:"name,omitempty"` 34 | Email *string `json:"email,omitempty"` 35 | } 36 | 37 | // IssueAlertTaskDetail represents the inline struct Sentry defines for task details 38 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/endpoints/project_rule_task_details.py#L29 39 | type IssueAlertTaskDetail struct { 40 | Status *string `json:"status,omitempty"` 41 | Rule *IssueAlert `json:"rule,omitempty"` 42 | Error *string `json:"error,omitempty"` 43 | } 44 | 45 | // IssueAlertsService provides methods for accessing Sentry project 46 | // client key API endpoints. 47 | // https://docs.sentry.io/api/projects/ 48 | type IssueAlertsService service 49 | 50 | // List issue alerts configured for a project. 51 | func (s *IssueAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*IssueAlert, *Response, error) { 52 | u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) 53 | u, err := addQuery(u, params) 54 | if err != nil { 55 | return nil, nil, err 56 | } 57 | 58 | req, err := s.client.NewRequest("GET", u, nil) 59 | if err != nil { 60 | return nil, nil, err 61 | } 62 | 63 | alerts := []*IssueAlert{} 64 | resp, err := s.client.Do(ctx, req, &alerts) 65 | if err != nil { 66 | return nil, resp, err 67 | } 68 | return alerts, resp, nil 69 | } 70 | 71 | // Get details on an issue alert. 72 | func (s *IssueAlertsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*IssueAlert, *Response, error) { 73 | u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, id) 74 | req, err := s.client.NewRequest("GET", u, nil) 75 | if err != nil { 76 | return nil, nil, err 77 | } 78 | 79 | alert := new(IssueAlert) 80 | resp, err := s.client.Do(ctx, req, alert) 81 | if err != nil { 82 | return nil, resp, err 83 | } 84 | return alert, resp, nil 85 | } 86 | 87 | // Create a new issue alert bound to a project. 88 | func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *IssueAlert) (*IssueAlert, *Response, error) { 89 | u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) 90 | req, err := s.client.NewRequest("POST", u, params) 91 | if err != nil { 92 | return nil, nil, err 93 | } 94 | 95 | alert := new(IssueAlert) 96 | resp, err := s.client.Do(ctx, req, alert) 97 | if err != nil { 98 | return nil, resp, err 99 | } 100 | 101 | if resp.StatusCode == 202 { 102 | if alert.TaskUUID == nil { 103 | return nil, resp, errors.New("missing task uuid") 104 | } 105 | // We just received a reference to an async task, we need to check another endpoint to retrieve the issue alert we created 106 | return s.getIssueAlertFromTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) 107 | } 108 | 109 | return alert, resp, nil 110 | } 111 | 112 | // getIssueAlertFromTaskDetail is called when Sentry offloads the issue alert creation process to an async task and sends us back the task's uuid. 113 | // It usually doesn't happen, but when creating Slack notification rules, it seemed to be sometimes the case. During testing it 114 | // took very long for a task to finish (10+ seconds) which is why this method can take long to return. 115 | func (s *IssueAlertsService) getIssueAlertFromTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUUID string) (*IssueAlert, *Response, error) { 116 | u := fmt.Sprintf("0/projects/%v/%v/rule-task/%v/", organizationSlug, projectSlug, taskUUID) 117 | req, err := s.client.NewRequest("GET", u, nil) 118 | if err != nil { 119 | return nil, nil, err 120 | } 121 | 122 | var resp *Response 123 | for i := 0; i < 5; i++ { 124 | // TODO: Read poll interval from context 125 | time.Sleep(5 * time.Second) 126 | 127 | taskDetail := new(IssueAlertTaskDetail) 128 | resp, err := s.client.Do(ctx, req, taskDetail) 129 | if err != nil { 130 | return nil, resp, err 131 | } 132 | 133 | if resp.StatusCode == 404 { 134 | return nil, resp, fmt.Errorf("cannot find issue alert creation task with UUID %v", taskUUID) 135 | } 136 | if taskDetail.Status != nil && taskDetail.Rule != nil { 137 | if *taskDetail.Status == "success" { 138 | return taskDetail.Rule, resp, err 139 | } else if *taskDetail.Status == "failed" { 140 | if taskDetail != nil { 141 | return taskDetail.Rule, resp, errors.New(*taskDetail.Error) 142 | } 143 | 144 | return taskDetail.Rule, resp, errors.New("error while running the issue alert creation task") 145 | } 146 | } 147 | } 148 | return nil, resp, errors.New("getting the status of the issue alert creation from Sentry took too long") 149 | } 150 | 151 | // Update an issue alert. 152 | func (s *IssueAlertsService) Update(ctx context.Context, organizationSlug string, projectSlug string, issueAlertID string, params *IssueAlert) (*IssueAlert, *Response, error) { 153 | u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, issueAlertID) 154 | req, err := s.client.NewRequest("PUT", u, params) 155 | if err != nil { 156 | return nil, nil, err 157 | } 158 | 159 | alert := new(IssueAlert) 160 | resp, err := s.client.Do(ctx, req, alert) 161 | if err != nil { 162 | return nil, resp, err 163 | } 164 | return alert, resp, nil 165 | } 166 | 167 | // Delete an issue alert. 168 | func (s *IssueAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { 169 | u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, id) 170 | req, err := s.client.NewRequest("DELETE", u, nil) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | return s.client.Do(ctx, req, nil) 176 | } 177 | -------------------------------------------------------------------------------- /sentry/metric_alerts.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | type MetricAlertsService service 11 | 12 | type MetricAlert struct { 13 | ID *string `json:"id,omitempty"` 14 | Name *string `json:"name,omitempty"` 15 | Environment *string `json:"environment,omitempty"` 16 | DataSet *string `json:"dataset,omitempty"` 17 | EventTypes []string `json:"eventTypes,omitempty"` 18 | Query *string `json:"query,omitempty"` 19 | Aggregate *string `json:"aggregate,omitempty"` 20 | TimeWindow *float64 `json:"timeWindow,omitempty"` 21 | ThresholdType *int `json:"thresholdType,omitempty"` 22 | ResolveThreshold *float64 `json:"resolveThreshold,omitempty"` 23 | ComparisonDelta *float64 `json:"comparisonDelta,omitempty"` 24 | Triggers []*MetricAlertTrigger `json:"triggers,omitempty"` 25 | Projects []string `json:"projects,omitempty"` 26 | Owner *string `json:"owner,omitempty"` 27 | DateCreated *time.Time `json:"dateCreated,omitempty"` 28 | TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the metric 29 | } 30 | 31 | // MetricAlertTaskDetail represents the inline struct Sentry defines for task details 32 | // https://github.com/getsentry/sentry/blob/22.12.0/src/sentry/incidents/endpoints/project_alert_rule_task_details.py#L31 33 | type MetricAlertTaskDetail struct { 34 | Status *string `json:"status,omitempty"` 35 | AlertRule *MetricAlert `json:"alertRule,omitempty"` 36 | Error *string `json:"error,omitempty"` 37 | } 38 | 39 | // MetricAlertTrigger represents a metric alert trigger. 40 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger.py#L35-L47 41 | type MetricAlertTrigger struct { 42 | ID *string `json:"id,omitempty"` 43 | AlertRuleID *string `json:"alertRuleId,omitempty"` 44 | Label *string `json:"label,omitempty"` 45 | ThresholdType *int `json:"thresholdType,omitempty"` 46 | AlertThreshold *float64 `json:"alertThreshold,omitempty"` 47 | ResolveThreshold *float64 `json:"resolveThreshold,omitempty"` 48 | DateCreated *time.Time `json:"dateCreated,omitempty"` 49 | Actions []*MetricAlertTriggerAction `json:"actions"` // Must always be present. 50 | } 51 | 52 | // MetricAlertTriggerAction represents a metric alert trigger action. 53 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger_action.py#L42-L66 54 | type MetricAlertTriggerAction struct { 55 | ID *string `json:"id,omitempty"` 56 | AlertRuleTriggerID *string `json:"alertRuleTriggerId,omitempty"` 57 | Type *string `json:"type,omitempty"` 58 | TargetType *string `json:"targetType,omitempty"` 59 | TargetIdentifier *Int64OrString `json:"targetIdentifier,omitempty"` 60 | InputChannelID *string `json:"inputChannelId,omitempty"` 61 | IntegrationID *int `json:"integrationId,omitempty"` 62 | SentryAppID *string `json:"sentryAppId,omitempty"` 63 | DateCreated *time.Time `json:"dateCreated,omitempty"` 64 | Description *string `json:"desc,omitempty"` 65 | } 66 | 67 | // List Alert Rules configured for a project 68 | func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*MetricAlert, *Response, error) { 69 | u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) 70 | u, err := addQuery(u, params) 71 | if err != nil { 72 | return nil, nil, err 73 | } 74 | 75 | req, err := s.client.NewRequest("GET", u, nil) 76 | if err != nil { 77 | return nil, nil, err 78 | } 79 | 80 | alerts := []*MetricAlert{} 81 | resp, err := s.client.Do(ctx, req, &alerts) 82 | if err != nil { 83 | return nil, resp, err 84 | } 85 | return alerts, resp, nil 86 | } 87 | 88 | // Get details on an issue alert. 89 | func (s *MetricAlertsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*MetricAlert, *Response, error) { 90 | // TODO: Remove projectSlug argument 91 | u := fmt.Sprintf("0/organizations/%v/alert-rules/%v/", organizationSlug, id) 92 | req, err := s.client.NewRequest("GET", u, nil) 93 | if err != nil { 94 | return nil, nil, err 95 | } 96 | 97 | alert := new(MetricAlert) 98 | resp, err := s.client.Do(ctx, req, alert) 99 | if err != nil { 100 | return nil, resp, err 101 | } 102 | return alert, resp, nil 103 | } 104 | 105 | // Create a new Alert Rule bound to a project. 106 | func (s *MetricAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *MetricAlert) (*MetricAlert, *Response, error) { 107 | u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) 108 | req, err := s.client.NewRequest("POST", u, params) 109 | if err != nil { 110 | return nil, nil, err 111 | } 112 | 113 | alert := new(MetricAlert) 114 | resp, err := s.client.Do(ctx, req, alert) 115 | if err != nil { 116 | return nil, resp, err 117 | } 118 | 119 | if resp.StatusCode == 202 { 120 | if alert.TaskUUID == nil { 121 | return nil, resp, errors.New("missing task uuid") 122 | } 123 | // We just received a reference to an async task, we need to check another endpoint to retrieve the metric alert we created 124 | return s.getMetricAlertFromMetricAlertTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) 125 | } 126 | 127 | return alert, resp, nil 128 | } 129 | 130 | // Update an Alert Rule. 131 | func (s *MetricAlertsService) Update(ctx context.Context, organizationSlug string, projectSlug string, alertRuleID string, params *MetricAlert) (*MetricAlert, *Response, error) { 132 | u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, alertRuleID) 133 | req, err := s.client.NewRequest("PUT", u, params) 134 | if err != nil { 135 | return nil, nil, err 136 | } 137 | 138 | alert := new(MetricAlert) 139 | resp, err := s.client.Do(ctx, req, alert) 140 | if err != nil { 141 | return nil, resp, err 142 | } 143 | 144 | if resp.StatusCode == 202 { 145 | if alert.TaskUUID == nil { 146 | return nil, resp, errors.New("missing task uuid") 147 | } 148 | // We just received a reference to an async task, we need to check another endpoint to retrieve the metric alert we created 149 | return s.getMetricAlertFromMetricAlertTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) 150 | } 151 | 152 | return alert, resp, nil 153 | } 154 | 155 | func (s *MetricAlertsService) getMetricAlertFromMetricAlertTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUUID string) (*MetricAlert, *Response, error) { 156 | u := fmt.Sprintf("0/projects/%v/%v/alert-rule-task/%v/", organizationSlug, projectSlug, taskUUID) 157 | req, err := s.client.NewRequest("GET", u, nil) 158 | if err != nil { 159 | return nil, nil, err 160 | } 161 | 162 | var resp *Response 163 | for i := 0; i < 5; i++ { 164 | time.Sleep(5 * time.Second) 165 | 166 | taskDetail := new(MetricAlertTaskDetail) 167 | resp, err := s.client.Do(ctx, req, taskDetail) 168 | if err != nil { 169 | return nil, resp, err 170 | } 171 | 172 | if resp.StatusCode == 404 { 173 | return nil, resp, fmt.Errorf("cannot find metric alert creation task with UUID %v", taskUUID) 174 | } 175 | if taskDetail.Status != nil && taskDetail.AlertRule != nil { 176 | if *taskDetail.Status == "success" { 177 | return taskDetail.AlertRule, resp, err 178 | } else if *taskDetail.Status == "failed" { 179 | if taskDetail != nil { 180 | return taskDetail.AlertRule, resp, errors.New(*taskDetail.Error) 181 | } 182 | 183 | return taskDetail.AlertRule, resp, errors.New("error while running the metric alert creation task") 184 | } 185 | } 186 | } 187 | return nil, resp, errors.New("getting the status of the metric alert creation from Sentry took too long") 188 | } 189 | 190 | // Delete an Alert Rule. 191 | func (s *MetricAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, alertRuleID string) (*Response, error) { 192 | u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, alertRuleID) 193 | req, err := s.client.NewRequest("DELETE", u, nil) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | return s.client.Do(ctx, req, nil) 199 | } 200 | -------------------------------------------------------------------------------- /sentry/notification_actions.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | type NotificationActionsService service 11 | 12 | type CreateNotificationActionParams struct { 13 | TriggerType *string `json:"triggerType"` 14 | ServiceType *string `json:"serviceType"` 15 | IntegrationId *json.Number `json:"integrationId,omitempty"` 16 | TargetIdentifier interface{} `json:"targetIdentifier,omitempty"` 17 | TargetDisplay *string `json:"targetDisplay,omitempty"` 18 | TargetType *string `json:"targetType,omitempty"` 19 | Projects []string `json:"projects"` 20 | } 21 | 22 | type NotificationAction struct { 23 | ID *json.Number `json:"id"` 24 | TriggerType *string `json:"triggerType"` 25 | ServiceType *string `json:"serviceType"` 26 | IntegrationId *json.Number `json:"integrationId"` 27 | TargetIdentifier interface{} `json:"targetIdentifier"` 28 | TargetDisplay *string `json:"targetDisplay"` 29 | TargetType *string `json:"targetType"` 30 | Projects []json.Number `json:"projects"` 31 | } 32 | 33 | func (s *NotificationActionsService) Get(ctx context.Context, organizationSlug string, actionId string) (*NotificationAction, *Response, error) { 34 | u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) 35 | req, err := s.client.NewRequest(http.MethodGet, u, nil) 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | 40 | action := &NotificationAction{} 41 | resp, err := s.client.Do(ctx, req, action) 42 | if err != nil { 43 | return nil, resp, err 44 | } 45 | return action, resp, nil 46 | } 47 | 48 | func (s *NotificationActionsService) Create(ctx context.Context, organizationSlug string, params *CreateNotificationActionParams) (*NotificationAction, *Response, error) { 49 | u := fmt.Sprintf("0/organizations/%v/notifications/actions/", organizationSlug) 50 | req, err := s.client.NewRequest(http.MethodPost, u, params) 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | 55 | action := &NotificationAction{} 56 | resp, err := s.client.Do(ctx, req, action) 57 | if err != nil { 58 | return nil, resp, err 59 | } 60 | return action, resp, nil 61 | } 62 | 63 | type UpdateNotificationActionParams = CreateNotificationActionParams 64 | 65 | func (s *NotificationActionsService) Update(ctx context.Context, organizationSlug string, actionId string, params *UpdateNotificationActionParams) (*NotificationAction, *Response, error) { 66 | u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) 67 | req, err := s.client.NewRequest(http.MethodPut, u, params) 68 | if err != nil { 69 | return nil, nil, err 70 | } 71 | 72 | action := &NotificationAction{} 73 | resp, err := s.client.Do(ctx, req, action) 74 | if err != nil { 75 | return nil, resp, err 76 | } 77 | return action, resp, nil 78 | } 79 | 80 | func (s *NotificationActionsService) Delete(ctx context.Context, organizationSlug string, actionId string) (*Response, error) { 81 | u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) 82 | req, err := s.client.NewRequest(http.MethodDelete, u, nil) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return s.client.Do(ctx, req, nil) 88 | } 89 | -------------------------------------------------------------------------------- /sentry/notification_actions_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNotificationActionsService_Get(t *testing.T) { 14 | client, mux, _, teardown := setup() 15 | defer teardown() 16 | 17 | mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/action_id/", func(w http.ResponseWriter, r *http.Request) { 18 | assertMethod(t, http.MethodGet, r) 19 | fmt.Fprintf(w, `{ 20 | "id": "836501735", 21 | "organizationId": "62848264", 22 | "serviceType": "sentry_notification", 23 | "targetDisplay": "default", 24 | "targetIdentifier": "default", 25 | "targetType": "specific", 26 | "triggerType": "spike-protection", 27 | "projects": [ 28 | 4505321021243392 29 | ] 30 | }`) 31 | }) 32 | 33 | ctx := context.Background() 34 | action, _, err := client.NotificationActions.Get(ctx, "organization_slug", "action_id") 35 | assert.NoError(t, err) 36 | 37 | expected := &NotificationAction{ 38 | ID: JsonNumber(json.Number("836501735")), 39 | TriggerType: String("spike-protection"), 40 | ServiceType: String("sentry_notification"), 41 | TargetIdentifier: "default", 42 | TargetDisplay: String("default"), 43 | TargetType: String("specific"), 44 | Projects: []json.Number{"4505321021243392"}, 45 | } 46 | assert.Equal(t, expected, action) 47 | } 48 | 49 | func TestNotificationActionsService_Create(t *testing.T) { 50 | client, mux, _, teardown := setup() 51 | defer teardown() 52 | 53 | mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/", func(w http.ResponseWriter, r *http.Request) { 54 | assertMethod(t, http.MethodPost, r) 55 | assertPostJSON(t, map[string]interface{}{ 56 | "projects": []interface{}{"go"}, 57 | "serviceType": "sentry_notification", 58 | "targetDisplay": "default", 59 | "targetIdentifier": "default", 60 | "targetType": "specific", 61 | "triggerType": "spike-protection", 62 | }, r) 63 | w.WriteHeader(http.StatusCreated) 64 | fmt.Fprintf(w, `{ 65 | "id": "836501735", 66 | "organizationId": "62848264", 67 | "serviceType": "sentry_notification", 68 | "targetDisplay": "default", 69 | "targetIdentifier": "default", 70 | "targetType": "specific", 71 | "triggerType": "spike-protection", 72 | "projects": [ 73 | 4505321021243392 74 | ] 75 | }`) 76 | }) 77 | 78 | params := &CreateNotificationActionParams{ 79 | TriggerType: String("spike-protection"), 80 | ServiceType: String("sentry_notification"), 81 | TargetIdentifier: String("default"), 82 | TargetDisplay: String("default"), 83 | TargetType: String("specific"), 84 | Projects: []string{"go"}, 85 | } 86 | ctx := context.Background() 87 | action, _, err := client.NotificationActions.Create(ctx, "organization_slug", params) 88 | assert.NoError(t, err) 89 | 90 | expected := &NotificationAction{ 91 | ID: JsonNumber(json.Number("836501735")), 92 | TriggerType: String("spike-protection"), 93 | ServiceType: String("sentry_notification"), 94 | TargetIdentifier: "default", 95 | TargetDisplay: String("default"), 96 | TargetType: String("specific"), 97 | Projects: []json.Number{"4505321021243392"}, 98 | } 99 | assert.Equal(t, expected, action) 100 | } 101 | 102 | func TestNotificationActionsService_Delete(t *testing.T) { 103 | client, mux, _, teardown := setup() 104 | defer teardown() 105 | 106 | mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/action_id/", func(w http.ResponseWriter, r *http.Request) { 107 | assertMethod(t, http.MethodDelete, r) 108 | }) 109 | 110 | ctx := context.Background() 111 | _, err := client.NotificationActions.Delete(ctx, "organization_slug", "action_id") 112 | assert.NoError(t, err) 113 | } 114 | -------------------------------------------------------------------------------- /sentry/organization_code_mappings.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // OrganizationCodeMapping represents a code mapping added for the organization. 9 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/repository_project_path_config.py 10 | type OrganizationCodeMapping struct { 11 | ID string `json:"id"` 12 | ProjectId string `json:"projectId"` 13 | ProjectSlug string `json:"projectSlug"` 14 | RepoId string `json:"repoId"` 15 | RepoName string `json:"repoName"` 16 | IntegrationId string `json:"integrationId"` 17 | Provider *OrganizationIntegrationProvider `json:"provider"` 18 | StackRoot string `json:"stackRoot"` 19 | SourceRoot string `json:"sourceRoot"` 20 | DefaultBranch string `json:"defaultBranch"` 21 | } 22 | 23 | // OrganizationCodeMappingsService provides methods for accessing Sentry organization code mappings API endpoints. 24 | // Paths: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/urls.py#L929-L938 25 | // Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/organization_code_mappings.py 26 | // Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/organization_code_mapping_details.py 27 | type OrganizationCodeMappingsService service 28 | 29 | type ListOrganizationCodeMappingsParams struct { 30 | ListCursorParams 31 | IntegrationId string `url:"integrationId,omitempty"` 32 | } 33 | 34 | // List organization integrations. 35 | func (s *OrganizationCodeMappingsService) List(ctx context.Context, organizationSlug string, params *ListOrganizationCodeMappingsParams) ([]*OrganizationCodeMapping, *Response, error) { 36 | u := fmt.Sprintf("0/organizations/%v/code-mappings/", organizationSlug) 37 | u, err := addQuery(u, params) 38 | if err != nil { 39 | return nil, nil, err 40 | } 41 | 42 | req, err := s.client.NewRequest("GET", u, nil) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | 47 | integrations := []*OrganizationCodeMapping{} 48 | resp, err := s.client.Do(ctx, req, &integrations) 49 | if err != nil { 50 | return nil, resp, err 51 | } 52 | return integrations, resp, nil 53 | } 54 | 55 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/organization_code_mappings.py#L26-L35 56 | type CreateOrganizationCodeMappingParams struct { 57 | DefaultBranch string `json:"defaultBranch"` 58 | StackRoot string `json:"stackRoot"` 59 | SourceRoot string `json:"sourceRoot"` 60 | RepositoryId string `json:"repositoryId"` 61 | IntegrationId string `json:"integrationId"` 62 | ProjectId string `json:"projectId"` 63 | } 64 | 65 | func (s *OrganizationCodeMappingsService) Create(ctx context.Context, organizationSlug string, params CreateOrganizationCodeMappingParams) (*OrganizationCodeMapping, *Response, error) { 66 | u := fmt.Sprintf("0/organizations/%v/code-mappings/", organizationSlug) 67 | req, err := s.client.NewRequest("POST", u, params) 68 | if err != nil { 69 | return nil, nil, err 70 | } 71 | 72 | repo := new(OrganizationCodeMapping) 73 | resp, err := s.client.Do(ctx, req, repo) 74 | if err != nil { 75 | return nil, resp, err 76 | } 77 | return repo, resp, nil 78 | } 79 | 80 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/organization_code_mappings.py#L26-L35 81 | type UpdateOrganizationCodeMappingParams CreateOrganizationCodeMappingParams 82 | 83 | func (s *OrganizationCodeMappingsService) Update(ctx context.Context, organizationSlug string, codeMappingId string, params UpdateOrganizationCodeMappingParams) (*OrganizationCodeMapping, *Response, error) { 84 | u := fmt.Sprintf("0/organizations/%v/code-mappings/%v/", organizationSlug, codeMappingId) 85 | req, err := s.client.NewRequest("PUT", u, params) 86 | if err != nil { 87 | return nil, nil, err 88 | } 89 | 90 | repo := new(OrganizationCodeMapping) 91 | resp, err := s.client.Do(ctx, req, repo) 92 | if err != nil { 93 | return nil, resp, err 94 | } 95 | return repo, resp, nil 96 | } 97 | 98 | func (s *OrganizationCodeMappingsService) Delete(ctx context.Context, organizationSlug string, codeMappingId string) (*Response, error) { 99 | u := fmt.Sprintf("0/organizations/%v/code-mappings/%v/", organizationSlug, codeMappingId) 100 | req, err := s.client.NewRequest("DELETE", u, nil) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | return s.client.Do(ctx, req, nil) 106 | } 107 | -------------------------------------------------------------------------------- /sentry/organization_code_mappings_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestOrganizationCodeMappingsService_List(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/code-mappings/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | assertQuery(t, map[string]string{"cursor": "100:-1:1", "integrationId": "123456"}, r) 19 | w.Header().Set("Content-Type", "application/json") 20 | fmt.Fprint(w, `[ 21 | { 22 | "id": "54321", 23 | "projectId": "7654321", 24 | "projectSlug": "spoon-knife", 25 | "repoId": "456123", 26 | "repoName": "octocat/Spoon-Knife", 27 | "integrationId": "123456", 28 | "provider": { 29 | "key": "github", 30 | "slug": "github", 31 | "name": "GitHub", 32 | "canAdd": true, 33 | "canDisable": false, 34 | "features": [ 35 | "codeowners", 36 | "commits", 37 | "issue-basic", 38 | "stacktrace-link" 39 | ], 40 | "aspects": {} 41 | }, 42 | "stackRoot": "/", 43 | "sourceRoot": "src/", 44 | "defaultBranch": "main" 45 | } 46 | ]`) 47 | }) 48 | 49 | ctx := context.Background() 50 | integrations, _, err := client.OrganizationCodeMappings.List(ctx, "the-interstellar-jurisdiction", &ListOrganizationCodeMappingsParams{ 51 | ListCursorParams: ListCursorParams{ 52 | Cursor: "100:-1:1", 53 | }, 54 | IntegrationId: "123456", 55 | }) 56 | assert.NoError(t, err) 57 | expected := []*OrganizationCodeMapping{ 58 | { 59 | ID: "54321", 60 | ProjectId: "7654321", 61 | ProjectSlug: "spoon-knife", 62 | RepoId: "456123", 63 | RepoName: "octocat/Spoon-Knife", 64 | IntegrationId: "123456", 65 | Provider: &OrganizationIntegrationProvider{ 66 | Key: "github", 67 | Slug: "github", 68 | Name: "GitHub", 69 | CanAdd: true, 70 | CanDisable: false, 71 | Features: []string{ 72 | "codeowners", 73 | "commits", 74 | "issue-basic", 75 | "stacktrace-link", 76 | }, 77 | }, 78 | StackRoot: "/", 79 | SourceRoot: "src/", 80 | DefaultBranch: "main", 81 | }, 82 | } 83 | assert.Equal(t, expected, integrations) 84 | } 85 | 86 | func TestOrganizationCodeMappingsService_Create(t *testing.T) { 87 | client, mux, _, teardown := setup() 88 | defer teardown() 89 | 90 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/code-mappings/", func(w http.ResponseWriter, r *http.Request) { 91 | assertMethod(t, "POST", r) 92 | w.WriteHeader(http.StatusCreated) 93 | w.Header().Set("Content-Type", "application/json") 94 | fmt.Fprint(w, `{ 95 | "id": "54321", 96 | "projectId": "7654321", 97 | "projectSlug": "spoon-knife", 98 | "repoId": "456123", 99 | "repoName": "octocat/Spoon-Knife", 100 | "integrationId": "123456", 101 | "provider": { 102 | "key": "github", 103 | "slug": "github", 104 | "name": "GitHub", 105 | "canAdd": true, 106 | "canDisable": false, 107 | "features": [ 108 | "codeowners", 109 | "commits", 110 | "issue-basic", 111 | "stacktrace-link" 112 | ], 113 | "aspects": {} 114 | }, 115 | "stackRoot": "/", 116 | "sourceRoot": "src/", 117 | "defaultBranch": "main" 118 | }`) 119 | }) 120 | 121 | ctx := context.Background() 122 | createOrganizationCodeMappingParams := CreateOrganizationCodeMappingParams{ 123 | DefaultBranch: "main", 124 | StackRoot: "/", 125 | SourceRoot: "src/", 126 | RepositoryId: "456123", 127 | IntegrationId: "123456", 128 | ProjectId: "7654321", 129 | } 130 | codeMapping, _, err := client.OrganizationCodeMappings.Create(ctx, "the-interstellar-jurisdiction", createOrganizationCodeMappingParams) 131 | assert.NoError(t, err) 132 | expected := &OrganizationCodeMapping{ 133 | ID: "54321", 134 | ProjectId: "7654321", 135 | ProjectSlug: "spoon-knife", 136 | RepoId: "456123", 137 | RepoName: "octocat/Spoon-Knife", 138 | IntegrationId: "123456", 139 | Provider: &OrganizationIntegrationProvider{ 140 | Key: "github", 141 | Slug: "github", 142 | Name: "GitHub", 143 | CanAdd: true, 144 | CanDisable: false, 145 | Features: []string{ 146 | "codeowners", 147 | "commits", 148 | "issue-basic", 149 | "stacktrace-link", 150 | }, 151 | }, 152 | StackRoot: "/", 153 | SourceRoot: "src/", 154 | DefaultBranch: "main", 155 | } 156 | assert.Equal(t, expected, codeMapping) 157 | } 158 | 159 | func TestOrganizationCodeMappingsService_Update(t *testing.T) { 160 | client, mux, _, teardown := setup() 161 | defer teardown() 162 | 163 | codeMappingId := "54321" 164 | 165 | mux.HandleFunc(fmt.Sprintf("/api/0/organizations/the-interstellar-jurisdiction/code-mappings/%s/", codeMappingId), func(w http.ResponseWriter, r *http.Request) { 166 | assertMethod(t, "PUT", r) 167 | w.WriteHeader(http.StatusOK) 168 | w.Header().Set("Content-Type", "application/json") 169 | fmt.Fprintf(w, `{ 170 | "id": "%s", 171 | "projectId": "7654321", 172 | "projectSlug": "spoon-knife", 173 | "repoId": "456123", 174 | "repoName": "octocat/Spoon-Knife", 175 | "integrationId": "123456", 176 | "provider": { 177 | "key": "github", 178 | "slug": "github", 179 | "name": "GitHub", 180 | "canAdd": true, 181 | "canDisable": false, 182 | "features": [ 183 | "codeowners", 184 | "commits", 185 | "issue-basic", 186 | "stacktrace-link" 187 | ], 188 | "aspects": {} 189 | }, 190 | "stackRoot": "/", 191 | "sourceRoot": "src/", 192 | "defaultBranch": "main" 193 | }`, codeMappingId) 194 | }) 195 | 196 | ctx := context.Background() 197 | updateOrganizationCodeMappingParams := UpdateOrganizationCodeMappingParams{ 198 | DefaultBranch: "main", 199 | StackRoot: "/", 200 | SourceRoot: "src/", 201 | RepositoryId: "456123", 202 | IntegrationId: "123456", 203 | ProjectId: "7654321", 204 | } 205 | codeMapping, _, err := client.OrganizationCodeMappings.Update(ctx, "the-interstellar-jurisdiction", codeMappingId, updateOrganizationCodeMappingParams) 206 | assert.NoError(t, err) 207 | expected := &OrganizationCodeMapping{ 208 | ID: codeMappingId, 209 | ProjectId: "7654321", 210 | ProjectSlug: "spoon-knife", 211 | RepoId: "456123", 212 | RepoName: "octocat/Spoon-Knife", 213 | IntegrationId: "123456", 214 | Provider: &OrganizationIntegrationProvider{ 215 | Key: "github", 216 | Slug: "github", 217 | Name: "GitHub", 218 | CanAdd: true, 219 | CanDisable: false, 220 | Features: []string{ 221 | "codeowners", 222 | "commits", 223 | "issue-basic", 224 | "stacktrace-link", 225 | }, 226 | }, 227 | StackRoot: "/", 228 | SourceRoot: "src/", 229 | DefaultBranch: "main", 230 | } 231 | assert.Equal(t, expected, codeMapping) 232 | } 233 | 234 | func TestOrganizationCodeMappingsService_Delete(t *testing.T) { 235 | client, mux, _, teardown := setup() 236 | defer teardown() 237 | 238 | codeMappingId := "54321" 239 | 240 | mux.HandleFunc(fmt.Sprintf("/api/0/organizations/the-interstellar-jurisdiction/code-mappings/%s/", codeMappingId), func(w http.ResponseWriter, r *http.Request) { 241 | assertMethod(t, "DELETE", r) 242 | w.WriteHeader(http.StatusNoContent) 243 | w.Header().Set("Content-Type", "application/json") 244 | }) 245 | 246 | ctx := context.Background() 247 | _, err := client.OrganizationCodeMappings.Delete(ctx, "the-interstellar-jurisdiction", codeMappingId) 248 | assert.NoError(t, err) 249 | } 250 | -------------------------------------------------------------------------------- /sentry/organization_integrations.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L22 11 | type OrganizationIntegrationProvider struct { 12 | Key string `json:"key"` 13 | Slug string `json:"slug"` 14 | Name string `json:"name"` 15 | CanAdd bool `json:"canAdd"` 16 | CanDisable bool `json:"canDisable"` 17 | Features []string `json:"features"` 18 | } 19 | 20 | // IntegrationConfigData for defining integration-specific configuration data. 21 | type IntegrationConfigData map[string]json.RawMessage 22 | 23 | // OrganizationIntegration represents an integration added for the organization. 24 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L93 25 | type OrganizationIntegration struct { 26 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L35 27 | ID string `json:"id"` 28 | Name string `json:"name"` 29 | Icon *string `json:"icon"` 30 | DomainName string `json:"domainName"` 31 | AccountType *string `json:"accountType"` 32 | Scopes []string `json:"scopes"` 33 | Status string `json:"status"` 34 | Provider OrganizationIntegrationProvider `json:"provider"` 35 | 36 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L138 37 | ConfigData json.RawMessage `json:"configData"` 38 | ExternalId string `json:"externalId"` 39 | OrganizationId int `json:"organizationId"` 40 | OrganizationIntegrationStatus string `json:"organizationIntegrationStatus"` 41 | GracePeriodEnd *time.Time `json:"gracePeriodEnd"` 42 | } 43 | 44 | // OrganizationIntegrationsService provides methods for accessing Sentry organization integrations API endpoints. 45 | // Paths: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/urls.py#L1236-L1245 46 | // Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/index.py 47 | // Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/details.py 48 | type OrganizationIntegrationsService service 49 | 50 | type ListOrganizationIntegrationsParams struct { 51 | ListCursorParams 52 | ProviderKey string `url:"provider_key,omitempty"` 53 | } 54 | 55 | // List organization integrations. 56 | func (s *OrganizationIntegrationsService) List(ctx context.Context, organizationSlug string, params *ListOrganizationIntegrationsParams) ([]*OrganizationIntegration, *Response, error) { 57 | u := fmt.Sprintf("0/organizations/%v/integrations/", organizationSlug) 58 | u, err := addQuery(u, params) 59 | if err != nil { 60 | return nil, nil, err 61 | } 62 | 63 | req, err := s.client.NewRequest("GET", u, nil) 64 | if err != nil { 65 | return nil, nil, err 66 | } 67 | 68 | integrations := []*OrganizationIntegration{} 69 | resp, err := s.client.Do(ctx, req, &integrations) 70 | if err != nil { 71 | return nil, resp, err 72 | } 73 | return integrations, resp, nil 74 | } 75 | 76 | // Get organization integration details. 77 | func (s *OrganizationIntegrationsService) Get(ctx context.Context, organizationSlug string, integrationID string) (*OrganizationIntegration, *Response, error) { 78 | u := fmt.Sprintf("0/organizations/%v/integrations/%v/", organizationSlug, integrationID) 79 | req, err := s.client.NewRequest("GET", u, nil) 80 | if err != nil { 81 | return nil, nil, err 82 | } 83 | 84 | integration := new(OrganizationIntegration) 85 | resp, err := s.client.Do(ctx, req, integration) 86 | if err != nil { 87 | return nil, resp, err 88 | } 89 | return integration, resp, nil 90 | } 91 | 92 | type UpdateConfigOrganizationIntegrationsParams = json.RawMessage 93 | 94 | // UpdateConfig - update configData for organization integration. 95 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/details.py#L94-L102 96 | func (s *OrganizationIntegrationsService) UpdateConfig(ctx context.Context, organizationSlug string, integrationID string, params *UpdateConfigOrganizationIntegrationsParams) (*Response, error) { 97 | u := fmt.Sprintf("0/organizations/%v/integrations/%v/", organizationSlug, integrationID) 98 | req, err := s.client.NewRequest("POST", u, params) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return s.client.Do(ctx, req, nil) 104 | } 105 | -------------------------------------------------------------------------------- /sentry/organization_integrations_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestOrganizationIntegrationsService_List(t *testing.T) { 14 | client, mux, _, teardown := setup() 15 | defer teardown() 16 | 17 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/integrations/", func(w http.ResponseWriter, r *http.Request) { 18 | assertMethod(t, "GET", r) 19 | assertQuery(t, map[string]string{"cursor": "100:-1:1", "provider_key": "github"}, r) 20 | w.Header().Set("Content-Type", "application/json") 21 | fmt.Fprint(w, `[ 22 | { 23 | "id": "123456", 24 | "name": "octocat", 25 | "icon": "https://avatars.githubusercontent.com/u/583231?v=4", 26 | "domainName": "github.com/octocat", 27 | "accountType": "Organization", 28 | "scopes": ["read", "write"], 29 | "status": "active", 30 | "provider": { 31 | "key": "github", 32 | "slug": "github", 33 | "name": "GitHub", 34 | "canAdd": true, 35 | "canDisable": false, 36 | "features": [ 37 | "codeowners", 38 | "commits", 39 | "issue-basic", 40 | "stacktrace-link" 41 | ], 42 | "aspects": {} 43 | }, 44 | "configOrganization": [], 45 | "configData": {}, 46 | "externalId": "87654321", 47 | "organizationId": 2, 48 | "organizationIntegrationStatus": "active", 49 | "gracePeriodEnd": null 50 | } 51 | ]`) 52 | }) 53 | 54 | ctx := context.Background() 55 | integrations, _, err := client.OrganizationIntegrations.List(ctx, "the-interstellar-jurisdiction", &ListOrganizationIntegrationsParams{ 56 | ListCursorParams: ListCursorParams{ 57 | Cursor: "100:-1:1", 58 | }, 59 | ProviderKey: "github", 60 | }) 61 | assert.NoError(t, err) 62 | expected := []*OrganizationIntegration{ 63 | { 64 | ID: "123456", 65 | Name: "octocat", 66 | Icon: String("https://avatars.githubusercontent.com/u/583231?v=4"), 67 | DomainName: "github.com/octocat", 68 | AccountType: String("Organization"), 69 | Scopes: []string{"read", "write"}, 70 | Status: "active", 71 | Provider: OrganizationIntegrationProvider{ 72 | Key: "github", 73 | Slug: "github", 74 | Name: "GitHub", 75 | CanAdd: true, 76 | CanDisable: false, 77 | Features: []string{ 78 | "codeowners", 79 | "commits", 80 | "issue-basic", 81 | "stacktrace-link", 82 | }, 83 | }, 84 | ConfigData: json.RawMessage("{}"), 85 | ExternalId: "87654321", 86 | OrganizationId: 2, 87 | OrganizationIntegrationStatus: "active", 88 | GracePeriodEnd: nil, 89 | }, 90 | } 91 | assert.Equal(t, expected, integrations) 92 | } 93 | 94 | func TestOrganizationIntegrationsService_Get(t *testing.T) { 95 | client, mux, _, teardown := setup() 96 | defer teardown() 97 | 98 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/integrations/456789/", func(w http.ResponseWriter, r *http.Request) { 99 | assertMethod(t, "GET", r) 100 | w.Header().Set("Content-Type", "application/json") 101 | fmt.Fprint(w, `{ 102 | "id": "456789", 103 | "name": "Interstellar PagerDuty", 104 | "icon": null, 105 | "domainName": "the-interstellar-jurisdiction", 106 | "accountType": null, 107 | "scopes": null, 108 | "status": "active", 109 | "provider": { 110 | "key": "pagerduty", 111 | "slug": "pagerduty", 112 | "name": "PagerDuty", 113 | "canAdd": true, 114 | "canDisable": false, 115 | "features": [ 116 | "alert-rule", 117 | "incident-management" 118 | ], 119 | "aspects": { 120 | "alerts": [ 121 | { 122 | "type": "info", 123 | "text": "The PagerDuty integration adds a new Alert Rule action to all projects. To enable automatic notifications sent to PagerDuty you must create a rule using the PagerDuty action in your project settings." 124 | } 125 | ] 126 | } 127 | }, 128 | "configOrganization": [ 129 | { 130 | "name": "service_table", 131 | "type": "table", 132 | "label": "PagerDuty services with the Sentry integration enabled", 133 | "help": "If services need to be updated, deleted, or added manually please do so here. Alert rules will need to be individually updated for any additions or deletions of services.", 134 | "addButtonText": "", 135 | "columnLabels": { 136 | "service": "Service", 137 | "integration_key": "Integration Key" 138 | }, 139 | "columnKeys": [ 140 | "service", 141 | "integration_key" 142 | ], 143 | "confirmDeleteMessage": "Any alert rules associated with this service will stop working. The rules will still exist but will show a removed service." 144 | } 145 | ], 146 | "configData": { 147 | "service_table": [ 148 | { 149 | "service": "testing123", 150 | "integration_key": "abc123xyz", 151 | "id": 22222 152 | } 153 | ] 154 | }, 155 | "externalId": "999999", 156 | "organizationId": 2, 157 | "organizationIntegrationStatus": "active", 158 | "gracePeriodEnd": null 159 | }`) 160 | }) 161 | 162 | ctx := context.Background() 163 | integration, _, err := client.OrganizationIntegrations.Get(ctx, "the-interstellar-jurisdiction", "456789") 164 | assert.NoError(t, err) 165 | expected := OrganizationIntegration{ 166 | ID: "456789", 167 | Name: "Interstellar PagerDuty", 168 | Icon: nil, 169 | DomainName: "the-interstellar-jurisdiction", 170 | AccountType: nil, 171 | Scopes: nil, 172 | Status: "active", 173 | Provider: OrganizationIntegrationProvider{ 174 | Key: "pagerduty", 175 | Slug: "pagerduty", 176 | Name: "PagerDuty", 177 | CanAdd: true, 178 | CanDisable: false, 179 | Features: []string{ 180 | "alert-rule", 181 | "incident-management", 182 | }, 183 | }, 184 | ConfigData: json.RawMessage(`{ 185 | "service_table": [ 186 | { 187 | "service": "testing123", 188 | "integration_key": "abc123xyz", 189 | "id": 22222 190 | } 191 | ] 192 | }`), 193 | ExternalId: "999999", 194 | OrganizationId: 2, 195 | OrganizationIntegrationStatus: "active", 196 | GracePeriodEnd: nil, 197 | } 198 | assert.Equal(t, &expected, integration) 199 | } 200 | 201 | func TestOrganizationIntegrationsService_UpdateConfig(t *testing.T) { 202 | client, mux, _, teardown := setup() 203 | defer teardown() 204 | 205 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/integrations/456789/", func(w http.ResponseWriter, r *http.Request) { 206 | assertMethod(t, "POST", r) 207 | w.Header().Set("Content-Type", "application/json") 208 | }) 209 | 210 | updateConfigOrganizationIntegrationsParams := UpdateConfigOrganizationIntegrationsParams(`{ 211 | "service_table": [ 212 | { 213 | "service": "testing123", 214 | "integration_key": "abc123xyz", 215 | "id": 22222 216 | }, 217 | { 218 | "service": "testing456", 219 | "integration_key": "efg456lmn", 220 | "id": "" 221 | } 222 | ] 223 | }`) 224 | ctx := context.Background() 225 | resp, err := client.OrganizationIntegrations.UpdateConfig(ctx, "the-interstellar-jurisdiction", "456789", &updateConfigOrganizationIntegrationsParams) 226 | assert.NoError(t, err) 227 | assert.Equal(t, int64(0), resp.ContentLength) 228 | } 229 | -------------------------------------------------------------------------------- /sentry/organization_members.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // OrganizationMember represents a User's membership to the organization. 10 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization_member/response.py#L57-L69 11 | type OrganizationMember struct { 12 | ID string `json:"id"` 13 | Email string `json:"email"` 14 | Name string `json:"name"` 15 | User User `json:"user"` 16 | OrgRole string `json:"orgRole"` 17 | OrgRoleList []OrganizationRoleListItem `json:"orgRoleList"` 18 | Pending bool `json:"pending"` 19 | Expired bool `json:"expired"` 20 | Flags map[string]bool `json:"flags"` 21 | DateCreated time.Time `json:"dateCreated"` 22 | InviteStatus string `json:"inviteStatus"` 23 | InviterName *string `json:"inviterName"` 24 | TeamRoleList []TeamRoleListItem `json:"teamRoleList"` 25 | TeamRoles []TeamRole `json:"teamRoles"` 26 | Teams []string `json:"teams"` 27 | } 28 | 29 | const ( 30 | OrganizationRoleBilling string = "billing" 31 | OrganizationRoleMember string = "member" 32 | OrganizationRoleManager string = "manager" 33 | OrganizationRoleOwner string = "owner" 34 | 35 | TeamRoleContributor string = "contributor" 36 | TeamRoleAdmin string = "admin" 37 | ) 38 | 39 | // OrganizationMembersService provides methods for accessing Sentry membership API endpoints. 40 | type OrganizationMembersService service 41 | 42 | // List organization members. 43 | func (s *OrganizationMembersService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*OrganizationMember, *Response, error) { 44 | u := fmt.Sprintf("0/organizations/%v/members/", organizationSlug) 45 | u, err := addQuery(u, params) 46 | if err != nil { 47 | return nil, nil, err 48 | } 49 | 50 | req, err := s.client.NewRequest("GET", u, nil) 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | 55 | members := []*OrganizationMember{} 56 | resp, err := s.client.Do(ctx, req, &members) 57 | if err != nil { 58 | return nil, resp, err 59 | } 60 | return members, resp, nil 61 | } 62 | 63 | func (s *OrganizationMembersService) Get(ctx context.Context, organizationSlug string, memberID string) (*OrganizationMember, *Response, error) { 64 | u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) 65 | req, err := s.client.NewRequest("GET", u, nil) 66 | if err != nil { 67 | return nil, nil, err 68 | } 69 | 70 | member := new(OrganizationMember) 71 | resp, err := s.client.Do(ctx, req, member) 72 | if err != nil { 73 | return nil, resp, err 74 | } 75 | return member, resp, nil 76 | } 77 | 78 | type CreateOrganizationMemberParams struct { 79 | Email string `json:"email"` 80 | Role string `json:"role"` 81 | Teams []string `json:"teams,omitempty"` 82 | } 83 | 84 | func (s *OrganizationMembersService) Create(ctx context.Context, organizationSlug string, params *CreateOrganizationMemberParams) (*OrganizationMember, *Response, error) { 85 | u := fmt.Sprintf("0/organizations/%v/members/", organizationSlug) 86 | req, err := s.client.NewRequest("POST", u, params) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | member := new(OrganizationMember) 92 | resp, err := s.client.Do(ctx, req, member) 93 | if err != nil { 94 | return nil, resp, err 95 | } 96 | return member, resp, nil 97 | } 98 | 99 | type TeamRole struct { 100 | TeamSlug string `json:"teamSlug"` 101 | Role *string `json:"role"` 102 | } 103 | 104 | type UpdateOrganizationMemberParams struct { 105 | OrganizationRole string `json:"role"` 106 | TeamRoles []TeamRole `json:"teamRoles"` 107 | } 108 | 109 | func (s *OrganizationMembersService) Update(ctx context.Context, organizationSlug string, memberID string, params *UpdateOrganizationMemberParams) (*OrganizationMember, *Response, error) { 110 | u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) 111 | req, err := s.client.NewRequest("PUT", u, params) 112 | if err != nil { 113 | return nil, nil, err 114 | } 115 | 116 | member := new(OrganizationMember) 117 | resp, err := s.client.Do(ctx, req, member) 118 | if err != nil { 119 | return nil, resp, err 120 | } 121 | return member, resp, nil 122 | } 123 | 124 | func (s *OrganizationMembersService) Delete(ctx context.Context, organizationSlug string, memberID string) (*Response, error) { 125 | u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) 126 | req, err := s.client.NewRequest("DELETE", u, nil) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | return s.client.Do(ctx, req, nil) 132 | } 133 | -------------------------------------------------------------------------------- /sentry/organization_projects.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type OrganizationProjectsService service 9 | 10 | type ListOrganizationProjectsParams struct { 11 | ListCursorParams 12 | 13 | Options string `url:"options,omitempty"` 14 | Query string `url:"query,omitempty"` 15 | } 16 | 17 | // List an Organization's Projects 18 | // https://docs.sentry.io/api/organizations/list-an-organizations-projects/ 19 | func (s *OrganizationProjectsService) List(ctx context.Context, organizationSlug string, params *ListOrganizationProjectsParams) ([]*Project, *Response, error) { 20 | u := fmt.Sprintf("0/organizations/%v/projects/", organizationSlug) 21 | u, err := addQuery(u, params) 22 | if err != nil { 23 | return nil, nil, err 24 | } 25 | 26 | req, err := s.client.NewRequest("GET", u, nil) 27 | if err != nil { 28 | return nil, nil, err 29 | } 30 | 31 | projects := []*Project{} 32 | resp, err := s.client.Do(ctx, req, &projects) 33 | if err != nil { 34 | return nil, resp, err 35 | } 36 | return projects, resp, nil 37 | } 38 | -------------------------------------------------------------------------------- /sentry/organization_repositories.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/repository.py#L12-L17 11 | type OrganizationRepositoryProvider struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | } 15 | 16 | // OrganizationRepositories represents 17 | // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/repository.py 18 | type OrganizationRepository struct { 19 | ID string `json:"id"` 20 | Name string `json:"name"` 21 | Url string `json:"url"` 22 | Provider OrganizationRepositoryProvider `json:"provider"` 23 | Status string `json:"status"` 24 | DateCreated time.Time `json:"dateCreated"` 25 | IntegrationId string `json:"integrationId"` 26 | ExternalSlug json.RawMessage `json:"externalSlug"` 27 | ExternalId string `json:"externalId"` 28 | } 29 | 30 | // OrganizationRepositoriesService provides methods for accessing Sentry organization repositories API endpoints. 31 | // Endpoints: https://github.com/getsentry/sentry/blob/24.10.0/src/sentry/integrations/api/endpoints/organization_repositories.py 32 | // Endpoints: https://github.com/getsentry/sentry/blob/24.10.0/src/sentry/integrations/api/endpoints/organization_repository_details.py 33 | type OrganizationRepositoriesService service 34 | 35 | type ListOrganizationRepositoriesParams struct { 36 | ListCursorParams 37 | IntegrationId string `url:"integration_id,omitempty"` 38 | // omitting status defaults to only active. 39 | // sending empty string shows everything, which is a more reasonable default. 40 | Status string `url:"status"` 41 | Query string `url:"query,omitempty"` 42 | } 43 | 44 | // List organization integrations. 45 | func (s *OrganizationRepositoriesService) List(ctx context.Context, organizationSlug string, params *ListOrganizationRepositoriesParams) ([]*OrganizationRepository, *Response, error) { 46 | u := fmt.Sprintf("0/organizations/%v/repos/", organizationSlug) 47 | u, err := addQuery(u, params) 48 | if err != nil { 49 | return nil, nil, err 50 | } 51 | 52 | req, err := s.client.NewRequest("GET", u, nil) 53 | if err != nil { 54 | return nil, nil, err 55 | } 56 | 57 | repos := []*OrganizationRepository{} 58 | resp, err := s.client.Do(ctx, req, &repos) 59 | if err != nil { 60 | return nil, resp, err 61 | } 62 | return repos, resp, nil 63 | } 64 | 65 | // Fields are different for different providers 66 | type CreateOrganizationRepositoryParams map[string]interface{} 67 | 68 | func (s *OrganizationRepositoriesService) Create(ctx context.Context, organizationSlug string, params CreateOrganizationRepositoryParams) (*OrganizationRepository, *Response, error) { 69 | u := fmt.Sprintf("0/organizations/%v/repos/", organizationSlug) 70 | req, err := s.client.NewRequest("POST", u, params) 71 | if err != nil { 72 | return nil, nil, err 73 | } 74 | 75 | repo := new(OrganizationRepository) 76 | resp, err := s.client.Do(ctx, req, repo) 77 | if err != nil { 78 | return nil, resp, err 79 | } 80 | return repo, resp, nil 81 | } 82 | 83 | func (s *OrganizationRepositoriesService) Delete(ctx context.Context, organizationSlug string, repoID string) (*OrganizationRepository, *Response, error) { 84 | u := fmt.Sprintf("0/organizations/%v/repos/%v/", organizationSlug, repoID) 85 | req, err := s.client.NewRequest("DELETE", u, nil) 86 | if err != nil { 87 | return nil, nil, err 88 | } 89 | 90 | repo := new(OrganizationRepository) 91 | resp, err := s.client.Do(ctx, req, repo) 92 | if err != nil { 93 | return nil, resp, err 94 | } 95 | return repo, resp, nil 96 | } 97 | -------------------------------------------------------------------------------- /sentry/organization_repositories_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestOrganizationRepositoriesService_List(t *testing.T) { 14 | client, mux, _, teardown := setup() 15 | defer teardown() 16 | 17 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/repos/", func(w http.ResponseWriter, r *http.Request) { 18 | assertMethod(t, "GET", r) 19 | assertQuery(t, map[string]string{"cursor": "100:-1:1", "status": "", "query": "foo"}, r) 20 | w.Header().Set("Content-Type", "application/json") 21 | fmt.Fprint(w, `[ 22 | { 23 | "id": "456123", 24 | "name": "octocat/Spoon-Knife", 25 | "url": "https://github.com/octocat/Spoon-Knife", 26 | "provider": { 27 | "id": "integrations:github", 28 | "name": "GitHub" 29 | }, 30 | "status": "active", 31 | "dateCreated": "2022-08-15T06:31:49.817916Z", 32 | "integrationId": "123456", 33 | "externalSlug": "aht4davchml6srhh6mvthluoscl2lzmi", 34 | "externalId": "123456" 35 | } 36 | ]`) 37 | }) 38 | 39 | ctx := context.Background() 40 | repos, _, err := client.OrganizationRepositories.List(ctx, "the-interstellar-jurisdiction", &ListOrganizationRepositoriesParams{ 41 | ListCursorParams: ListCursorParams{ 42 | Cursor: "100:-1:1", 43 | }, 44 | Query: "foo", 45 | }) 46 | assert.NoError(t, err) 47 | expected := []*OrganizationRepository{ 48 | { 49 | ID: "456123", 50 | Name: "octocat/Spoon-Knife", 51 | Url: "https://github.com/octocat/Spoon-Knife", 52 | Provider: OrganizationRepositoryProvider{ 53 | ID: "integrations:github", 54 | Name: "GitHub", 55 | }, 56 | Status: "active", 57 | DateCreated: mustParseTime("2022-08-15T06:31:49.817916Z"), 58 | IntegrationId: "123456", 59 | ExternalSlug: json.RawMessage(`"aht4davchml6srhh6mvthluoscl2lzmi"`), 60 | ExternalId: "123456", 61 | }, 62 | } 63 | assert.Equal(t, expected, repos) 64 | } 65 | 66 | func TestOrganizationRepositoriesService_Create(t *testing.T) { 67 | client, mux, _, teardown := setup() 68 | defer teardown() 69 | 70 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/repos/", func(w http.ResponseWriter, r *http.Request) { 71 | assertMethod(t, "POST", r) 72 | w.WriteHeader(http.StatusCreated) 73 | w.Header().Set("Content-Type", "application/json") 74 | fmt.Fprint(w, `{ 75 | "id": "456123", 76 | "name": "octocat/Spoon-Knife", 77 | "url": "https://github.com/octocat/Spoon-Knife", 78 | "provider": { 79 | "id": "integrations:github", 80 | "name": "GitHub" 81 | }, 82 | "status": "active", 83 | "dateCreated": "2022-08-15T06:31:49.817916Z", 84 | "integrationId": "123456", 85 | "externalSlug": "aht4davchml6srhh6mvthluoscl2lzmi", 86 | "externalId": "123456" 87 | }`) 88 | }) 89 | 90 | ctx := context.Background() 91 | createOrganizationRepositoryParams := CreateOrganizationRepositoryParams{ 92 | "installation": "123456", 93 | "identifier": "octocat/Spoon-Knife", 94 | "provider": "integrations:github", 95 | } 96 | repo, _, err := client.OrganizationRepositories.Create(ctx, "the-interstellar-jurisdiction", createOrganizationRepositoryParams) 97 | assert.NoError(t, err) 98 | expected := &OrganizationRepository{ 99 | ID: "456123", 100 | Name: "octocat/Spoon-Knife", 101 | Url: "https://github.com/octocat/Spoon-Knife", 102 | Provider: OrganizationRepositoryProvider{ 103 | ID: "integrations:github", 104 | Name: "GitHub", 105 | }, 106 | Status: "active", 107 | DateCreated: mustParseTime("2022-08-15T06:31:49.817916Z"), 108 | IntegrationId: "123456", 109 | ExternalSlug: json.RawMessage(`"aht4davchml6srhh6mvthluoscl2lzmi"`), 110 | ExternalId: "123456", 111 | } 112 | assert.Equal(t, expected, repo) 113 | } 114 | 115 | func TestOrganizationRepositoriesService_Delete(t *testing.T) { 116 | client, mux, _, teardown := setup() 117 | defer teardown() 118 | 119 | repoId := "456123" 120 | 121 | mux.HandleFunc(fmt.Sprintf("/api/0/organizations/the-interstellar-jurisdiction/repos/%s/", repoId), func(w http.ResponseWriter, r *http.Request) { 122 | assertMethod(t, "DELETE", r) 123 | w.WriteHeader(http.StatusAccepted) 124 | w.Header().Set("Content-Type", "application/json") 125 | fmt.Fprint(w, `{ 126 | "id": "456123", 127 | "name": "octocat/Spoon-Knife", 128 | "url": "https://github.com/octocat/Spoon-Knife", 129 | "provider": { 130 | "id": "integrations:github", 131 | "name": "GitHub" 132 | }, 133 | "status": "pending_deletion", 134 | "dateCreated": "2022-08-15T06:31:49.817916Z", 135 | "integrationId": "123456", 136 | "externalSlug": "aht4davchml6srhh6mvthluoscl2lzmi", 137 | "externalId": "123456" 138 | }`) 139 | }) 140 | 141 | ctx := context.Background() 142 | repo, _, err := client.OrganizationRepositories.Delete(ctx, "the-interstellar-jurisdiction", repoId) 143 | assert.NoError(t, err) 144 | expected := &OrganizationRepository{ 145 | ID: "456123", 146 | Name: "octocat/Spoon-Knife", 147 | Url: "https://github.com/octocat/Spoon-Knife", 148 | Provider: OrganizationRepositoryProvider{ 149 | ID: "integrations:github", 150 | Name: "GitHub", 151 | }, 152 | Status: "pending_deletion", 153 | DateCreated: mustParseTime("2022-08-15T06:31:49.817916Z"), 154 | IntegrationId: "123456", 155 | ExternalSlug: json.RawMessage(`"aht4davchml6srhh6mvthluoscl2lzmi"`), 156 | ExternalId: "123456", 157 | } 158 | assert.Equal(t, expected, repo) 159 | } 160 | -------------------------------------------------------------------------------- /sentry/organizations.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // OrganizationStatus represents a Sentry organization's status. 10 | type OrganizationStatus struct { 11 | ID *string `json:"id"` 12 | Name *string `json:"name"` 13 | } 14 | 15 | // OrganizationQuota represents a Sentry organization's quota. 16 | type OrganizationQuota struct { 17 | MaxRate *int `json:"maxRate"` 18 | MaxRateInterval *int `json:"maxRateInterval"` 19 | AccountLimit *int `json:"accountLimit"` 20 | ProjectLimit *int `json:"projectLimit"` 21 | } 22 | 23 | // OrganizationAvailableRole represents a Sentry organization's available role. 24 | type OrganizationAvailableRole struct { 25 | ID *string `json:"id"` 26 | Name *string `json:"name"` 27 | } 28 | 29 | // Organization represents detailed information about a Sentry organization. 30 | // Based on https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization.py#L263-L288 31 | type Organization struct { 32 | // Basic 33 | ID *string `json:"id,omitempty"` 34 | Slug *string `json:"slug,omitempty"` 35 | Status *OrganizationStatus `json:"status,omitempty"` 36 | Name *string `json:"name,omitempty"` 37 | DateCreated *time.Time `json:"dateCreated,omitempty"` 38 | IsEarlyAdopter *bool `json:"isEarlyAdopter,omitempty"` 39 | Require2FA *bool `json:"require2FA,omitempty"` 40 | RequireEmailVerification *bool `json:"requireEmailVerification,omitempty"` 41 | Avatar *Avatar `json:"avatar,omitempty"` 42 | Features []string `json:"features,omitempty"` 43 | 44 | // Detailed 45 | // TODO: experiments 46 | Quota *OrganizationQuota `json:"quota,omitempty"` 47 | IsDefault *bool `json:"isDefault,omitempty"` 48 | DefaultRole *string `json:"defaultRole,omitempty"` 49 | AvailableRoles []OrganizationAvailableRole `json:"availableRoles,omitempty"` 50 | OrgRoleList []OrganizationRoleListItem `json:"orgRoleList,omitempty"` 51 | TeamRoleList []TeamRoleListItem `json:"teamRoleList,omitempty"` 52 | OpenMembership *bool `json:"openMembership,omitempty"` 53 | AllowSharedIssues *bool `json:"allowSharedIssues,omitempty"` 54 | EnhancedPrivacy *bool `json:"enhancedPrivacy,omitempty"` 55 | DataScrubber *bool `json:"dataScrubber,omitempty"` 56 | DataScrubberDefaults *bool `json:"dataScrubberDefaults,omitempty"` 57 | SensitiveFields []string `json:"sensitiveFields,omitempty"` 58 | SafeFields []string `json:"safeFields,omitempty"` 59 | StoreCrashReports *int `json:"storeCrashReports,omitempty"` 60 | AttachmentsRole *string `json:"attachmentsRole,omitempty"` 61 | DebugFilesRole *string `json:"debugFilesRole,omitempty"` 62 | EventsMemberAdmin *bool `json:"eventsMemberAdmin,omitempty"` 63 | AlertsMemberWrite *bool `json:"alertsMemberWrite,omitempty"` 64 | ScrubIPAddresses *bool `json:"scrubIPAddresses,omitempty"` 65 | ScrapeJavaScript *bool `json:"scrapeJavaScript,omitempty"` 66 | AllowJoinRequests *bool `json:"allowJoinRequests,omitempty"` 67 | RelayPiiConfig *string `json:"relayPiiConfig,omitempty"` 68 | // TODO: trustedRelays 69 | Access []string `json:"access,omitempty"` 70 | Role *string `json:"role,omitempty"` 71 | PendingAccessRequests *int `json:"pendingAccessRequests,omitempty"` 72 | // TODO: onboardingTasks 73 | } 74 | 75 | // OrganizationsService provides methods for accessing Sentry organization API endpoints. 76 | // https://docs.sentry.io/api/organizations/ 77 | type OrganizationsService service 78 | 79 | // List organizations available to the authenticated session. 80 | // https://docs.sentry.io/api/organizations/list-your-organizations/ 81 | func (s *OrganizationsService) List(ctx context.Context, params *ListCursorParams) ([]*Organization, *Response, error) { 82 | u, err := addQuery("0/organizations/", params) 83 | if err != nil { 84 | return nil, nil, err 85 | } 86 | 87 | req, err := s.client.NewRequest("GET", u, nil) 88 | if err != nil { 89 | return nil, nil, err 90 | } 91 | 92 | orgs := []*Organization{} 93 | resp, err := s.client.Do(ctx, req, &orgs) 94 | if err != nil { 95 | return nil, resp, err 96 | } 97 | return orgs, resp, nil 98 | } 99 | 100 | // Get a Sentry organization. 101 | // https://docs.sentry.io/api/organizations/retrieve-an-organization/ 102 | func (s *OrganizationsService) Get(ctx context.Context, slug string) (*Organization, *Response, error) { 103 | u := fmt.Sprintf("0/organizations/%v/", slug) 104 | req, err := s.client.NewRequest("GET", u, nil) 105 | if err != nil { 106 | return nil, nil, err 107 | } 108 | 109 | org := new(Organization) 110 | resp, err := s.client.Do(ctx, req, org) 111 | if err != nil { 112 | return nil, resp, err 113 | } 114 | return org, resp, nil 115 | } 116 | 117 | // CreateOrganizationParams are the parameters for OrganizationService.Create. 118 | type CreateOrganizationParams struct { 119 | Name *string `json:"name,omitempty"` 120 | Slug *string `json:"slug,omitempty"` 121 | AgreeTerms *bool `json:"agreeTerms,omitempty"` 122 | } 123 | 124 | // Create a new Sentry organization. 125 | func (s *OrganizationsService) Create(ctx context.Context, params *CreateOrganizationParams) (*Organization, *Response, error) { 126 | u := "0/organizations/" 127 | req, err := s.client.NewRequest("POST", u, params) 128 | if err != nil { 129 | return nil, nil, err 130 | } 131 | 132 | org := new(Organization) 133 | resp, err := s.client.Do(ctx, req, org) 134 | if err != nil { 135 | return nil, resp, err 136 | } 137 | return org, resp, nil 138 | } 139 | 140 | // UpdateOrganizationParams are the parameters for OrganizationService.Update. 141 | type UpdateOrganizationParams struct { 142 | Name *string `json:"name,omitempty"` 143 | Slug *string `json:"slug,omitempty"` 144 | } 145 | 146 | // Update a Sentry organization. 147 | // https://docs.sentry.io/api/organizations/update-an-organization/ 148 | func (s *OrganizationsService) Update(ctx context.Context, slug string, params *UpdateOrganizationParams) (*Organization, *Response, error) { 149 | u := fmt.Sprintf("0/organizations/%v/", slug) 150 | req, err := s.client.NewRequest("PUT", u, params) 151 | if err != nil { 152 | return nil, nil, err 153 | } 154 | 155 | org := new(Organization) 156 | resp, err := s.client.Do(ctx, req, org) 157 | if err != nil { 158 | return nil, resp, err 159 | } 160 | return org, resp, nil 161 | } 162 | 163 | // Delete a Sentry organization. 164 | func (s *OrganizationsService) Delete(ctx context.Context, slug string) (*Response, error) { 165 | u := fmt.Sprintf("0/organizations/%v/", slug) 166 | req, err := s.client.NewRequest("DELETE", u, nil) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | return s.client.Do(ctx, req, nil) 172 | } 173 | -------------------------------------------------------------------------------- /sentry/project_filters.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // ProjectFilter represents inbounding filters applied to a project. 11 | type ProjectFilter struct { 12 | ID string `json:"id"` 13 | Active json.RawMessage `json:"active"` 14 | } 15 | 16 | // ProjectFiltersService provides methods for accessing Sentry project 17 | // filters API endpoints. 18 | type ProjectFiltersService service 19 | 20 | // Get the filters. 21 | func (s *ProjectFiltersService) Get(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectFilter, *Response, error) { 22 | url := fmt.Sprintf("0/projects/%v/%v/filters/", organizationSlug, projectSlug) 23 | req, err := s.client.NewRequest(http.MethodGet, url, nil) 24 | if err != nil { 25 | return nil, nil, err 26 | } 27 | 28 | var filters []*ProjectFilter 29 | resp, err := s.client.Do(ctx, req, &filters) 30 | if err != nil { 31 | return nil, resp, err 32 | } 33 | 34 | return filters, resp, nil 35 | } 36 | 37 | // FilterConfig represents configuration for project filter 38 | type FilterConfig struct { 39 | BrowserExtension bool 40 | LegacyBrowsers []string 41 | } 42 | 43 | // GetFilterConfig retrieves filter configuration. 44 | func (s *ProjectFiltersService) GetFilterConfig(ctx context.Context, organizationSlug string, projectSlug string) (*FilterConfig, *Response, error) { 45 | filters, resp, err := s.Get(ctx, organizationSlug, projectSlug) 46 | if err != nil { 47 | return nil, resp, err 48 | } 49 | 50 | var filterConfig FilterConfig 51 | 52 | for _, filter := range filters { 53 | switch filter.ID { 54 | case "browser-extensions": 55 | if string(filter.Active) == "true" { 56 | filterConfig.BrowserExtension = true 57 | } 58 | 59 | case "legacy-browsers": 60 | if string(filter.Active) != "false" { 61 | err = json.Unmarshal(filter.Active, &filterConfig.LegacyBrowsers) 62 | if err != nil { 63 | return nil, resp, err 64 | } 65 | } 66 | } 67 | } 68 | 69 | return &filterConfig, resp, err 70 | } 71 | 72 | // BrowserExtensionParams defines parameters for browser extension request 73 | type BrowserExtensionParams struct { 74 | Active bool `json:"active"` 75 | } 76 | 77 | // UpdateBrowserExtensions updates configuration for browser extension filter 78 | func (s *ProjectFiltersService) UpdateBrowserExtensions(ctx context.Context, organizationSlug string, projectSlug string, active bool) (*Response, error) { 79 | url := fmt.Sprintf("0/projects/%v/%v/filters/browser-extensions/", organizationSlug, projectSlug) 80 | params := BrowserExtensionParams{active} 81 | req, err := s.client.NewRequest(http.MethodPut, url, params) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return s.client.Do(ctx, req, nil) 87 | } 88 | 89 | // LegacyBrowserParams defines parameters for legacy browser request 90 | type LegacyBrowserParams struct { 91 | Browsers []string `json:"subfilters"` 92 | } 93 | 94 | // UpdateLegacyBrowser updates configuration for legacy browser filters 95 | func (s *ProjectFiltersService) UpdateLegacyBrowser(ctx context.Context, organizationSlug string, projectSlug string, browsers []string) (*Response, error) { 96 | url := fmt.Sprintf("0/projects/%v/%v/filters/legacy-browsers/", organizationSlug, projectSlug) 97 | params := LegacyBrowserParams{browsers} 98 | 99 | req, err := s.client.NewRequest(http.MethodPut, url, params) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return s.client.Do(ctx, req, nil) 105 | } 106 | 107 | type UpdateProjectFilterParams struct { 108 | Active bool `json:"active"` 109 | Subfilters []string `json:"subfilters"` 110 | } 111 | 112 | func (s *ProjectFiltersService) Update(ctx context.Context, organizationSlug string, projectSlug string, filterID string, params *UpdateProjectFilterParams) (*Response, error) { 113 | url := fmt.Sprintf("0/projects/%v/%v/filters/%v/", organizationSlug, projectSlug, filterID) 114 | req, err := s.client.NewRequest(http.MethodPut, url, params) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return s.client.Do(ctx, req, nil) 120 | } 121 | -------------------------------------------------------------------------------- /sentry/project_filters_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestProjectFiltersService_GetWithLegacyExtension(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/powerful-abolitionist/filters/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, getWithLegacyExtensionHeader) 20 | }) 21 | 22 | ctx := context.Background() 23 | filterConfig, _, err := client.ProjectFilters.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") 24 | assert.NoError(t, err) 25 | 26 | expected := FilterConfig{ 27 | LegacyBrowsers: []string{"ie_pre_9"}, 28 | BrowserExtension: false, 29 | } 30 | assert.Equal(t, &expected, filterConfig) 31 | } 32 | 33 | func TestProjectFiltersService_GetWithoutLegacyExtension(t *testing.T) { 34 | client, mux, _, teardown := setup() 35 | defer teardown() 36 | 37 | mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/powerful-abolitionist/filters/", func(w http.ResponseWriter, r *http.Request) { 38 | assertMethod(t, "GET", r) 39 | w.Header().Set("Content-Type", "application/json") 40 | fmt.Fprint(w, getWithoutLegacyExtensionHeader) 41 | }) 42 | 43 | ctx := context.Background() 44 | filterConfig, _, err := client.ProjectFilters.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") 45 | assert.NoError(t, err) 46 | 47 | expected := FilterConfig{ 48 | LegacyBrowsers: nil, 49 | BrowserExtension: true, 50 | } 51 | assert.Equal(t, &expected, filterConfig) 52 | } 53 | 54 | func TestBrowserExtensionFilter(t *testing.T) { 55 | client, mux, _, teardown := setup() 56 | defer teardown() 57 | 58 | mux.HandleFunc("/api/0/projects/test_org/test_project/filters/browser-extensions/", func(w http.ResponseWriter, r *http.Request) { 59 | assertMethod(t, "PUT", r) 60 | assertPostJSON(t, map[string]interface{}{ 61 | "active": true, 62 | }, r) 63 | w.Header().Set("Content-Type", "application/json") 64 | }) 65 | 66 | ctx := context.Background() 67 | _, err := client.ProjectFilters.UpdateBrowserExtensions(ctx, "test_org", "test_project", true) 68 | assert.NoError(t, err) 69 | } 70 | 71 | func TestLegacyBrowserFilter(t *testing.T) { 72 | client, mux, _, teardown := setup() 73 | defer teardown() 74 | 75 | mux.HandleFunc("/api/0/projects/test_org/test_project/filters/legacy-browsers/", func(w http.ResponseWriter, r *http.Request) { 76 | assertMethod(t, "PUT", r) 77 | assertPostJSON(t, map[string]interface{}{ 78 | "subfilters": []interface{}{"ie_pre_9", "ie10"}, 79 | }, r) 80 | w.Header().Set("Content-Type", "application/json") 81 | fmt.Fprintf(w, "") 82 | }) 83 | 84 | ctx := context.Background() 85 | browsers := []string{"ie_pre_9", "ie10"} 86 | _, err := client.ProjectFilters.UpdateLegacyBrowser(ctx, "test_org", "test_project", browsers) 87 | assert.NoError(t, err) 88 | } 89 | 90 | var ( 91 | getWithLegacyExtensionHeader = `[ 92 | { 93 | "id":"browser-extensions", 94 | "active":false, 95 | "description":"description_1", 96 | "name":"name_1", 97 | "hello":"hello_1" 98 | }, 99 | { 100 | "id":"localhost", 101 | "active":false, 102 | "description":"description_2", 103 | "name":"name_2", 104 | "hello":"hello_2" 105 | }, 106 | { 107 | "id":"legacy-browsers", 108 | "active":["ie_pre_9"], 109 | "description":"description_3", 110 | "name":"name_3", 111 | "hello":"hello_3" 112 | }, 113 | { 114 | "id":"web-crawlers", 115 | "active":true, 116 | "description":"description_4", 117 | "name":"name_4", 118 | "hello":"hello_4" 119 | } 120 | ]` 121 | getWithoutLegacyExtensionHeader = `[ 122 | { 123 | "id":"browser-extensions", 124 | "active":true, 125 | "description":"description_1", 126 | "name":"name_1", 127 | "hello":"hello_1" 128 | }, 129 | { 130 | "id":"localhost", 131 | "active":false, 132 | "description":"description_2", 133 | "name":"name_2", 134 | "hello":"hello_2" 135 | }, 136 | { 137 | "id":"legacy-browsers", 138 | "active":false, 139 | "description":"description_3", 140 | "name":"name_3", 141 | "hello":"hello_3" 142 | }, 143 | { 144 | "id":"web-crawlers", 145 | "active":true, 146 | "description":"description_4", 147 | "name":"name_4", 148 | "hello":"hello_4" 149 | } 150 | ]` 151 | ) 152 | 153 | func TestProjectFiltersService_Update(t *testing.T) { 154 | client, mux, _, teardown := setup() 155 | defer teardown() 156 | 157 | mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/powerful-abolitionist/filters/filter-id/", func(w http.ResponseWriter, r *http.Request) { 158 | assertMethod(t, http.MethodPut, r) 159 | assertPostJSON(t, map[string]interface{}{ 160 | "active": true, 161 | "subfilters": []interface{}{"ie_pre_9", "ie9"}, 162 | }, r) 163 | }) 164 | 165 | params := &UpdateProjectFilterParams{ 166 | Active: true, 167 | Subfilters: []string{"ie_pre_9", "ie9"}, 168 | } 169 | ctx := context.Background() 170 | _, err := client.ProjectFilters.Update(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist", "filter-id", params) 171 | assert.NoError(t, err) 172 | 173 | } 174 | -------------------------------------------------------------------------------- /sentry/project_inbound_data_filters.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ProjectInboundDataFilter struct { 9 | ID string `json:"id"` 10 | Active BoolOrStringSlice `json:"active"` 11 | } 12 | 13 | type ProjectInboundDataFiltersService service 14 | 15 | func (s *ProjectInboundDataFiltersService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectInboundDataFilter, *Response, error) { 16 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/filters/" 17 | req, err := s.client.NewRequest(http.MethodGet, u, nil) 18 | if err != nil { 19 | return nil, nil, err 20 | } 21 | 22 | filters := []*ProjectInboundDataFilter{} 23 | resp, err := s.client.Do(ctx, req, &filters) 24 | if err != nil { 25 | return nil, resp, err 26 | } 27 | return filters, resp, nil 28 | } 29 | 30 | type UpdateProjectInboundDataFilterParams struct { 31 | Active *bool `json:"active,omitempty"` 32 | Subfilters []string `json:"subfilters,omitempty"` 33 | } 34 | 35 | func (s *ProjectInboundDataFiltersService) Update(ctx context.Context, organizationSlug string, projectSlug string, filterID string, params *UpdateProjectInboundDataFilterParams) (*Response, error) { 36 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/filters/" + filterID + "/" 37 | req, err := s.client.NewRequest(http.MethodPut, u, params) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return s.client.Do(ctx, req, nil) 42 | } 43 | -------------------------------------------------------------------------------- /sentry/project_inbound_data_filters_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestProjectInboundDataFiltersService_List(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, http.MethodGet, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `[ 20 | { 21 | "id": "browser-extensions", 22 | "active": false 23 | }, 24 | { 25 | "id": "filtered-transaction", 26 | "active": true 27 | }, 28 | { 29 | "id": "legacy-browsers", 30 | "active": [ 31 | "ie_pre_9" 32 | ] 33 | }, 34 | { 35 | "id": "localhost", 36 | "active": false 37 | }, 38 | { 39 | "id": "web-crawlers", 40 | "active": false 41 | } 42 | ]`) 43 | }) 44 | 45 | ctx := context.Background() 46 | filters, _, err := client.ProjectInboundDataFilters.List(ctx, "organization_slug", "project_slug") 47 | assert.NoError(t, err) 48 | 49 | expected := []*ProjectInboundDataFilter{ 50 | { 51 | ID: "browser-extensions", 52 | Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, 53 | }, 54 | { 55 | ID: "filtered-transaction", 56 | Active: BoolOrStringSlice{IsBool: true, BoolVal: true}, 57 | }, 58 | { 59 | ID: "legacy-browsers", 60 | Active: BoolOrStringSlice{IsStringSlice: true, StringSliceVal: []string{"ie_pre_9"}}, 61 | }, 62 | { 63 | ID: "localhost", 64 | Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, 65 | }, 66 | { 67 | ID: "web-crawlers", 68 | Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, 69 | }, 70 | } 71 | assert.Equal(t, expected, filters) 72 | } 73 | 74 | func TestProjectInboundDataFiltersService_UpdateActive(t *testing.T) { 75 | client, mux, _, teardown := setup() 76 | defer teardown() 77 | 78 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/filter_id/", func(w http.ResponseWriter, r *http.Request) { 79 | assertMethod(t, http.MethodPut, r) 80 | assertPostJSON(t, map[string]interface{}{ 81 | "active": true, 82 | }, r) 83 | }) 84 | 85 | ctx := context.Background() 86 | params := &UpdateProjectInboundDataFilterParams{ 87 | Active: Bool(true), 88 | } 89 | _, err := client.ProjectInboundDataFilters.Update(ctx, "organization_slug", "project_slug", "filter_id", params) 90 | assert.NoError(t, err) 91 | } 92 | 93 | func TestProjectInboundDataFiltersService_UpdateSubfilters(t *testing.T) { 94 | client, mux, _, teardown := setup() 95 | defer teardown() 96 | 97 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/filter_id/", func(w http.ResponseWriter, r *http.Request) { 98 | assertMethod(t, http.MethodPut, r) 99 | assertPostJSON(t, map[string]interface{}{ 100 | "subfilters": []interface{}{"ie_pre_9"}, 101 | }, r) 102 | }) 103 | 104 | ctx := context.Background() 105 | params := &UpdateProjectInboundDataFilterParams{ 106 | Subfilters: []string{"ie_pre_9"}, 107 | } 108 | _, err := client.ProjectInboundDataFilters.Update(ctx, "organization_slug", "project_slug", "filter_id", params) 109 | assert.NoError(t, err) 110 | } 111 | -------------------------------------------------------------------------------- /sentry/project_keys.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // ProjectKeyRateLimit represents a project key's rate limit. 11 | type ProjectKeyRateLimit struct { 12 | Window int `json:"window"` 13 | Count int `json:"count"` 14 | } 15 | 16 | // ProjectKeyDSN represents a project key's DSN. 17 | type ProjectKeyDSN struct { 18 | Secret string `json:"secret"` 19 | Public string `json:"public"` 20 | CSP string `json:"csp"` 21 | Security string `json:"security"` 22 | Minidump string `json:"minidump"` 23 | NEL string `json:"nel"` 24 | Unreal string `json:"unreal"` 25 | CDN string `json:"cdn"` 26 | Crons string `json:"crons"` 27 | } 28 | 29 | type ProjectKeyDynamicSDKLoaderOptions struct { 30 | HasReplay bool `json:"hasReplay"` 31 | HasPerformance bool `json:"hasPerformance"` 32 | HasDebugFiles bool `json:"hasDebug"` 33 | } 34 | 35 | // ProjectKey represents a client key bound to a project. 36 | // https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/project_key.py 37 | type ProjectKey struct { 38 | ID string `json:"id"` 39 | Name string `json:"name"` 40 | Label string `json:"label"` 41 | Public string `json:"public"` 42 | Secret string `json:"secret"` 43 | ProjectID json.Number `json:"projectId"` 44 | IsActive bool `json:"isActive"` 45 | RateLimit *ProjectKeyRateLimit `json:"rateLimit"` 46 | DSN ProjectKeyDSN `json:"dsn"` 47 | BrowserSDKVersion string `json:"browserSdkVersion"` 48 | DateCreated time.Time `json:"dateCreated"` 49 | DynamicSDKLoaderOptions ProjectKeyDynamicSDKLoaderOptions `json:"dynamicSdkLoaderOptions"` 50 | } 51 | 52 | // ProjectKeysService provides methods for accessing Sentry project 53 | // client key API endpoints. 54 | // https://docs.sentry.io/api/projects/ 55 | type ProjectKeysService service 56 | 57 | type ListProjectKeysParams struct { 58 | ListCursorParams 59 | 60 | Status *string `url:"status,omitempty"` 61 | } 62 | 63 | // List client keys bound to a project. 64 | // https://docs.sentry.io/api/projects/get-project-keys/ 65 | func (s *ProjectKeysService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListProjectKeysParams) ([]*ProjectKey, *Response, error) { 66 | u := fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug) 67 | u, err := addQuery(u, params) 68 | if err != nil { 69 | return nil, nil, err 70 | } 71 | 72 | req, err := s.client.NewRequest("GET", u, nil) 73 | if err != nil { 74 | return nil, nil, err 75 | } 76 | 77 | projectKeys := []*ProjectKey{} 78 | resp, err := s.client.Do(ctx, req, &projectKeys) 79 | if err != nil { 80 | return nil, resp, err 81 | } 82 | return projectKeys, resp, nil 83 | } 84 | 85 | // Get details of a client key. 86 | // https://docs.sentry.io/api/projects/retrieve-a-client-key/ 87 | func (s *ProjectKeysService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*ProjectKey, *Response, error) { 88 | u := fmt.Sprintf("0/projects/%v/%v/keys/%v/", organizationSlug, projectSlug, id) 89 | req, err := s.client.NewRequest("GET", u, nil) 90 | if err != nil { 91 | return nil, nil, err 92 | } 93 | 94 | projectKey := new(ProjectKey) 95 | resp, err := s.client.Do(ctx, req, projectKey) 96 | if err != nil { 97 | return nil, resp, err 98 | } 99 | return projectKey, resp, nil 100 | } 101 | 102 | // CreateProjectKeyParams are the parameters for ProjectKeyService.Create. 103 | type CreateProjectKeyParams struct { 104 | Name string `json:"name,omitempty"` 105 | RateLimit *ProjectKeyRateLimit `json:"rateLimit,omitempty"` 106 | } 107 | 108 | // Create a new client key bound to a project. 109 | // https://docs.sentry.io/api/projects/post-project-keys/ 110 | func (s *ProjectKeysService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateProjectKeyParams) (*ProjectKey, *Response, error) { 111 | u := fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug) 112 | req, err := s.client.NewRequest("POST", u, params) 113 | if err != nil { 114 | return nil, nil, err 115 | } 116 | 117 | projectKey := new(ProjectKey) 118 | resp, err := s.client.Do(ctx, req, projectKey) 119 | if err != nil { 120 | return nil, resp, err 121 | } 122 | return projectKey, resp, nil 123 | } 124 | 125 | // UpdateProjectKeyParams are the parameters for ProjectKeyService.Update. 126 | type UpdateProjectKeyParams struct { 127 | Name string `json:"name,omitempty"` 128 | RateLimit *ProjectKeyRateLimit `json:"rateLimit,omitempty"` 129 | } 130 | 131 | // Update a client key. 132 | // https://docs.sentry.io/api/projects/put-project-key-details/ 133 | func (s *ProjectKeysService) Update(ctx context.Context, organizationSlug string, projectSlug string, keyID string, params *UpdateProjectKeyParams) (*ProjectKey, *Response, error) { 134 | u := fmt.Sprintf("0/projects/%v/%v/keys/%v/", organizationSlug, projectSlug, keyID) 135 | req, err := s.client.NewRequest("PUT", u, params) 136 | if err != nil { 137 | return nil, nil, err 138 | } 139 | 140 | projectKey := new(ProjectKey) 141 | resp, err := s.client.Do(ctx, req, projectKey) 142 | if err != nil { 143 | return nil, resp, err 144 | } 145 | return projectKey, resp, nil 146 | } 147 | 148 | // Delete a project. 149 | // https://docs.sentry.io/api/projects/delete-project-details/ 150 | func (s *ProjectKeysService) Delete(ctx context.Context, organizationSlug string, projectSlug string, keyID string) (*Response, error) { 151 | u := fmt.Sprintf("0/projects/%v/%v/keys/%v/", organizationSlug, projectSlug, keyID) 152 | req, err := s.client.NewRequest("DELETE", u, nil) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | return s.client.Do(ctx, req, nil) 158 | } 159 | -------------------------------------------------------------------------------- /sentry/project_ownerships.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // https://github.com/getsentry/sentry/blob/master/src/sentry/api/serializers/models/projectownership.py 10 | type ProjectOwnership struct { 11 | Raw string `json:"raw"` 12 | FallThrough bool `json:"fallthrough"` 13 | DateCreated time.Time `json:"dateCreated"` 14 | LastUpdated time.Time `json:"lastUpdated"` 15 | IsActive bool `json:"isActive"` 16 | AutoAssignment string `json:"autoAssignment"` 17 | CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` 18 | } 19 | 20 | // ProjectOwnershipsService provides methods for accessing Sentry project 21 | // ownership API endpoints. 22 | type ProjectOwnershipsService service 23 | 24 | // Get details on a project's ownership configuration. 25 | func (s *ProjectOwnershipsService) Get(ctx context.Context, organizationSlug string, projectSlug string) (*ProjectOwnership, *Response, error) { 26 | u := fmt.Sprintf("0/projects/%v/%v/ownership/", organizationSlug, projectSlug) 27 | req, err := s.client.NewRequest("GET", u, nil) 28 | if err != nil { 29 | return nil, nil, err 30 | } 31 | 32 | owner := new(ProjectOwnership) 33 | resp, err := s.client.Do(ctx, req, owner) 34 | if err != nil { 35 | return nil, resp, err 36 | } 37 | return owner, resp, nil 38 | } 39 | 40 | // CreateProjectParams are the parameters for ProjectOwnershipService.Update. 41 | type UpdateProjectOwnershipParams struct { 42 | Raw string `json:"raw,omitempty"` 43 | FallThrough *bool `json:"fallthrough,omitempty"` 44 | AutoAssignment *string `json:"autoAssignment,omitempty"` 45 | CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` 46 | } 47 | 48 | // Update a Project's Ownership configuration 49 | func (s *ProjectOwnershipsService) Update(ctx context.Context, organizationSlug string, projectSlug string, params *UpdateProjectOwnershipParams) (*ProjectOwnership, *Response, error) { 50 | u := fmt.Sprintf("0/projects/%v/%v/ownership/", organizationSlug, projectSlug) 51 | req, err := s.client.NewRequest("PUT", u, params) 52 | if err != nil { 53 | return nil, nil, err 54 | } 55 | 56 | owner := new(ProjectOwnership) 57 | resp, err := s.client.Do(ctx, req, owner) 58 | if err != nil { 59 | return nil, resp, err 60 | } 61 | return owner, resp, nil 62 | } 63 | -------------------------------------------------------------------------------- /sentry/project_ownerships_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestProjectOwnershipsService_Get(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/powerful-abolitionist/ownership/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `{ 20 | "raw": "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 21 | "fallthrough": false, 22 | "dateCreated": "2021-11-18T13:09:16.819818Z", 23 | "lastUpdated": "2022-03-01T14:00:31.317734Z", 24 | "isActive": true, 25 | "autoAssignment": "Auto Assign to Issue Owner", 26 | "codeownersAutoSync": null 27 | }`) 28 | }) 29 | 30 | ctx := context.Background() 31 | ownership, _, err := client.ProjectOwnerships.Get(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") 32 | assert.NoError(t, err) 33 | 34 | expected := &ProjectOwnership{ 35 | Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 36 | FallThrough: false, 37 | IsActive: true, 38 | AutoAssignment: "Auto Assign to Issue Owner", 39 | CodeownersAutoSync: nil, 40 | DateCreated: mustParseTime("2021-11-18T13:09:16.819818Z"), 41 | LastUpdated: mustParseTime("2022-03-01T14:00:31.317734Z"), 42 | } 43 | 44 | assert.Equal(t, expected, ownership) 45 | } 46 | 47 | func TestProjectOwnershipsService_Update(t *testing.T) { 48 | client, mux, _, teardown := setup() 49 | defer teardown() 50 | 51 | mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/the-obese-philosophers/ownership/", func(w http.ResponseWriter, r *http.Request) { 52 | assertMethod(t, "PUT", r) 53 | assertPostJSON(t, map[string]interface{}{ 54 | "raw": "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 55 | }, r) 56 | w.Header().Set("Content-Type", "application/json") 57 | fmt.Fprint(w, `{ 58 | "raw": "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 59 | "fallthrough": false, 60 | "dateCreated": "2021-11-18T13:09:16.819818Z", 61 | "lastUpdated": "2022-03-01T14:00:31.317734Z", 62 | "isActive": true, 63 | "autoAssignment": "Auto Assign to Issue Owner", 64 | "codeownersAutoSync": null 65 | }`) 66 | }) 67 | 68 | params := &UpdateProjectOwnershipParams{ 69 | Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 70 | } 71 | ctx := context.Background() 72 | ownership, _, err := client.ProjectOwnerships.Update(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers", params) 73 | assert.NoError(t, err) 74 | expected := &ProjectOwnership{ 75 | Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", 76 | FallThrough: false, 77 | IsActive: true, 78 | AutoAssignment: "Auto Assign to Issue Owner", 79 | CodeownersAutoSync: nil, 80 | DateCreated: mustParseTime("2021-11-18T13:09:16.819818Z"), 81 | LastUpdated: mustParseTime("2022-03-01T14:00:31.317734Z"), 82 | } 83 | assert.Equal(t, expected, ownership) 84 | } 85 | -------------------------------------------------------------------------------- /sentry/project_plugins.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | // ProjectPluginAsset represents an asset of a plugin. 10 | type ProjectPluginAsset struct { 11 | URL string `json:"url"` 12 | } 13 | 14 | // ProjectPluginConfig represents the configuration of a plugin. 15 | // Based on https://github.com/getsentry/sentry/blob/96bc1c63df5ec73fe12c136ada11561bf52f1ec9/src/sentry/api/serializers/models/plugin.py#L62-L94. 16 | type ProjectPluginConfig struct { 17 | Name string `json:"name"` 18 | Label string `json:"label"` 19 | Type string `json:"type"` 20 | Required bool `json:"required"` 21 | Help string `json:"help"` 22 | Placeholder string `json:"placeholder"` 23 | Choices json.RawMessage `json:"choices"` 24 | ReadOnly bool `json:"readonly"` 25 | DefaultValue interface{} `json:"defaultValue"` 26 | Value interface{} `json:"value"` 27 | } 28 | 29 | // ProjectPlugin represents a plugin bound to a project. 30 | // Based on https://github.com/getsentry/sentry/blob/96bc1c63df5ec73fe12c136ada11561bf52f1ec9/src/sentry/api/serializers/models/plugin.py#L11. 31 | type ProjectPlugin struct { 32 | ID string `json:"id"` 33 | Name string `json:"name"` 34 | Type string `json:"type"` 35 | CanDisable bool `json:"canDisable"` 36 | IsTestable bool `json:"isTestable"` 37 | Metadata map[string]interface{} `json:"metadata"` 38 | Contexts []string `json:"contexts"` 39 | Status string `json:"status"` 40 | Assets []ProjectPluginAsset `json:"assets"` 41 | Doc string `json:"doc"` 42 | Config []ProjectPluginConfig `json:"config"` 43 | } 44 | 45 | // ProjectPluginsService provides methods for accessing Sentry project 46 | // plugin API endpoints. 47 | type ProjectPluginsService service 48 | 49 | // List plugins bound to a project. 50 | func (s *ProjectPluginsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectPlugin, *Response, error) { 51 | u := fmt.Sprintf("0/projects/%v/%v/plugins/", organizationSlug, projectSlug) 52 | req, err := s.client.NewRequest("GET", u, nil) 53 | if err != nil { 54 | return nil, nil, err 55 | } 56 | 57 | projectPlugins := []*ProjectPlugin{} 58 | resp, err := s.client.Do(ctx, req, &projectPlugins) 59 | if err != nil { 60 | return nil, resp, err 61 | } 62 | return projectPlugins, resp, nil 63 | } 64 | 65 | // Get details of a project plugin. 66 | func (s *ProjectPluginsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*ProjectPlugin, *Response, error) { 67 | u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) 68 | req, err := s.client.NewRequest("GET", u, nil) 69 | if err != nil { 70 | return nil, nil, err 71 | } 72 | 73 | projectPlugin := new(ProjectPlugin) 74 | resp, err := s.client.Do(ctx, req, projectPlugin) 75 | if err != nil { 76 | return nil, resp, err 77 | } 78 | return projectPlugin, resp, nil 79 | } 80 | 81 | // UpdateProjectPluginParams are the parameters for TeamService.Update. 82 | type UpdateProjectPluginParams map[string]interface{} 83 | 84 | // Update settings for a given team. 85 | // https://docs.sentry.io/api/teams/put-team-details/ 86 | func (s *ProjectPluginsService) Update(ctx context.Context, organizationSlug string, projectSlug string, id string, params UpdateProjectPluginParams) (*ProjectPlugin, *Response, error) { 87 | u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) 88 | req, err := s.client.NewRequest("PUT", u, params) 89 | if err != nil { 90 | return nil, nil, err 91 | } 92 | 93 | projectPlugin := new(ProjectPlugin) 94 | resp, err := s.client.Do(ctx, req, projectPlugin) 95 | if err != nil { 96 | return nil, resp, err 97 | } 98 | return projectPlugin, resp, nil 99 | } 100 | 101 | // Enable a project plugin. 102 | func (s *ProjectPluginsService) Enable(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { 103 | u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) 104 | req, err := s.client.NewRequest("POST", u, nil) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | return s.client.Do(ctx, req, nil) 110 | } 111 | 112 | // Disable a project plugin. 113 | func (s *ProjectPluginsService) Disable(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { 114 | u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) 115 | req, err := s.client.NewRequest("DELETE", u, nil) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | return s.client.Do(ctx, req, nil) 121 | } 122 | -------------------------------------------------------------------------------- /sentry/project_symbol_sources.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ProjectSymbolSourceLayout struct { 9 | Type *string `json:"type"` 10 | Casing *string `json:"casing"` 11 | } 12 | 13 | type ProjectSymbolSourceHiddenSecret struct { 14 | HiddenSecret *bool `json:"hidden-secret"` 15 | } 16 | 17 | type ProjectSymbolSource struct { 18 | ID *string `json:"id"` 19 | Type *string `json:"type"` 20 | Name *string `json:"name"` 21 | Layout *ProjectSymbolSourceLayout `json:"layout"` 22 | 23 | AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` 24 | AppConnectPrivateKey *ProjectSymbolSourceHiddenSecret `json:"appconnectPrivateKey,omitempty"` 25 | AppId *string `json:"appId,omitempty"` 26 | Url *string `json:"url,omitempty"` 27 | Username *string `json:"username,omitempty"` 28 | Password *ProjectSymbolSourceHiddenSecret `json:"password,omitempty"` 29 | Bucket *string `json:"bucket,omitempty"` 30 | Region *string `json:"region,omitempty"` 31 | AccessKey *string `json:"access_key,omitempty"` 32 | SecretKey *ProjectSymbolSourceHiddenSecret `json:"secret_key,omitempty"` 33 | Prefix *string `json:"prefix,omitempty"` 34 | ClientEmail *string `json:"client_email,omitempty"` 35 | PrivateKey *ProjectSymbolSourceHiddenSecret `json:"private_key,omitempty"` 36 | } 37 | 38 | type ProjectSymbolSourcesService service 39 | 40 | type ProjectSymbolSourceQueryParams struct { 41 | ID *string `url:"id,omitempty"` 42 | } 43 | 44 | func (s *ProjectSymbolSourcesService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ProjectSymbolSourceQueryParams) ([]*ProjectSymbolSource, *Response, error) { 45 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" 46 | u, err := addQuery(u, params) 47 | if err != nil { 48 | return nil, nil, err 49 | } 50 | 51 | req, err := s.client.NewRequest(http.MethodGet, u, nil) 52 | if err != nil { 53 | return nil, nil, err 54 | } 55 | 56 | filters := []*ProjectSymbolSource{} 57 | resp, err := s.client.Do(ctx, req, &filters) 58 | if err != nil { 59 | return nil, resp, err 60 | } 61 | return filters, resp, nil 62 | } 63 | 64 | type CreateProjectSymbolSourceParams struct { 65 | Type *string `json:"type"` 66 | Name *string `json:"name"` 67 | Layout *ProjectSymbolSourceLayout `json:"layout"` 68 | 69 | AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` 70 | AppConnectPrivateKey *string `json:"appconnectPrivateKey,omitempty"` 71 | AppId *string `json:"appId,omitempty"` 72 | Url *string `json:"url,omitempty"` 73 | Username *string `json:"username,omitempty"` 74 | Password *string `json:"password,omitempty"` 75 | Bucket *string `json:"bucket,omitempty"` 76 | Region *string `json:"region,omitempty"` 77 | AccessKey *string `json:"access_key,omitempty"` 78 | SecretKey *string `json:"secret_key,omitempty"` 79 | Prefix *string `json:"prefix,omitempty"` 80 | ClientEmail *string `json:"client_email,omitempty"` 81 | PrivateKey *string `json:"private_key,omitempty"` 82 | } 83 | 84 | func (s *ProjectSymbolSourcesService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateProjectSymbolSourceParams) (*ProjectSymbolSource, *Response, error) { 85 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" 86 | req, err := s.client.NewRequest(http.MethodPost, u, params) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | filter := &ProjectSymbolSource{} 92 | resp, err := s.client.Do(ctx, req, filter) 93 | if err != nil { 94 | return nil, resp, err 95 | } 96 | return filter, resp, nil 97 | } 98 | 99 | type UpdateProjectSymbolSourceParams struct { 100 | ID *string `json:"id"` 101 | Type *string `json:"type"` 102 | Name *string `json:"name"` 103 | Layout *ProjectSymbolSourceLayout `json:"layout"` 104 | 105 | AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` 106 | AppConnectPrivateKey *string `json:"appconnectPrivateKey,omitempty"` 107 | AppId *string `json:"appId,omitempty"` 108 | Url *string `json:"url,omitempty"` 109 | Username *string `json:"username,omitempty"` 110 | Password *string `json:"password,omitempty"` 111 | Bucket *string `json:"bucket,omitempty"` 112 | Region *string `json:"region,omitempty"` 113 | AccessKey *string `json:"access_key,omitempty"` 114 | SecretKey *string `json:"secret_key,omitempty"` 115 | Prefix *string `json:"prefix,omitempty"` 116 | ClientEmail *string `json:"client_email,omitempty"` 117 | PrivateKey *string `json:"private_key,omitempty"` 118 | } 119 | 120 | func (s *ProjectSymbolSourcesService) Update(ctx context.Context, organizationSlug string, projectSlug string, symbolSourceId string, params *UpdateProjectSymbolSourceParams) (*ProjectSymbolSource, *Response, error) { 121 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" 122 | u, err := addQuery(u, &ProjectSymbolSourceQueryParams{ 123 | ID: String(symbolSourceId), 124 | }) 125 | if err != nil { 126 | return nil, nil, err 127 | } 128 | 129 | req, err := s.client.NewRequest(http.MethodPut, u, params) 130 | if err != nil { 131 | return nil, nil, err 132 | } 133 | 134 | filter := &ProjectSymbolSource{} 135 | resp, err := s.client.Do(ctx, req, filter) 136 | if err != nil { 137 | return nil, resp, err 138 | } 139 | return filter, resp, nil 140 | } 141 | 142 | func (s *ProjectSymbolSourcesService) Delete(ctx context.Context, organizationSlug string, projectSlug string, symbolSourceId string) (*Response, error) { 143 | u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" 144 | u, err := addQuery(u, &ProjectSymbolSourceQueryParams{ 145 | ID: String(symbolSourceId), 146 | }) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | req, err := s.client.NewRequest(http.MethodDelete, u, nil) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return s.client.Do(ctx, req, nil) 157 | } 158 | -------------------------------------------------------------------------------- /sentry/project_symbol_sources_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestProjectSymbolSourcesService_List(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, http.MethodGet, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `[ 20 | { 21 | "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", 22 | "name": "s3", 23 | "bucket": "bucket", 24 | "region": "us-east-2", 25 | "access_key": "access_key", 26 | "type": "s3", 27 | "layout": { 28 | "casing": "default", 29 | "type": "native" 30 | }, 31 | "secret_key": { 32 | "hidden-secret": true 33 | } 34 | }, 35 | { 36 | "private_key": { 37 | "hidden-secret": true 38 | }, 39 | "id": "f9df862d-45f7-496c-bf9b-ecade4c9f136", 40 | "layout": { 41 | "type": "native", 42 | "casing": "default" 43 | }, 44 | "name": "gcs", 45 | "bucket": "gcs-bucket-name", 46 | "client_email": "test@example.com", 47 | "type": "gcs" 48 | }, 49 | { 50 | "id": "1ccb6083-91ac-4394-a276-40fe0bb10ece", 51 | "name": "http", 52 | "url": "https://example.com", 53 | "layout": { 54 | "type": "native", 55 | "casing": "default" 56 | }, 57 | "username": "admin", 58 | "password": { 59 | "hidden-secret": true 60 | }, 61 | "type": "http" 62 | } 63 | ]`) 64 | }) 65 | 66 | ctx := context.Background() 67 | sources, _, err := client.ProjectSymbolSources.List(ctx, "organization_slug", "project_slug", nil) 68 | assert.NoError(t, err) 69 | 70 | expected := []*ProjectSymbolSource{ 71 | { 72 | ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), 73 | Type: String("s3"), 74 | Name: String("s3"), 75 | Layout: &ProjectSymbolSourceLayout{ 76 | Type: String("native"), 77 | Casing: String("default"), 78 | }, 79 | Bucket: String("bucket"), 80 | Region: String("us-east-2"), 81 | AccessKey: String("access_key"), 82 | SecretKey: &ProjectSymbolSourceHiddenSecret{ 83 | HiddenSecret: Bool(true), 84 | }, 85 | }, 86 | { 87 | ID: String("f9df862d-45f7-496c-bf9b-ecade4c9f136"), 88 | Type: String("gcs"), 89 | Name: String("gcs"), 90 | Layout: &ProjectSymbolSourceLayout{ 91 | Type: String("native"), 92 | Casing: String("default"), 93 | }, 94 | Bucket: String("gcs-bucket-name"), 95 | ClientEmail: String("test@example.com"), 96 | PrivateKey: &ProjectSymbolSourceHiddenSecret{ 97 | HiddenSecret: Bool(true), 98 | }, 99 | }, 100 | { 101 | ID: String("1ccb6083-91ac-4394-a276-40fe0bb10ece"), 102 | Type: String("http"), 103 | Name: String("http"), 104 | Layout: &ProjectSymbolSourceLayout{ 105 | Type: String("native"), 106 | Casing: String("default"), 107 | }, 108 | Url: String("https://example.com"), 109 | Username: String("admin"), 110 | Password: &ProjectSymbolSourceHiddenSecret{ 111 | HiddenSecret: Bool(true), 112 | }, 113 | }, 114 | } 115 | assert.Equal(t, expected, sources) 116 | } 117 | 118 | func TestProjectSymbolSourcesService_Create(t *testing.T) { 119 | client, mux, _, teardown := setup() 120 | defer teardown() 121 | 122 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { 123 | assertMethod(t, http.MethodPost, r) 124 | assertPostJSON(t, map[string]interface{}{ 125 | "name": "s3", 126 | "bucket": "bucket", 127 | "region": "us-east-2", 128 | "access_key": "access_key", 129 | "type": "s3", 130 | "layout": map[string]interface{}{ 131 | "casing": "default", 132 | "type": "native", 133 | }, 134 | "secret_key": "secret_key", 135 | }, r) 136 | w.Header().Set("Content-Type", "application/json") 137 | fmt.Fprint(w, `{ 138 | "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", 139 | "name": "s3", 140 | "bucket": "bucket", 141 | "region": "us-east-2", 142 | "access_key": "access_key", 143 | "type": "s3", 144 | "layout": { 145 | "casing": "default", 146 | "type": "native" 147 | }, 148 | "secret_key": { 149 | "hidden-secret": true 150 | } 151 | }`) 152 | }) 153 | 154 | ctx := context.Background() 155 | params := &CreateProjectSymbolSourceParams{ 156 | Type: String("s3"), 157 | Name: String("s3"), 158 | Layout: &ProjectSymbolSourceLayout{ 159 | Type: String("native"), 160 | Casing: String("default"), 161 | }, 162 | Bucket: String("bucket"), 163 | Region: String("us-east-2"), 164 | AccessKey: String("access_key"), 165 | SecretKey: String("secret_key"), 166 | } 167 | source, _, err := client.ProjectSymbolSources.Create(ctx, "organization_slug", "project_slug", params) 168 | assert.NoError(t, err) 169 | 170 | expected := &ProjectSymbolSource{ 171 | ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), 172 | Type: String("s3"), 173 | Name: String("s3"), 174 | Layout: &ProjectSymbolSourceLayout{ 175 | Type: String("native"), 176 | Casing: String("default"), 177 | }, 178 | Bucket: String("bucket"), 179 | Region: String("us-east-2"), 180 | AccessKey: String("access_key"), 181 | SecretKey: &ProjectSymbolSourceHiddenSecret{ 182 | HiddenSecret: Bool(true), 183 | }, 184 | } 185 | assert.Equal(t, expected, source) 186 | } 187 | 188 | func TestProjectSymbolSourcesService_Update(t *testing.T) { 189 | client, mux, _, teardown := setup() 190 | defer teardown() 191 | 192 | mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { 193 | assert.Equal(t, "27c5692e-de41-4087-bc14-74ed0fa421ba", r.URL.Query().Get("id")) 194 | assertMethod(t, http.MethodPut, r) 195 | assertPostJSON(t, map[string]interface{}{ 196 | "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", 197 | "name": "s3", 198 | "bucket": "bucket", 199 | "region": "us-east-2", 200 | "access_key": "access_key", 201 | "type": "s3", 202 | "layout": map[string]interface{}{ 203 | "casing": "default", 204 | "type": "native", 205 | }, 206 | "secret_key": "secret_key", 207 | }, r) 208 | w.Header().Set("Content-Type", "application/json") 209 | fmt.Fprint(w, `{ 210 | "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", 211 | "name": "s3", 212 | "bucket": "bucket", 213 | "region": "us-east-2", 214 | "access_key": "access_key", 215 | "type": "s3", 216 | "layout": { 217 | "casing": "default", 218 | "type": "native" 219 | }, 220 | "secret_key": { 221 | "hidden-secret": true 222 | } 223 | }`) 224 | }) 225 | 226 | ctx := context.Background() 227 | params := &UpdateProjectSymbolSourceParams{ 228 | ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), 229 | Type: String("s3"), 230 | Name: String("s3"), 231 | Layout: &ProjectSymbolSourceLayout{ 232 | Type: String("native"), 233 | Casing: String("default"), 234 | }, 235 | Bucket: String("bucket"), 236 | Region: String("us-east-2"), 237 | AccessKey: String("access_key"), 238 | SecretKey: String("secret_key"), 239 | } 240 | source, _, err := client.ProjectSymbolSources.Update(ctx, "organization_slug", "project_slug", "27c5692e-de41-4087-bc14-74ed0fa421ba", params) 241 | assert.NoError(t, err) 242 | 243 | expected := &ProjectSymbolSource{ 244 | ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), 245 | Type: String("s3"), 246 | Name: String("s3"), 247 | Layout: &ProjectSymbolSourceLayout{ 248 | Type: String("native"), 249 | Casing: String("default"), 250 | }, 251 | Bucket: String("bucket"), 252 | Region: String("us-east-2"), 253 | AccessKey: String("access_key"), 254 | SecretKey: &ProjectSymbolSourceHiddenSecret{ 255 | HiddenSecret: Bool(true), 256 | }, 257 | } 258 | assert.Equal(t, expected, source) 259 | } 260 | -------------------------------------------------------------------------------- /sentry/projects.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Project represents a Sentry project. 10 | // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/project.py 11 | type Project struct { 12 | ID string `json:"id"` 13 | Slug string `json:"slug"` 14 | Name string `json:"name"` 15 | 16 | IsPublic bool `json:"isPublic"` 17 | IsBookmarked bool `json:"isBookmarked"` 18 | Color string `json:"color"` 19 | 20 | DateCreated time.Time `json:"dateCreated"` 21 | FirstEvent time.Time `json:"firstEvent"` 22 | 23 | Features []string `json:"features"` 24 | Status string `json:"status"` 25 | Platform string `json:"platform"` 26 | 27 | IsInternal bool `json:"isInternal"` 28 | IsMember bool `json:"isMember"` 29 | HasAccess bool `json:"hasAccess"` 30 | 31 | Avatar Avatar `json:"avatar"` 32 | 33 | // TODO: latestRelease 34 | Options map[string]interface{} `json:"options"` 35 | 36 | DigestsMinDelay int `json:"digestsMinDelay"` 37 | DigestsMaxDelay int `json:"digestsMaxDelay"` 38 | SubjectPrefix string `json:"subjectPrefix"` 39 | AllowedDomains []string `json:"allowedDomains"` 40 | ResolveAge int `json:"resolveAge"` 41 | DataScrubber bool `json:"dataScrubber"` 42 | DataScrubberDefaults bool `json:"dataScrubberDefaults"` 43 | FingerprintingRules string `json:"fingerprintingRules"` 44 | GroupingEnhancements string `json:"groupingEnhancements"` 45 | SafeFields []string `json:"safeFields"` 46 | SensitiveFields []string `json:"sensitiveFields"` 47 | SubjectTemplate string `json:"subjectTemplate"` 48 | SecurityToken string `json:"securityToken"` 49 | SecurityTokenHeader *string `json:"securityTokenHeader"` 50 | VerifySSL bool `json:"verifySSL"` 51 | ScrubIPAddresses bool `json:"scrubIPAddresses"` 52 | ScrapeJavaScript bool `json:"scrapeJavaScript"` 53 | 54 | Organization Organization `json:"organization"` 55 | // TODO: plugins 56 | // TODO: platforms 57 | ProcessingIssues int `json:"processingIssues"` 58 | // TODO: defaultEnvironment 59 | 60 | Team Team `json:"team"` 61 | Teams []Team `json:"teams"` 62 | } 63 | 64 | // ProjectSummary represents the summary of a Sentry project. 65 | type ProjectSummary struct { 66 | ID string `json:"id"` 67 | Name string `json:"name"` 68 | Slug string `json:"slug"` 69 | IsBookmarked bool `json:"isBookmarked"` 70 | IsMember bool `json:"isMember"` 71 | HasAccess bool `json:"hasAccess"` 72 | 73 | DateCreated time.Time `json:"dateCreated"` 74 | FirstEvent time.Time `json:"firstEvent"` 75 | 76 | Platform *string `json:"platform"` 77 | Platforms []string `json:"platforms"` 78 | 79 | Team *ProjectSummaryTeam `json:"team"` 80 | Teams []ProjectSummaryTeam `json:"teams"` 81 | // TODO: deploys 82 | } 83 | 84 | // ProjectSummaryTeam represents a team in a ProjectSummary. 85 | type ProjectSummaryTeam struct { 86 | ID string `json:"id"` 87 | Name string `json:"name"` 88 | Slug string `json:"slug"` 89 | } 90 | 91 | // ProjectsService provides methods for accessing Sentry project API endpoints. 92 | // https://docs.sentry.io/api/projects/ 93 | type ProjectsService service 94 | 95 | type ListProjectsParams struct { 96 | ListCursorParams 97 | } 98 | 99 | // List projects available. 100 | // https://docs.sentry.io/api/projects/list-your-projects/ 101 | func (s *ProjectsService) List(ctx context.Context, params *ListProjectsParams) ([]*Project, *Response, error) { 102 | u := "0/projects/" 103 | u, err := addQuery(u, params) 104 | if err != nil { 105 | return nil, nil, err 106 | } 107 | 108 | req, err := s.client.NewRequest("GET", u, nil) 109 | if err != nil { 110 | return nil, nil, err 111 | } 112 | 113 | projects := []*Project{} 114 | resp, err := s.client.Do(ctx, req, &projects) 115 | if err != nil { 116 | return nil, resp, err 117 | } 118 | return projects, resp, nil 119 | } 120 | 121 | // Get details on an individual project. 122 | // https://docs.sentry.io/api/projects/retrieve-a-project/ 123 | func (s *ProjectsService) Get(ctx context.Context, organizationSlug string, slug string) (*Project, *Response, error) { 124 | u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) 125 | req, err := s.client.NewRequest("GET", u, nil) 126 | if err != nil { 127 | return nil, nil, err 128 | } 129 | 130 | project := new(Project) 131 | resp, err := s.client.Do(ctx, req, project) 132 | if err != nil { 133 | return nil, resp, err 134 | } 135 | return project, resp, nil 136 | } 137 | 138 | // CreateProjectParams are the parameters for ProjectService.Create. 139 | type CreateProjectParams struct { 140 | Name string `json:"name,omitempty"` 141 | Slug string `json:"slug,omitempty"` 142 | Platform string `json:"platform,omitempty"` 143 | DefaultRules *bool `json:"default_rules,omitempty"` 144 | } 145 | 146 | // Create a new project bound to a team. 147 | func (s *ProjectsService) Create(ctx context.Context, organizationSlug string, teamSlug string, params *CreateProjectParams) (*Project, *Response, error) { 148 | u := fmt.Sprintf("0/teams/%v/%v/projects/", organizationSlug, teamSlug) 149 | req, err := s.client.NewRequest("POST", u, params) 150 | if err != nil { 151 | return nil, nil, err 152 | } 153 | 154 | project := new(Project) 155 | resp, err := s.client.Do(ctx, req, project) 156 | if err != nil { 157 | return nil, resp, err 158 | } 159 | return project, resp, nil 160 | } 161 | 162 | // UpdateProjectParams are the parameters for ProjectService.Update. 163 | type UpdateProjectParams struct { 164 | Name string `json:"name,omitempty"` 165 | Slug string `json:"slug,omitempty"` 166 | Platform string `json:"platform,omitempty"` 167 | IsBookmarked *bool `json:"isBookmarked,omitempty"` 168 | DigestsMinDelay *int `json:"digestsMinDelay,omitempty"` 169 | DigestsMaxDelay *int `json:"digestsMaxDelay,omitempty"` 170 | ResolveAge *int `json:"resolveAge,omitempty"` 171 | Options map[string]interface{} `json:"options,omitempty"` 172 | AllowedDomains []string `json:"allowedDomains,omitempty"` 173 | FingerprintingRules *string `json:"fingerprintingRules,omitempty"` 174 | GroupingEnhancements *string `json:"groupingEnhancements,omitempty"` 175 | } 176 | 177 | // Update various attributes and configurable settings for a given project. 178 | // https://docs.sentry.io/api/projects/update-a-project/ 179 | func (s *ProjectsService) Update(ctx context.Context, organizationSlug string, slug string, params *UpdateProjectParams) (*Project, *Response, error) { 180 | u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) 181 | req, err := s.client.NewRequest("PUT", u, params) 182 | if err != nil { 183 | return nil, nil, err 184 | } 185 | 186 | project := new(Project) 187 | resp, err := s.client.Do(ctx, req, project) 188 | if err != nil { 189 | return nil, resp, err 190 | } 191 | return project, resp, nil 192 | } 193 | 194 | // Delete a project. 195 | // https://docs.sentry.io/api/projects/delete-a-project/ 196 | func (s *ProjectsService) Delete(ctx context.Context, organizationSlug string, slug string) (*Response, error) { 197 | u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) 198 | req, err := s.client.NewRequest("DELETE", u, nil) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | return s.client.Do(ctx, req, nil) 204 | } 205 | 206 | // AddTeam add a team to a project. 207 | func (s *ProjectsService) AddTeam(ctx context.Context, organizationSlug string, slug string, teamSlug string) (*Project, *Response, error) { 208 | u := fmt.Sprintf("0/projects/%v/%v/teams/%v/", organizationSlug, slug, teamSlug) 209 | req, err := s.client.NewRequest("POST", u, nil) 210 | if err != nil { 211 | return nil, nil, err 212 | } 213 | 214 | project := new(Project) 215 | resp, err := s.client.Do(ctx, req, project) 216 | if err != nil { 217 | return nil, resp, err 218 | } 219 | return project, resp, nil 220 | } 221 | 222 | // RemoveTeam remove a team from a project. 223 | func (s *ProjectsService) RemoveTeam(ctx context.Context, organizationSlug string, slug string, teamSlug string) (*Response, error) { 224 | u := fmt.Sprintf("0/projects/%v/%v/teams/%v/", organizationSlug, slug, teamSlug) 225 | req, err := s.client.NewRequest("DELETE", u, nil) 226 | if err != nil { 227 | return nil, err 228 | } 229 | 230 | return s.client.Do(ctx, req, nil) 231 | } 232 | -------------------------------------------------------------------------------- /sentry/release_deployment.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type ReleaseDeploymentsService service 10 | 11 | type ReleaseDeployment struct { 12 | ID string `json:"id"` 13 | Name *string `json:"name,omitempty"` 14 | Environment string `json:"environment,omitempty"` 15 | URL *string `json:"url,omitempty"` 16 | Projects []string `json:"projects,omitempty"` 17 | DateStarted *time.Time `json:"dateStarted,omitempty"` 18 | DateFinished *time.Time `json:"dateFinished,omitempty"` 19 | } 20 | 21 | // Get a Release Deploy for a project. 22 | func (s *ReleaseDeploymentsService) Get(ctx context.Context, organizationSlug string, version string, deployID string) (*ReleaseDeployment, *Response, error) { 23 | 24 | lastCursor := "" 25 | 26 | // Search for the deployment ID by using the list endpoint. When we have 27 | // found the first match return immediately 28 | for { 29 | params := ListCursorParams{ 30 | Cursor: lastCursor, 31 | } 32 | 33 | u := fmt.Sprintf("0/organizations/%v/releases/%s/deploys/", organizationSlug, version) 34 | u, err := addQuery(u, params) 35 | if err != nil { 36 | return nil, nil, err 37 | } 38 | 39 | req, err := s.client.NewRequest("GET", u, nil) 40 | if err != nil { 41 | return nil, nil, err 42 | } 43 | 44 | deployments := new([]ReleaseDeployment) 45 | resp, err := s.client.Do(ctx, req, deployments) 46 | if err != nil { 47 | return nil, resp, err 48 | } 49 | 50 | for i := range *deployments { 51 | d := (*deployments)[i] 52 | if d.ID == deployID { 53 | return &d, resp, nil 54 | } 55 | } 56 | 57 | // No matches in the current page and no further pages to check 58 | if resp.Cursor == "" { 59 | return nil, resp, nil 60 | } 61 | lastCursor = resp.Cursor 62 | } 63 | } 64 | 65 | // Create a new Release Deploy to a project. 66 | func (s *ReleaseDeploymentsService) Create(ctx context.Context, organizationSlug string, version string, params *ReleaseDeployment) (*ReleaseDeployment, *Response, error) { 67 | u := fmt.Sprintf("0/organizations/%v/releases/%s/deploys/", organizationSlug, version) 68 | req, err := s.client.NewRequest("POST", u, params) 69 | if err != nil { 70 | return nil, nil, err 71 | } 72 | 73 | deploy := new(ReleaseDeployment) 74 | resp, err := s.client.Do(ctx, req, deploy) 75 | if err != nil { 76 | return nil, resp, err 77 | } 78 | 79 | return deploy, resp, nil 80 | } 81 | -------------------------------------------------------------------------------- /sentry/roles.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | // https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/role.py#L62-L74 4 | type OrganizationRoleListItem struct { 5 | ID string `json:"id"` 6 | Name string `json:"name"` 7 | Desc string `json:"desc"` 8 | Scopes []string `json:"scopes"` 9 | IsAllowed bool `json:"isAllowed"` 10 | IsRetired bool `json:"isRetired"` 11 | IsGlobal bool `json:"isGlobal"` 12 | MinimumTeamRole string `json:"minimumTeamRole"` 13 | } 14 | 15 | // https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/role.py#L77-L85 16 | type TeamRoleListItem struct { 17 | ID string `json:"id"` 18 | Name string `json:"name"` 19 | Desc string `json:"desc"` 20 | Scopes []string `json:"scopes"` 21 | IsAllowed bool `json:"isAllowed"` 22 | IsRetired bool `json:"isRetired"` 23 | IsMinimumRoleFor *string `json:"isMinimumRoleFor"` 24 | } 25 | -------------------------------------------------------------------------------- /sentry/sentry.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "reflect" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/google/go-querystring/query" 18 | "github.com/peterhellberg/link" 19 | ) 20 | 21 | const ( 22 | defaultBaseURL = "https://sentry.io/api/" 23 | userAgent = "go-sentry" 24 | 25 | // https://docs.sentry.io/api/ratelimits/ 26 | headerRateLimit = "X-Sentry-Rate-Limit-Limit" 27 | headerRateRemaining = "X-Sentry-Rate-Limit-Remaining" 28 | headerRateReset = "X-Sentry-Rate-Limit-Reset" 29 | headerRateConcurrentLimit = "X-Sentry-Rate-Limit-ConcurrentLimit" 30 | headerRateConcurrentRemaining = "X-Sentry-Rate-Limit-ConcurrentRemaining" 31 | ) 32 | 33 | var errNonNilContext = errors.New("context must be non-nil") 34 | 35 | // Client for Sentry API. 36 | type Client struct { 37 | client *http.Client 38 | 39 | // BaseURL for API requests. 40 | BaseURL *url.URL 41 | 42 | // User agent used when communicating with Sentry. 43 | UserAgent string 44 | 45 | // Common struct used by all services. 46 | common service 47 | 48 | // Services 49 | Dashboards *DashboardsService 50 | DashboardWidgets *DashboardWidgetsService 51 | IssueAlerts *IssueAlertsService 52 | MetricAlerts *MetricAlertsService 53 | NotificationActions *NotificationActionsService 54 | OrganizationCodeMappings *OrganizationCodeMappingsService 55 | OrganizationIntegrations *OrganizationIntegrationsService 56 | OrganizationMembers *OrganizationMembersService 57 | OrganizationProjects *OrganizationProjectsService 58 | OrganizationRepositories *OrganizationRepositoriesService 59 | Organizations *OrganizationsService 60 | ProjectFilters *ProjectFiltersService 61 | ProjectInboundDataFilters *ProjectInboundDataFiltersService 62 | ProjectKeys *ProjectKeysService 63 | ProjectOwnerships *ProjectOwnershipsService 64 | ProjectPlugins *ProjectPluginsService 65 | Projects *ProjectsService 66 | ProjectSymbolSources *ProjectSymbolSourcesService 67 | ReleaseDeployments *ReleaseDeploymentsService 68 | SpikeProtections *SpikeProtectionsService 69 | TeamMembers *TeamMembersService 70 | Teams *TeamsService 71 | } 72 | 73 | type service struct { 74 | client *Client 75 | } 76 | 77 | // NewClient returns a new Sentry API client. 78 | // If a nil httpClient is provided, the http.DefaultClient will be used. 79 | func NewClient(httpClient *http.Client) *Client { 80 | if httpClient == nil { 81 | httpClient = http.DefaultClient 82 | } 83 | baseURL, _ := url.Parse(defaultBaseURL) 84 | 85 | c := &Client{ 86 | client: httpClient, 87 | BaseURL: baseURL, 88 | UserAgent: userAgent, 89 | } 90 | c.common.client = c 91 | c.Dashboards = (*DashboardsService)(&c.common) 92 | c.DashboardWidgets = (*DashboardWidgetsService)(&c.common) 93 | c.IssueAlerts = (*IssueAlertsService)(&c.common) 94 | c.MetricAlerts = (*MetricAlertsService)(&c.common) 95 | c.NotificationActions = (*NotificationActionsService)(&c.common) 96 | c.OrganizationCodeMappings = (*OrganizationCodeMappingsService)(&c.common) 97 | c.OrganizationIntegrations = (*OrganizationIntegrationsService)(&c.common) 98 | c.OrganizationMembers = (*OrganizationMembersService)(&c.common) 99 | c.OrganizationProjects = (*OrganizationProjectsService)(&c.common) 100 | c.OrganizationRepositories = (*OrganizationRepositoriesService)(&c.common) 101 | c.Organizations = (*OrganizationsService)(&c.common) 102 | c.ProjectFilters = (*ProjectFiltersService)(&c.common) 103 | c.ProjectInboundDataFilters = (*ProjectInboundDataFiltersService)(&c.common) 104 | c.ProjectKeys = (*ProjectKeysService)(&c.common) 105 | c.ProjectOwnerships = (*ProjectOwnershipsService)(&c.common) 106 | c.ProjectPlugins = (*ProjectPluginsService)(&c.common) 107 | c.Projects = (*ProjectsService)(&c.common) 108 | c.ProjectSymbolSources = (*ProjectSymbolSourcesService)(&c.common) 109 | c.ReleaseDeployments = (*ReleaseDeploymentsService)(&c.common) 110 | c.SpikeProtections = (*SpikeProtectionsService)(&c.common) 111 | c.TeamMembers = (*TeamMembersService)(&c.common) 112 | c.Teams = (*TeamsService)(&c.common) 113 | return c 114 | } 115 | 116 | // NewOnPremiseClient returns a new Sentry API client with the provided base URL. 117 | // Note that the base URL must be in the format "http(s)://[hostname]/api/". 118 | // If the base URL does not have the suffix "/api/", it will be added automatically. 119 | // If a nil httpClient is provided, the http.DefaultClient will be used. 120 | func NewOnPremiseClient(baseURL string, httpClient *http.Client) (*Client, error) { 121 | baseEndpoint, err := url.Parse(baseURL) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | if !strings.HasSuffix(baseEndpoint.Path, "/") { 127 | baseEndpoint.Path += "/" 128 | } 129 | if !strings.HasSuffix(baseEndpoint.Path, "/api/") { 130 | baseEndpoint.Path += "api/" 131 | } 132 | 133 | c := NewClient(httpClient) 134 | c.BaseURL = baseEndpoint 135 | return c, nil 136 | } 137 | 138 | type ListCursorParams struct { 139 | // A cursor, as given in the Link header. 140 | // If specified, the query continues the search using this cursor. 141 | Cursor string `url:"cursor,omitempty"` 142 | } 143 | 144 | func addQuery(s string, params interface{}) (string, error) { 145 | v := reflect.ValueOf(params) 146 | if v.Kind() == reflect.Ptr && v.IsNil() { 147 | return s, nil 148 | } 149 | 150 | u, err := url.Parse(s) 151 | if err != nil { 152 | return s, err 153 | } 154 | 155 | qs, err := query.Values(params) 156 | if err != nil { 157 | return s, err 158 | } 159 | 160 | u.RawQuery = qs.Encode() 161 | return u.String(), nil 162 | } 163 | 164 | // NewRequest creates an API request. 165 | func (c *Client) NewRequest(method, urlRef string, body interface{}) (*http.Request, error) { 166 | if !strings.HasSuffix(c.BaseURL.Path, "/") { 167 | return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL) 168 | } 169 | 170 | u, err := c.BaseURL.Parse(urlRef) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | var buf io.ReadWriter 176 | if body != nil { 177 | buf = &bytes.Buffer{} 178 | enc := json.NewEncoder(buf) 179 | enc.SetEscapeHTML(false) 180 | err := enc.Encode(body) 181 | if err != nil { 182 | return nil, err 183 | } 184 | } 185 | 186 | req, err := http.NewRequest(method, u.String(), buf) 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | if body != nil { 192 | req.Header.Set("Content-Type", "application/json") 193 | } 194 | if c.UserAgent != "" { 195 | req.Header.Set("User-Agent", c.UserAgent) 196 | } 197 | return req, nil 198 | } 199 | 200 | // Response is a Sentry API response. This wraps the standard http.Response 201 | // and provides convenient access to things like pagination links and rate limits. 202 | type Response struct { 203 | *http.Response 204 | 205 | // For APIs that support cursor pagination, the following field will be populated 206 | // to point to the next page if more results are available. 207 | // Set ListCursorParams.Cursor to this value when calling the endpoint again. 208 | Cursor string 209 | 210 | Rate Rate 211 | } 212 | 213 | func newResponse(r *http.Response) *Response { 214 | response := &Response{Response: r} 215 | response.Rate = ParseRate(r) 216 | response.populatePaginationCursor() 217 | return response 218 | } 219 | 220 | func (r *Response) populatePaginationCursor() { 221 | rels := link.ParseResponse(r.Response) 222 | if nextRel, ok := rels["next"]; ok && nextRel.Extra["results"] == "true" { 223 | r.Cursor = nextRel.Extra["cursor"] 224 | } 225 | } 226 | 227 | // ParseRate parses the rate limit headers. 228 | func ParseRate(r *http.Response) Rate { 229 | var rate Rate 230 | if limit := r.Header.Get(headerRateLimit); limit != "" { 231 | rate.Limit, _ = strconv.Atoi(limit) 232 | } 233 | if remaining := r.Header.Get(headerRateRemaining); remaining != "" { 234 | rate.Remaining, _ = strconv.Atoi(remaining) 235 | } 236 | if reset := r.Header.Get(headerRateReset); reset != "" { 237 | if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { 238 | rate.Reset = time.Unix(v, 0).UTC() 239 | } 240 | } 241 | if concurrentLimit := r.Header.Get(headerRateConcurrentLimit); concurrentLimit != "" { 242 | rate.ConcurrentLimit, _ = strconv.Atoi(concurrentLimit) 243 | } 244 | if concurrentRemaining := r.Header.Get(headerRateConcurrentRemaining); concurrentRemaining != "" { 245 | rate.ConcurrentRemaining, _ = strconv.Atoi(concurrentRemaining) 246 | } 247 | return rate 248 | } 249 | 250 | func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, error) { 251 | if ctx == nil { 252 | return nil, errNonNilContext 253 | } 254 | 255 | resp, err := c.client.Do(req) 256 | if err != nil { 257 | // If we got an error, and the context has been canceled, 258 | // the context's error is probably more useful. 259 | select { 260 | case <-ctx.Done(): 261 | return nil, ctx.Err() 262 | default: 263 | return nil, err 264 | } 265 | } 266 | 267 | response := newResponse(resp) 268 | err = CheckResponse(resp) 269 | return response, err 270 | } 271 | 272 | func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { 273 | resp, err := c.BareDo(ctx, req) 274 | if err != nil { 275 | return resp, err 276 | } 277 | defer resp.Body.Close() 278 | 279 | switch v := v.(type) { 280 | case nil: 281 | case io.Writer: 282 | _, err = io.Copy(v, resp.Body) 283 | default: 284 | dec := json.NewDecoder(resp.Body) 285 | dec.UseNumber() 286 | decErr := dec.Decode(v) 287 | if decErr == io.EOF { 288 | decErr = nil 289 | } 290 | if decErr != nil { 291 | err = decErr 292 | } 293 | } 294 | return resp, err 295 | } 296 | 297 | // matchHTTPResponse compares two http.Response objects. Currently, only StatusCode is checked. 298 | func matchHTTPResponse(r1, r2 *http.Response) bool { 299 | if r1 == nil && r2 == nil { 300 | return true 301 | } 302 | if r1 != nil && r2 != nil { 303 | return r1.StatusCode == r2.StatusCode 304 | } 305 | return false 306 | } 307 | 308 | type ErrorResponse struct { 309 | Response *http.Response 310 | Detail string `json:"detail"` 311 | } 312 | 313 | func (r *ErrorResponse) Error() string { 314 | return fmt.Sprintf( 315 | "%v %v: %d %v", 316 | r.Response.Request.Method, r.Response.Request.URL, 317 | r.Response.StatusCode, r.Detail) 318 | } 319 | 320 | func (r *ErrorResponse) Is(target error) bool { 321 | v, ok := target.(*ErrorResponse) 322 | if !ok { 323 | return false 324 | } 325 | if r.Detail != v.Detail || 326 | !matchHTTPResponse(r.Response, v.Response) { 327 | return false 328 | } 329 | return true 330 | } 331 | 332 | type RateLimitError struct { 333 | Rate Rate 334 | Response *http.Response 335 | Detail string 336 | } 337 | 338 | func (r *RateLimitError) Error() string { 339 | return fmt.Sprintf( 340 | "%v %v: %d %v %v", 341 | r.Response.Request.Method, r.Response.Request.URL, 342 | r.Response.StatusCode, r.Detail, fmt.Sprintf("[rate reset in %v]", time.Until(r.Rate.Reset))) 343 | } 344 | 345 | func (r *RateLimitError) Is(target error) bool { 346 | v, ok := target.(*RateLimitError) 347 | if !ok { 348 | return false 349 | } 350 | return r.Rate == v.Rate && 351 | r.Detail == v.Detail && 352 | matchHTTPResponse(r.Response, v.Response) 353 | } 354 | 355 | func CheckResponse(r *http.Response) error { 356 | if c := r.StatusCode; 200 <= c && c <= 299 { 357 | return nil 358 | } 359 | 360 | errorResponse := &ErrorResponse{Response: r} 361 | data, err := io.ReadAll(r.Body) 362 | if err == nil && data != nil { 363 | apiError := new(APIError) 364 | json.Unmarshal(data, apiError) 365 | if apiError.Empty() { 366 | errorResponse.Detail = strings.TrimSpace(string(data)) 367 | } else { 368 | errorResponse.Detail = apiError.Detail() 369 | } 370 | } 371 | // Re-populate error response body. 372 | r.Body = io.NopCloser(bytes.NewBuffer(data)) 373 | 374 | switch { 375 | case r.StatusCode == http.StatusTooManyRequests && 376 | (r.Header.Get(headerRateRemaining) == "0" || r.Header.Get(headerRateConcurrentRemaining) == "0"): 377 | return &RateLimitError{ 378 | Rate: ParseRate(r), 379 | Response: errorResponse.Response, 380 | Detail: errorResponse.Detail, 381 | } 382 | } 383 | 384 | return errorResponse 385 | } 386 | 387 | // Rate represents the rate limit for the current client. 388 | type Rate struct { 389 | // The maximum number of requests allowed within the window. 390 | Limit int 391 | 392 | // The number of requests this caller has left on this endpoint within the current window 393 | Remaining int 394 | 395 | // The time when the next rate limit window begins and the count resets, measured in UTC seconds from epoch 396 | Reset time.Time 397 | 398 | // The maximum number of concurrent requests allowed within the window 399 | ConcurrentLimit int 400 | 401 | // The number of concurrent requests this caller has left on this endpoint within the current window 402 | ConcurrentRemaining int 403 | } 404 | 405 | // Bool returns a pointer to the bool value passed in. 406 | func Bool(v bool) *bool { return &v } 407 | 408 | // BoolValue returns the value of the bool pointer passed in or 409 | // false if the pointer is nil. 410 | func BoolValue(v *bool) bool { 411 | if v != nil { 412 | return *v 413 | } 414 | return false 415 | } 416 | 417 | // Int returns a pointer to the int value passed in. 418 | func Int(v int) *int { return &v } 419 | 420 | // IntValue returns the value of the int pointer passed in or 421 | // 0 if the pointer is nil. 422 | func IntValue(v *int) int { 423 | if v != nil { 424 | return *v 425 | } 426 | return 0 427 | } 428 | 429 | // Float64 returns a pointer to the float64 value passed in. 430 | func Float64(v float64) *float64 { 431 | return &v 432 | } 433 | 434 | // Float64Value returns the value of the float64 pointer passed in or 435 | // 0 if the pointer is nil. 436 | func Float64Value(v *float64) float64 { 437 | if v != nil { 438 | return *v 439 | } 440 | return 0 441 | } 442 | 443 | // String returns a pointer to the string value passed in. 444 | func String(v string) *string { return &v } 445 | 446 | // StringValue returns the value of the string pointer passed in or 447 | // "" if the pointer is nil. 448 | func StringValue(v *string) string { 449 | if v != nil { 450 | return *v 451 | } 452 | return "" 453 | } 454 | 455 | // Time returns a pointer to the time.Time value passed in. 456 | func Time(v time.Time) *time.Time { return &v } 457 | 458 | // TimeValue returns the value of the time.Time pointer passed in or 459 | // time.Time{} if the pointer is nil. 460 | func TimeValue(v *time.Time) time.Time { 461 | if v != nil { 462 | return *v 463 | } 464 | return time.Time{} 465 | } 466 | 467 | // JsonNumber returns a pointer to the json.Number value passed in. 468 | func JsonNumber(v json.Number) *json.Number { return &v } 469 | 470 | // JsonNumberValue returns the value of the json.Number pointer passed in or 471 | // json.Number("") if the pointer is nil. 472 | func JsonNumberValue(v *json.Number) json.Number { 473 | if v != nil { 474 | return *v 475 | } 476 | return json.Number("") 477 | } 478 | -------------------------------------------------------------------------------- /sentry/sentry_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func setup() (client *Client, mux *http.ServeMux, serverURL string, teardown func()) { 19 | mux = http.NewServeMux() 20 | server := httptest.NewServer(mux) 21 | client = NewClient(nil) 22 | url, _ := url.Parse(server.URL + "/api/") 23 | client.BaseURL = url 24 | return client, mux, server.URL, server.Close 25 | } 26 | 27 | // RewriteTransport rewrites https requests to http to avoid TLS cert issues 28 | // during testing. 29 | type RewriteTransport struct { 30 | Transport http.RoundTripper 31 | } 32 | 33 | // RoundTrip rewrites the request scheme to http and calls through to the 34 | // composed RoundTripper or if it is nil, to the http.DefaultTransport. 35 | func (t *RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { 36 | req.URL.Scheme = "http" 37 | if t.Transport == nil { 38 | return http.DefaultTransport.RoundTrip(req) 39 | } 40 | return t.Transport.RoundTrip(req) 41 | } 42 | 43 | func assertMethod(t *testing.T, expectedMethod string, req *http.Request) { 44 | assert.Equal(t, expectedMethod, req.Method) 45 | } 46 | 47 | // assertQuery tests that the Request has the expected url query key/val pairs 48 | func assertQuery(t *testing.T, expected map[string]string, req *http.Request) { 49 | queryValues := req.URL.Query() 50 | expectedValues := url.Values{} 51 | for key, value := range expected { 52 | expectedValues.Add(key, value) 53 | } 54 | assert.Equal(t, expectedValues, queryValues) 55 | } 56 | 57 | // assertPostJSON tests that the Request has the expected JSON in its Body 58 | func assertPostJSON(t *testing.T, expected interface{}, req *http.Request) { 59 | var actual interface{} 60 | 61 | d := json.NewDecoder(req.Body) 62 | d.UseNumber() 63 | 64 | err := d.Decode(&actual) 65 | assert.NoError(t, err) 66 | assert.EqualValues(t, expected, actual) 67 | } 68 | 69 | // assertPostJSON tests that the request has the expected values in its body. 70 | func assertPostJSONValue(t *testing.T, expected interface{}, req *http.Request) { 71 | var actual interface{} 72 | 73 | d := json.NewDecoder(req.Body) 74 | d.UseNumber() 75 | 76 | err := d.Decode(&actual) 77 | assert.NoError(t, err) 78 | assert.ObjectsAreEqualValues(expected, actual) 79 | } 80 | 81 | func mustParseTime(value string) time.Time { 82 | t, err := time.Parse(time.RFC3339, value) 83 | if err != nil { 84 | panic(fmt.Sprintf("mustParseTime: %s", err)) 85 | } 86 | return t 87 | } 88 | 89 | func TestNewClient(t *testing.T) { 90 | c := NewClient(nil) 91 | 92 | assert.Equal(t, "https://sentry.io/api/", c.BaseURL.String()) 93 | } 94 | 95 | func TestNewOnPremiseClient(t *testing.T) { 96 | testCases := []struct { 97 | baseURL string 98 | }{ 99 | {"https://example.com"}, 100 | {"https://example.com/"}, 101 | {"https://example.com/api"}, 102 | {"https://example.com/api/"}, 103 | } 104 | for _, tc := range testCases { 105 | t.Run(tc.baseURL, func(t *testing.T) { 106 | c, err := NewOnPremiseClient(tc.baseURL, nil) 107 | 108 | assert.NoError(t, err) 109 | assert.Equal(t, "https://example.com/api/", c.BaseURL.String()) 110 | }) 111 | } 112 | 113 | } 114 | 115 | func TestResponse_populatePaginationCursor_hasNextResults(t *testing.T) { 116 | r := &http.Response{ 117 | Header: http.Header{ 118 | "Link": {`; rel="previous"; results="false"; cursor="100:-1:1", ` + 119 | `; rel="next"; results="true"; cursor="100:1:0"`, 120 | }, 121 | }, 122 | } 123 | 124 | response := newResponse(r) 125 | assert.Equal(t, response.Cursor, "100:1:0") 126 | } 127 | 128 | func TestResponse_populatePaginationCursor_noNextResults(t *testing.T) { 129 | r := &http.Response{ 130 | Header: http.Header{ 131 | "Link": {`; rel="previous"; results="false"; cursor="100:-1:1", ` + 132 | `; rel="next"; results="false"; cursor="100:1:0"`, 133 | }, 134 | }, 135 | } 136 | 137 | response := newResponse(r) 138 | assert.Equal(t, response.Cursor, "") 139 | } 140 | 141 | func TestDo(t *testing.T) { 142 | client, mux, _, teardown := setup() 143 | defer teardown() 144 | 145 | type foo struct { 146 | A string 147 | } 148 | 149 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 150 | assertMethod(t, "GET", r) 151 | fmt.Fprint(w, `{"A":"a"}`) 152 | }) 153 | 154 | req, _ := client.NewRequest("GET", "/", nil) 155 | body := new(foo) 156 | ctx := context.Background() 157 | client.Do(ctx, req, body) 158 | 159 | expected := &foo{A: "a"} 160 | 161 | assert.Equal(t, expected, body) 162 | } 163 | 164 | func TestDo_rateLimit(t *testing.T) { 165 | client, mux, _, teardown := setup() 166 | defer teardown() 167 | 168 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 169 | w.Header().Set(headerRateLimit, "40") 170 | w.Header().Set(headerRateRemaining, "39") 171 | w.Header().Set(headerRateReset, "1654566542") 172 | w.Header().Set(headerRateConcurrentLimit, "25") 173 | w.Header().Set(headerRateConcurrentRemaining, "24") 174 | }) 175 | 176 | req, _ := client.NewRequest("GET", "/", nil) 177 | ctx := context.Background() 178 | resp, err := client.Do(ctx, req, nil) 179 | assert.NoError(t, err) 180 | assert.Equal(t, resp.Rate.Limit, 40) 181 | assert.Equal(t, resp.Rate.Remaining, 39) 182 | assert.Equal(t, resp.Rate.Reset, time.Date(2022, time.June, 7, 1, 49, 2, 0, time.UTC)) 183 | assert.Equal(t, resp.Rate.ConcurrentLimit, 25) 184 | assert.Equal(t, resp.Rate.ConcurrentRemaining, 24) 185 | } 186 | 187 | func TestDo_nilContext(t *testing.T) { 188 | client, _, _, teardown := setup() 189 | defer teardown() 190 | 191 | req, _ := client.NewRequest("GET", "/", nil) 192 | _, err := client.Do(nil, req, nil) 193 | 194 | assert.Equal(t, errNonNilContext, err) 195 | } 196 | 197 | func TestDo_httpErrorPlainText(t *testing.T) { 198 | client, mux, _, teardown := setup() 199 | defer teardown() 200 | 201 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 202 | assertMethod(t, "GET", r) 203 | http.Error(w, "Bad Request", http.StatusBadRequest) 204 | }) 205 | 206 | req, _ := client.NewRequest("GET", ".", nil) 207 | ctx := context.Background() 208 | resp, err := client.Do(ctx, req, nil) 209 | 210 | assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "Bad Request"}, err) 211 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 212 | } 213 | 214 | func TestDo_apiError(t *testing.T) { 215 | client, mux, _, teardown := setup() 216 | defer teardown() 217 | 218 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 219 | assertMethod(t, "GET", r) 220 | w.Header().Set("Content-Type", "application/json") 221 | w.WriteHeader(http.StatusBadRequest) 222 | fmt.Fprint(w, `{"detail": "API error message"}`) 223 | }) 224 | 225 | req, _ := client.NewRequest("GET", ".", nil) 226 | ctx := context.Background() 227 | resp, err := client.Do(ctx, req, nil) 228 | 229 | assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "API error message"}, err) 230 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 231 | } 232 | 233 | func TestDo_apiError_noDetail(t *testing.T) { 234 | client, mux, _, teardown := setup() 235 | defer teardown() 236 | 237 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 238 | assertMethod(t, "GET", r) 239 | w.Header().Set("Content-Type", "application/json") 240 | w.WriteHeader(http.StatusBadRequest) 241 | fmt.Fprint(w, `"API error message"`) 242 | }) 243 | 244 | req, _ := client.NewRequest("GET", ".", nil) 245 | ctx := context.Background() 246 | resp, err := client.Do(ctx, req, nil) 247 | 248 | assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "API error message"}, err) 249 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 250 | } 251 | 252 | func TestCheckResponse(t *testing.T) { 253 | testcases := []struct { 254 | description string 255 | body string 256 | }{ 257 | { 258 | description: "JSON object", 259 | body: `{"detail": "Error message"}`, 260 | }, 261 | { 262 | description: "JSON string", 263 | body: `"Error message"`, 264 | }, 265 | { 266 | description: "plain text", 267 | body: `Error message`, 268 | }, 269 | } 270 | for _, tc := range testcases { 271 | t.Run(tc.description, func(t *testing.T) { 272 | res := &http.Response{ 273 | Request: &http.Request{}, 274 | StatusCode: http.StatusBadRequest, 275 | Body: io.NopCloser(strings.NewReader(tc.body)), 276 | } 277 | 278 | err := CheckResponse(res) 279 | 280 | expected := &ErrorResponse{ 281 | Response: res, 282 | Detail: "Error message", 283 | } 284 | assert.ErrorIs(t, err, expected) 285 | }) 286 | } 287 | 288 | } 289 | 290 | func TestCheckResponse_rateLimit(t *testing.T) { 291 | testcases := []struct { 292 | description string 293 | addHeaders func(res *http.Response) 294 | }{ 295 | { 296 | description: "headerRateRemaining", 297 | addHeaders: func(res *http.Response) { 298 | res.Header.Set(headerRateRemaining, "0") 299 | res.Header.Set(headerRateReset, "123456") 300 | }, 301 | }, 302 | { 303 | description: "headerRateConcurrentRemaining", 304 | addHeaders: func(res *http.Response) { 305 | res.Header.Set(headerRateConcurrentRemaining, "0") 306 | res.Header.Set(headerRateReset, "123456") 307 | }, 308 | }, 309 | } 310 | for _, tc := range testcases { 311 | t.Run(tc.description, func(t *testing.T) { 312 | res := &http.Response{ 313 | Request: &http.Request{}, 314 | StatusCode: http.StatusTooManyRequests, 315 | Header: http.Header{}, 316 | Body: io.NopCloser(strings.NewReader(`{"detail": "Rate limit exceeded"}`)), 317 | } 318 | tc.addHeaders(res) 319 | 320 | err := CheckResponse(res) 321 | 322 | expected := &RateLimitError{ 323 | Rate: ParseRate(res), 324 | Response: res, 325 | Detail: "Rate limit exceeded", 326 | } 327 | assert.ErrorIs(t, err, expected) 328 | }) 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /sentry/spike_protections.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | type SpikeProtectionsService service 10 | 11 | type SpikeProtectionParams struct { 12 | ProjectSlug string `json:"-" url:"projectSlug,omitempty"` 13 | Projects []string `json:"projects" url:"-"` 14 | } 15 | 16 | func (s *SpikeProtectionsService) Enable(ctx context.Context, organizationSlug string, params *SpikeProtectionParams) (*Response, error) { 17 | u := fmt.Sprintf("0/organizations/%v/spike-protections/", organizationSlug) 18 | u, err := addQuery(u, params) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | req, err := s.client.NewRequest(http.MethodPost, u, params) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return s.client.Do(ctx, req, nil) 29 | } 30 | 31 | func (s *SpikeProtectionsService) Disable(ctx context.Context, organizationSlug string, params *SpikeProtectionParams) (*Response, error) { 32 | u := fmt.Sprintf("0/organizations/%v/spike-protections/", organizationSlug) 33 | u, err := addQuery(u, params) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | req, err := s.client.NewRequest(http.MethodDelete, u, params) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return s.client.Do(ctx, req, nil) 44 | } 45 | -------------------------------------------------------------------------------- /sentry/spike_protections_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSpikeProtectionsService_Enable(t *testing.T) { 12 | client, mux, _, teardown := setup() 13 | defer teardown() 14 | 15 | mux.HandleFunc("/api/0/organizations/organization_slug/spike-protections/", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, http.MethodPost, r) 17 | assertPostJSON(t, map[string]interface{}{ 18 | "projects": []interface{}{"$all"}, 19 | }, r) 20 | }) 21 | 22 | params := &SpikeProtectionParams{ 23 | Projects: []string{"$all"}, 24 | } 25 | ctx := context.Background() 26 | _, err := client.SpikeProtections.Enable(ctx, "organization_slug", params) 27 | assert.NoError(t, err) 28 | } 29 | 30 | func TestSpikeProtectionsService_Disable(t *testing.T) { 31 | client, mux, _, teardown := setup() 32 | defer teardown() 33 | 34 | mux.HandleFunc("/api/0/organizations/organization_slug/spike-protections/", func(w http.ResponseWriter, r *http.Request) { 35 | assertMethod(t, http.MethodDelete, r) 36 | assertPostJSON(t, map[string]interface{}{ 37 | "projects": []interface{}{"$all"}, 38 | }, r) 39 | }) 40 | 41 | params := &SpikeProtectionParams{ 42 | Projects: []string{"$all"}, 43 | } 44 | ctx := context.Background() 45 | _, err := client.SpikeProtections.Disable(ctx, "organization_slug", params) 46 | assert.NoError(t, err) 47 | } 48 | -------------------------------------------------------------------------------- /sentry/team_members.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type TeamMember struct { 11 | ID *string `json:"id"` 12 | Slug *string `json:"slug"` 13 | Name *string `json:"name"` 14 | DateCreated *time.Time `json:"dateCreated"` 15 | IsMember *bool `json:"isMember"` 16 | TeamRole *string `json:"teamRole"` 17 | Flags map[string]bool `json:"flags"` 18 | Access []string `json:"access"` 19 | HasAccess *bool `json:"hasAccess"` 20 | IsPending *bool `json:"isPending"` 21 | MemberCount *int `json:"memberCount"` 22 | Avatar *Avatar `json:"avatar"` 23 | } 24 | 25 | // TeamMember provides methods for accessing Sentry team member API endpoints. 26 | type TeamMembersService service 27 | 28 | func (s *TeamMembersService) Create(ctx context.Context, organizationSlug string, memberID string, teamSlug string) (*TeamMember, *Response, error) { 29 | u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) 30 | req, err := s.client.NewRequest(http.MethodPost, u, nil) 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | 35 | member := new(TeamMember) 36 | resp, err := s.client.Do(ctx, req, member) 37 | if err != nil { 38 | return nil, resp, err 39 | } 40 | return member, resp, nil 41 | } 42 | 43 | type UpdateTeamMemberParams struct { 44 | TeamRole *string `json:"teamRole,omitempty"` 45 | } 46 | 47 | type UpdateTeamMemberResponse struct { 48 | IsActive *bool `json:"isActive,omitempty"` 49 | TeamRole *string `json:"teamRole,omitempty"` 50 | } 51 | 52 | func (s *TeamMembersService) Update(ctx context.Context, organizationSlug string, memberID string, teamSlug string, params *UpdateTeamMemberParams) (*UpdateTeamMemberResponse, *Response, error) { 53 | u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) 54 | req, err := s.client.NewRequest(http.MethodPut, u, params) 55 | if err != nil { 56 | return nil, nil, err 57 | } 58 | 59 | member := new(UpdateTeamMemberResponse) 60 | resp, err := s.client.Do(ctx, req, member) 61 | if err != nil { 62 | return nil, resp, err 63 | } 64 | return member, resp, nil 65 | } 66 | 67 | func (s *TeamMembersService) Delete(ctx context.Context, organizationSlug string, memberID string, teamSlug string) (*TeamMember, *Response, error) { 68 | u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) 69 | req, err := s.client.NewRequest(http.MethodDelete, u, nil) 70 | if err != nil { 71 | return nil, nil, err 72 | } 73 | 74 | member := new(TeamMember) 75 | resp, err := s.client.Do(ctx, req, member) 76 | if err != nil { 77 | return nil, resp, err 78 | } 79 | return member, resp, nil 80 | } 81 | -------------------------------------------------------------------------------- /sentry/team_members_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestTeamMembersService_Create(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/organizations/organization_slug/members/member_id/teams/team_slug/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "POST", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `{ 20 | "id": "4502349234123", 21 | "slug": "ancient-gabelers", 22 | "name": "Ancient Gabelers", 23 | "dateCreated": "2023-05-31T19:47:53.621181Z", 24 | "isMember": true, 25 | "teamRole": "contributor", 26 | "flags": { 27 | "idp:provisioned": false 28 | }, 29 | "access": [ 30 | "alerts:read", 31 | "event:write", 32 | "project:read", 33 | "team:read", 34 | "member:read", 35 | "project:releases", 36 | "event:read", 37 | "org:read" 38 | ], 39 | "hasAccess": true, 40 | "isPending": false, 41 | "memberCount": 3, 42 | "avatar": { 43 | "avatarType": "letter_avatar", 44 | "avatarUuid": null 45 | } 46 | }`) 47 | }) 48 | 49 | ctx := context.Background() 50 | team, _, err := client.TeamMembers.Create(ctx, "organization_slug", "member_id", "team_slug") 51 | assert.NoError(t, err) 52 | 53 | expected := &TeamMember{ 54 | ID: String("4502349234123"), 55 | Slug: String("ancient-gabelers"), 56 | Name: String("Ancient Gabelers"), 57 | DateCreated: Time(mustParseTime("2023-05-31T19:47:53.621181Z")), 58 | IsMember: Bool(true), 59 | TeamRole: String("contributor"), 60 | Flags: map[string]bool{ 61 | "idp:provisioned": false, 62 | }, 63 | Access: []string{ 64 | "alerts:read", 65 | "event:write", 66 | "project:read", 67 | "team:read", 68 | "member:read", 69 | "project:releases", 70 | "event:read", 71 | "org:read", 72 | }, 73 | HasAccess: Bool(true), 74 | IsPending: Bool(false), 75 | MemberCount: Int(3), 76 | Avatar: &Avatar{ 77 | UUID: nil, 78 | Type: "letter_avatar", 79 | }, 80 | } 81 | assert.Equal(t, expected, team) 82 | } 83 | 84 | func TestTeamMembersService_Delete(t *testing.T) { 85 | client, mux, _, teardown := setup() 86 | defer teardown() 87 | 88 | mux.HandleFunc("/api/0/organizations/organization_slug/members/member_id/teams/team_slug/", func(w http.ResponseWriter, r *http.Request) { 89 | assertMethod(t, "DELETE", r) 90 | w.Header().Set("Content-Type", "application/json") 91 | fmt.Fprint(w, `{ 92 | "id": "4502349234123", 93 | "slug": "ancient-gabelers", 94 | "name": "Ancient Gabelers", 95 | "dateCreated": "2023-05-31T19:47:53.621181Z", 96 | "isMember": false, 97 | "teamRole": null, 98 | "flags": { 99 | "idp:provisioned": false 100 | }, 101 | "access": [ 102 | "alerts:read", 103 | "event:write", 104 | "project:read", 105 | "team:read", 106 | "member:read", 107 | "project:releases", 108 | "event:read", 109 | "org:read" 110 | ], 111 | "hasAccess": true, 112 | "isPending": false, 113 | "memberCount": 3, 114 | "avatar": { 115 | "avatarType": "letter_avatar", 116 | "avatarUuid": null 117 | } 118 | }`) 119 | }) 120 | 121 | ctx := context.Background() 122 | team, _, err := client.TeamMembers.Delete(ctx, "organization_slug", "member_id", "team_slug") 123 | assert.NoError(t, err) 124 | 125 | expected := &TeamMember{ 126 | ID: String("4502349234123"), 127 | Slug: String("ancient-gabelers"), 128 | Name: String("Ancient Gabelers"), 129 | DateCreated: Time(mustParseTime("2023-05-31T19:47:53.621181Z")), 130 | IsMember: Bool(false), 131 | TeamRole: nil, 132 | Flags: map[string]bool{ 133 | "idp:provisioned": false, 134 | }, 135 | Access: []string{ 136 | "alerts:read", 137 | "event:write", 138 | "project:read", 139 | "team:read", 140 | "member:read", 141 | "project:releases", 142 | "event:read", 143 | "org:read", 144 | }, 145 | HasAccess: Bool(true), 146 | IsPending: Bool(false), 147 | MemberCount: Int(3), 148 | Avatar: &Avatar{ 149 | UUID: nil, 150 | Type: "letter_avatar", 151 | }, 152 | } 153 | assert.Equal(t, expected, team) 154 | } 155 | -------------------------------------------------------------------------------- /sentry/teams.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Team represents a Sentry team that is bound to an organization. 10 | // https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/team.py#L155C7-L190 11 | type Team struct { 12 | ID *string `json:"id,omitempty"` 13 | Slug *string `json:"slug,omitempty"` 14 | Name *string `json:"name,omitempty"` 15 | DateCreated *time.Time `json:"dateCreated,omitempty"` 16 | IsMember *bool `json:"isMember,omitempty"` 17 | TeamRole *string `json:"teamRole,omitempty"` 18 | HasAccess *bool `json:"hasAccess,omitempty"` 19 | IsPending *bool `json:"isPending,omitempty"` 20 | MemberCount *int `json:"memberCount,omitempty"` 21 | Avatar *Avatar `json:"avatar,omitempty"` 22 | OrgRole *string `json:"orgRole,omitempty"` 23 | // TODO: externalTeams 24 | // TODO: projects 25 | } 26 | 27 | // TeamsService provides methods for accessing Sentry team API endpoints. 28 | // https://docs.sentry.io/api/teams/ 29 | type TeamsService service 30 | 31 | // List returns a list of teams bound to an organization. 32 | // https://docs.sentry.io/api/teams/list-an-organizations-teams/ 33 | func (s *TeamsService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*Team, *Response, error) { 34 | u := fmt.Sprintf("0/organizations/%v/teams/", organizationSlug) 35 | u, err := addQuery(u, params) 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | 40 | req, err := s.client.NewRequest("GET", u, nil) 41 | if err != nil { 42 | return nil, nil, err 43 | } 44 | 45 | teams := []*Team{} 46 | resp, err := s.client.Do(ctx, req, &teams) 47 | if err != nil { 48 | return nil, resp, err 49 | } 50 | return teams, resp, nil 51 | } 52 | 53 | // Get details on an individual team of an organization. 54 | // https://docs.sentry.io/api/teams/retrieve-a-team/ 55 | func (s *TeamsService) Get(ctx context.Context, organizationSlug string, slug string) (*Team, *Response, error) { 56 | u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) 57 | req, err := s.client.NewRequest("GET", u, nil) 58 | if err != nil { 59 | return nil, nil, err 60 | } 61 | 62 | team := new(Team) 63 | resp, err := s.client.Do(ctx, req, team) 64 | if err != nil { 65 | return nil, resp, err 66 | } 67 | return team, resp, nil 68 | } 69 | 70 | // CreateTeamParams are the parameters for TeamService.Create. 71 | type CreateTeamParams struct { 72 | Name *string `json:"name,omitempty"` 73 | Slug *string `json:"slug,omitempty"` 74 | } 75 | 76 | // Create a new Sentry team bound to an organization. 77 | // https://docs.sentry.io/api/teams/create-a-new-team/ 78 | func (s *TeamsService) Create(ctx context.Context, organizationSlug string, params *CreateTeamParams) (*Team, *Response, error) { 79 | u := fmt.Sprintf("0/organizations/%v/teams/", organizationSlug) 80 | req, err := s.client.NewRequest("POST", u, params) 81 | if err != nil { 82 | return nil, nil, err 83 | } 84 | 85 | team := new(Team) 86 | resp, err := s.client.Do(ctx, req, team) 87 | if err != nil { 88 | return nil, resp, err 89 | } 90 | return team, resp, nil 91 | } 92 | 93 | // UpdateTeamParams are the parameters for TeamService.Update. 94 | type UpdateTeamParams struct { 95 | Name *string `json:"name,omitempty"` 96 | Slug *string `json:"slug,omitempty"` 97 | } 98 | 99 | // Update settings for a given team. 100 | // https://docs.sentry.io/api/teams/update-a-team/ 101 | func (s *TeamsService) Update(ctx context.Context, organizationSlug string, slug string, params *UpdateTeamParams) (*Team, *Response, error) { 102 | u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) 103 | req, err := s.client.NewRequest("PUT", u, params) 104 | if err != nil { 105 | return nil, nil, err 106 | } 107 | 108 | team := new(Team) 109 | resp, err := s.client.Do(ctx, req, team) 110 | if err != nil { 111 | return nil, resp, err 112 | } 113 | return team, resp, nil 114 | } 115 | 116 | // Delete a team. 117 | // https://docs.sentry.io/api/teams/update-a-team/ 118 | func (s *TeamsService) Delete(ctx context.Context, organizationSlug string, slug string) (*Response, error) { 119 | u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) 120 | req, err := s.client.NewRequest("DELETE", u, nil) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | return s.client.Do(ctx, req, nil) 126 | } 127 | -------------------------------------------------------------------------------- /sentry/teams_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestTeamsService_List(t *testing.T) { 13 | client, mux, _, teardown := setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/teams/", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprint(w, `[ 20 | { 21 | "id": "3", 22 | "slug": "ancient-gabelers", 23 | "name": "Ancient Gabelers", 24 | "dateCreated": "2017-07-18T19:29:46.305Z", 25 | "isMember": false, 26 | "teamRole": "admin", 27 | "hasAccess": true, 28 | "isPending": false, 29 | "memberCount": 1, 30 | "avatar": { 31 | "avatarType": "letter_avatar", 32 | "avatarUuid": null 33 | }, 34 | "externalTeams": [], 35 | "projects": [] 36 | }, 37 | { 38 | "id": "2", 39 | "slug": "powerful-abolitionist", 40 | "name": "Powerful Abolitionist", 41 | "dateCreated": "2017-07-18T19:29:24.743Z", 42 | "isMember": false, 43 | "teamRole": "admin", 44 | "hasAccess": true, 45 | "isPending": false, 46 | "memberCount": 1, 47 | "avatar": { 48 | "avatarType": "letter_avatar", 49 | "avatarUuid": null 50 | }, 51 | "externalTeams": [], 52 | "projects": [ 53 | { 54 | "status": "active", 55 | "slug": "prime-mover", 56 | "defaultEnvironment": null, 57 | "features": [ 58 | "data-forwarding", 59 | "rate-limits", 60 | "releases" 61 | ], 62 | "color": "#bf5b3f", 63 | "isPublic": false, 64 | "dateCreated": "2017-07-18T19:29:30.063Z", 65 | "platforms": [], 66 | "callSign": "PRIME-MOVER", 67 | "firstEvent": null, 68 | "processingIssues": 0, 69 | "isBookmarked": false, 70 | "callSignReviewed": false, 71 | "id": "3", 72 | "name": "Prime Mover" 73 | }, 74 | { 75 | "status": "active", 76 | "slug": "pump-station", 77 | "defaultEnvironment": null, 78 | "features": [ 79 | "data-forwarding", 80 | "rate-limits", 81 | "releases" 82 | ], 83 | "color": "#3fbf7f", 84 | "isPublic": false, 85 | "dateCreated": "2017-07-18T19:29:24.793Z", 86 | "platforms": [], 87 | "callSign": "PUMP-STATION", 88 | "firstEvent": null, 89 | "processingIssues": 0, 90 | "isBookmarked": false, 91 | "callSignReviewed": false, 92 | "id": "2", 93 | "name": "Pump Station" 94 | }, 95 | { 96 | "status": "active", 97 | "slug": "the-spoiled-yoghurt", 98 | "defaultEnvironment": null, 99 | "features": [ 100 | "data-forwarding", 101 | "rate-limits" 102 | ], 103 | "color": "#bf6e3f", 104 | "isPublic": false, 105 | "dateCreated": "2017-07-18T19:29:44.996Z", 106 | "platforms": [], 107 | "callSign": "THE-SPOILED-YOGHURT", 108 | "firstEvent": null, 109 | "processingIssues": 0, 110 | "isBookmarked": false, 111 | "callSignReviewed": false, 112 | "id": "4", 113 | "name": "The Spoiled Yoghurt" 114 | } 115 | ] 116 | } 117 | ]`) 118 | }) 119 | 120 | ctx := context.Background() 121 | teams, _, err := client.Teams.List(ctx, "the-interstellar-jurisdiction", nil) 122 | assert.NoError(t, err) 123 | 124 | expected := []*Team{ 125 | { 126 | ID: String("3"), 127 | Slug: String("ancient-gabelers"), 128 | Name: String("Ancient Gabelers"), 129 | DateCreated: Time(mustParseTime("2017-07-18T19:29:46.305Z")), 130 | IsMember: Bool(false), 131 | TeamRole: String("admin"), 132 | HasAccess: Bool(true), 133 | IsPending: Bool(false), 134 | MemberCount: Int(1), 135 | Avatar: &Avatar{ 136 | Type: "letter_avatar", 137 | }, 138 | }, 139 | { 140 | ID: String("2"), 141 | Slug: String("powerful-abolitionist"), 142 | Name: String("Powerful Abolitionist"), 143 | DateCreated: Time(mustParseTime("2017-07-18T19:29:24.743Z")), 144 | IsMember: Bool(false), 145 | TeamRole: String("admin"), 146 | HasAccess: Bool(true), 147 | IsPending: Bool(false), 148 | MemberCount: Int(1), 149 | Avatar: &Avatar{ 150 | Type: "letter_avatar", 151 | }, 152 | }, 153 | } 154 | assert.Equal(t, expected, teams) 155 | } 156 | 157 | func TestTeamsService_Get(t *testing.T) { 158 | client, mux, _, teardown := setup() 159 | defer teardown() 160 | 161 | mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/powerful-abolitionist/", func(w http.ResponseWriter, r *http.Request) { 162 | assertMethod(t, "GET", r) 163 | w.Header().Set("Content-Type", "application/json") 164 | fmt.Fprint(w, `{ 165 | "slug": "powerful-abolitionist", 166 | "name": "Powerful Abolitionist", 167 | "hasAccess": true, 168 | "isPending": false, 169 | "dateCreated": "2017-07-18T19:29:24.743Z", 170 | "isMember": false, 171 | "organization": { 172 | "name": "The Interstellar Jurisdiction", 173 | "slug": "the-interstellar-jurisdiction", 174 | "avatar": { 175 | "avatarUuid": null, 176 | "avatarType": "letter_avatar" 177 | }, 178 | "dateCreated": "2017-07-18T19:29:24.565Z", 179 | "id": "2", 180 | "isEarlyAdopter": false 181 | }, 182 | "id": "2" 183 | }`) 184 | }) 185 | 186 | ctx := context.Background() 187 | team, _, err := client.Teams.Get(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") 188 | assert.NoError(t, err) 189 | 190 | expected := &Team{ 191 | ID: String("2"), 192 | Slug: String("powerful-abolitionist"), 193 | Name: String("Powerful Abolitionist"), 194 | DateCreated: Time(mustParseTime("2017-07-18T19:29:24.743Z")), 195 | HasAccess: Bool(true), 196 | IsPending: Bool(false), 197 | IsMember: Bool(false), 198 | } 199 | assert.Equal(t, expected, team) 200 | } 201 | 202 | func TestTeamsService_Create(t *testing.T) { 203 | client, mux, _, teardown := setup() 204 | defer teardown() 205 | 206 | mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/teams/", func(w http.ResponseWriter, r *http.Request) { 207 | assertMethod(t, "POST", r) 208 | assertPostJSON(t, map[string]interface{}{ 209 | "name": "Ancient Gabelers", 210 | }, r) 211 | w.Header().Set("Content-Type", "application/json") 212 | fmt.Fprint(w, `{ 213 | "slug": "ancient-gabelers", 214 | "name": "Ancient Gabelers", 215 | "hasAccess": true, 216 | "isPending": false, 217 | "dateCreated": "2017-07-18T19:29:46.305Z", 218 | "isMember": false, 219 | "id": "3" 220 | }`) 221 | }) 222 | 223 | params := &CreateTeamParams{ 224 | Name: String("Ancient Gabelers"), 225 | } 226 | ctx := context.Background() 227 | team, _, err := client.Teams.Create(ctx, "the-interstellar-jurisdiction", params) 228 | assert.NoError(t, err) 229 | 230 | expected := &Team{ 231 | ID: String("3"), 232 | Slug: String("ancient-gabelers"), 233 | Name: String("Ancient Gabelers"), 234 | DateCreated: Time(mustParseTime("2017-07-18T19:29:46.305Z")), 235 | HasAccess: Bool(true), 236 | IsPending: Bool(false), 237 | IsMember: Bool(false), 238 | } 239 | assert.Equal(t, expected, team) 240 | } 241 | 242 | func TestTeamsService_Update(t *testing.T) { 243 | client, mux, _, teardown := setup() 244 | defer teardown() 245 | 246 | mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/the-obese-philosophers/", func(w http.ResponseWriter, r *http.Request) { 247 | assertMethod(t, "PUT", r) 248 | assertPostJSON(t, map[string]interface{}{ 249 | "name": "The Inflated Philosophers", 250 | }, r) 251 | w.Header().Set("Content-Type", "application/json") 252 | fmt.Fprint(w, `{ 253 | "slug": "the-obese-philosophers", 254 | "name": "The Inflated Philosophers", 255 | "hasAccess": true, 256 | "isPending": false, 257 | "dateCreated": "2017-07-18T19:30:14.736Z", 258 | "isMember": false, 259 | "id": "4" 260 | }`) 261 | }) 262 | 263 | params := &UpdateTeamParams{ 264 | Name: String("The Inflated Philosophers"), 265 | } 266 | ctx := context.Background() 267 | team, _, err := client.Teams.Update(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers", params) 268 | assert.NoError(t, err) 269 | expected := &Team{ 270 | ID: String("4"), 271 | Slug: String("the-obese-philosophers"), 272 | Name: String("The Inflated Philosophers"), 273 | DateCreated: Time(mustParseTime("2017-07-18T19:30:14.736Z")), 274 | HasAccess: Bool(true), 275 | IsPending: Bool(false), 276 | IsMember: Bool(false), 277 | } 278 | assert.Equal(t, expected, team) 279 | } 280 | 281 | func TestTeamsService_Delete(t *testing.T) { 282 | client, mux, _, teardown := setup() 283 | defer teardown() 284 | 285 | mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/the-obese-philosophers/", func(w http.ResponseWriter, r *http.Request) { 286 | assertMethod(t, "DELETE", r) 287 | }) 288 | 289 | ctx := context.Background() 290 | _, err := client.Teams.Delete(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers") 291 | assert.NoError(t, err) 292 | 293 | } 294 | -------------------------------------------------------------------------------- /sentry/types.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // BoolOrStringSlice is a type that can be unmarshaled from either a bool or a 9 | // string slice. 10 | type BoolOrStringSlice struct { 11 | IsBool bool 12 | IsStringSlice bool 13 | BoolVal bool 14 | StringSliceVal []string 15 | } 16 | 17 | var _ json.Unmarshaler = (*BoolOrStringSlice)(nil) 18 | var _ json.Marshaler = (*BoolOrStringSlice)(nil) 19 | 20 | // UnmarshalJSON implements json.Unmarshaler. 21 | func (bos *BoolOrStringSlice) UnmarshalJSON(data []byte) error { 22 | // Try to unmarshal as a bool 23 | var boolVal bool 24 | if err := json.Unmarshal(data, &boolVal); err == nil { 25 | bos.IsBool = true 26 | bos.IsStringSlice = false 27 | bos.BoolVal = boolVal 28 | return nil 29 | } 30 | 31 | // Try to unmarshal as a string slice 32 | var sliceVal []string 33 | if err := json.Unmarshal(data, &sliceVal); err == nil { 34 | bos.IsBool = false 35 | bos.IsStringSlice = true 36 | bos.StringSliceVal = sliceVal 37 | return nil 38 | } 39 | 40 | // If neither worked, return an error 41 | return fmt.Errorf("unable to unmarshal as bool or string slice: %s", string(data)) 42 | } 43 | 44 | func (bos BoolOrStringSlice) MarshalJSON() ([]byte, error) { 45 | if bos.IsBool { 46 | return json.Marshal(bos.BoolVal) 47 | } 48 | return json.Marshal(bos.StringSliceVal) 49 | } 50 | 51 | // Int64OrString is a type that can be unmarshaled from either an int64 or a 52 | // string. 53 | type Int64OrString struct { 54 | IsInt64 bool 55 | IsString bool 56 | Int64Val int64 57 | StringVal string 58 | } 59 | 60 | var _ json.Unmarshaler = (*Int64OrString)(nil) 61 | var _ json.Marshaler = (*Int64OrString)(nil) 62 | 63 | func (ios *Int64OrString) UnmarshalJSON(data []byte) error { 64 | // Try to unmarshal as an int64 65 | var int64Val int64 66 | if err := json.Unmarshal(data, &int64Val); err == nil { 67 | ios.IsInt64 = true 68 | ios.IsString = false 69 | ios.Int64Val = int64Val 70 | return nil 71 | } 72 | 73 | // Try to unmarshal as a string 74 | var stringVal string 75 | if err := json.Unmarshal(data, &stringVal); err == nil { 76 | ios.IsInt64 = false 77 | ios.IsString = true 78 | ios.StringVal = stringVal 79 | return nil 80 | } 81 | 82 | // If neither worked, return an error 83 | return fmt.Errorf("unable to unmarshal as int64 or string: %s", string(data)) 84 | } 85 | 86 | func (ios Int64OrString) MarshalJSON() ([]byte, error) { 87 | if ios.IsInt64 { 88 | return json.Marshal(ios.Int64Val) 89 | } 90 | return json.Marshal(ios.StringVal) 91 | } 92 | -------------------------------------------------------------------------------- /sentry/users.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import "time" 4 | 5 | // User represents a Sentry User. 6 | // https://github.com/getsentry/sentry/blob/275e6efa0f364ce05d9bfd09386b895b8a5e0671/src/sentry/api/serializers/models/user.py#L35 7 | type User struct { 8 | ID string `json:"id"` 9 | Name string `json:"name"` 10 | Username string `json:"username"` 11 | Email string `json:"email"` 12 | AvatarURL string `json:"avatarUrl"` 13 | IsActive bool `json:"isActive"` 14 | HasPasswordAuth bool `json:"hasPasswordAuth"` 15 | IsManaged bool `json:"isManaged"` 16 | DateJoined time.Time `json:"dateJoined"` 17 | LastLogin time.Time `json:"lastLogin"` 18 | Has2FA bool `json:"has2fa"` 19 | LastActive time.Time `json:"lastActive"` 20 | IsSuperuser bool `json:"isSuperuser"` 21 | IsStaff bool `json:"isStaff"` 22 | Avatar Avatar `json:"avatar"` 23 | Emails []UserEmail `json:"emails"` 24 | } 25 | 26 | // UserEmail represents a user's email and its verified status. 27 | type UserEmail struct { 28 | ID string `json:"id"` 29 | Email string `json:"email"` 30 | IsVerified bool `json:"is_verified"` 31 | } 32 | 33 | // Avatar represents an avatar. 34 | type Avatar struct { 35 | UUID *string `json:"avatarUuid"` 36 | Type string `json:"avatarType"` 37 | } 38 | -------------------------------------------------------------------------------- /sentry/users_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestUserUnmarshal(t *testing.T) { 11 | data := []byte(`{ 12 | "username": "test@example.com", 13 | "lastLogin": "2020-01-02T00:00:00.000000Z", 14 | "isSuperuser": false, 15 | "emails": [ 16 | { 17 | "is_verified": true, 18 | "id": "1", 19 | "email": "test@example.com" 20 | } 21 | ], 22 | "isManaged": false, 23 | "experiments": {}, 24 | "lastActive": "2020-01-03T00:00:00.000000Z", 25 | "isStaff": false, 26 | "identities": [], 27 | "id": "1", 28 | "isActive": true, 29 | "has2fa": false, 30 | "name": "John Doe", 31 | "avatarUrl": "https://secure.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=32&d=mm", 32 | "dateJoined": "2020-01-01T00:00:00.000000Z", 33 | "options": { 34 | "timezone": "UTC", 35 | "stacktraceOrder": -1, 36 | "language": "en", 37 | "clock24Hours": false 38 | }, 39 | "flags": { 40 | "newsletter_consent_prompt": false 41 | }, 42 | "avatar": { 43 | "avatarUuid": null, 44 | "avatarType": "letter_avatar" 45 | }, 46 | "hasPasswordAuth": true, 47 | "email": "test@example.com" 48 | }`) 49 | 50 | var user User 51 | err := json.Unmarshal(data, &user) 52 | assert.NoError(t, err) 53 | 54 | assert.Equal(t, User{ 55 | ID: "1", 56 | Name: "John Doe", 57 | Username: "test@example.com", 58 | Email: "test@example.com", 59 | AvatarURL: "https://secure.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=32&d=mm", 60 | IsActive: true, 61 | HasPasswordAuth: true, 62 | IsManaged: false, 63 | DateJoined: mustParseTime("2020-01-01T00:00:00.000000Z"), 64 | LastLogin: mustParseTime("2020-01-02T00:00:00.000000Z"), 65 | Has2FA: false, 66 | LastActive: mustParseTime("2020-01-03T00:00:00.000000Z"), 67 | IsSuperuser: false, 68 | IsStaff: false, 69 | Avatar: Avatar{ 70 | Type: "letter_avatar", 71 | UUID: nil, 72 | }, 73 | Emails: []UserEmail{ 74 | { 75 | ID: "1", 76 | Email: "test@example.com", 77 | IsVerified: true, 78 | }, 79 | }, 80 | }, user) 81 | } 82 | --------------------------------------------------------------------------------