diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 1af295c..9692341 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -3,26 +3,24 @@ name: Go on: push jobs: - build: runs-on: ubuntu-latest strategy: matrix: go: - - "1.15" - - "1.16" - - "1.17" - - "1.18" + - "1.19" + - "1.20" + - "1.21" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: ${{ matrix.go }} + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} - - name: Build - run: go build -v ./... + - name: Build + run: go build -v ./... - - name: Test - run: make test + - name: Test + run: make test diff --git a/go.mod b/go.mod index 8a3ebff..ff71198 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/canva/go-sentry -go 1.18 +go 1.19 require ( github.com/google/go-querystring v1.1.0 - github.com/peterhellberg/link v1.1.0 - github.com/stretchr/testify v1.8.0 + github.com/peterhellberg/link v1.2.0 + github.com/stretchr/testify v1.8.4 ) require ( diff --git a/go.sum b/go.sum index 987b949..e80cf89 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,17 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/peterhellberg/link v1.1.0 h1:s2+RH8EGuI/mI4QwrWGSYQCRz7uNgip9BaM04HKu5kc= -github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFYl75keYyBB8= +github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= +github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= -github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= -github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sentry/errors.go b/sentry/errors.go index bb6ad50..dac7627 100644 --- a/sentry/errors.go +++ b/sentry/errors.go @@ -8,10 +8,9 @@ import ( // APIError represents a Sentry API Error response. // Should look like: // -// type apiError struct { -// Detail string `json:"detail"` -// } -// +// type apiError struct { +// Detail string `json:"detail"` +// } type APIError struct { f interface{} // unknown } diff --git a/sentry/issue_alerts.go b/sentry/issue_alerts.go index 33e59db..2b1604b 100644 --- a/sentry/issue_alerts.go +++ b/sentry/issue_alerts.go @@ -2,6 +2,7 @@ package sentry import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -10,20 +11,20 @@ import ( // IssueAlert represents an issue alert configured for this project. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/rule.py#L131-L155 type IssueAlert struct { - ID *string `json:"id,omitempty"` - Conditions []*IssueAlertCondition `json:"conditions,omitempty"` - Filters []*IssueAlertFilter `json:"filters,omitempty"` - Actions []*IssueAlertAction `json:"actions,omitempty"` - ActionMatch *string `json:"actionMatch,omitempty"` - FilterMatch *string `json:"filterMatch,omitempty"` - Frequency *int `json:"frequency,omitempty"` - Name *string `json:"name,omitempty"` - DateCreated *time.Time `json:"dateCreated,omitempty"` - Owner *string `json:"owner,omitempty"` - CreatedBy *IssueAlertCreatedBy `json:"createdBy,omitempty"` - Environment *string `json:"environment,omitempty"` - Projects []string `json:"projects,omitempty"` - TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule + ID *string `json:"id,omitempty"` + Conditions []map[string]interface{} `json:"conditions,omitempty"` + Filters []map[string]interface{} `json:"filters,omitempty"` + Actions []map[string]interface{} `json:"actions,omitempty"` + ActionMatch *string `json:"actionMatch,omitempty"` + FilterMatch *string `json:"filterMatch,omitempty"` + Frequency *json.Number `json:"frequency,omitempty"` + Name *string `json:"name,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Owner *string `json:"owner,omitempty"` + CreatedBy *IssueAlertCreatedBy `json:"createdBy,omitempty"` + Environment *string `json:"environment,omitempty"` + Projects []string `json:"projects,omitempty"` + TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule } // IssueAlertCreatedBy for defining the rule creator. @@ -33,15 +34,6 @@ type IssueAlertCreatedBy struct { Email *string `json:"email,omitempty"` } -// IssueAlertCondition for defining conditions. -type IssueAlertCondition map[string]interface{} - -// IssueAlertAction for defining actions. -type IssueAlertAction map[string]interface{} - -// IssueAlertFilter for defining actions. -type IssueAlertFilter map[string]interface{} - // IssueAlertTaskDetail represents the inline struct Sentry defines for task details // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/endpoints/project_rule_task_details.py#L29 type IssueAlertTaskDetail struct { diff --git a/sentry/issue_alerts_test.go b/sentry/issue_alerts_test.go index e8b759c..1bcc350 100644 --- a/sentry/issue_alerts_test.go +++ b/sentry/issue_alerts_test.go @@ -57,9 +57,9 @@ func TestIssueAlertsService_List(t *testing.T) { ID: String("12345"), ActionMatch: String("any"), Environment: String("production"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", "name": "An issue is first seen", @@ -67,7 +67,7 @@ func TestIssueAlertsService_List(t *testing.T) { "interval": "1h", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -221,7 +221,7 @@ func TestIssueAlertsService_Get(t *testing.T) { expected := &IssueAlert{ ID: String("11185158"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", "name": "A new issue is created", @@ -256,7 +256,7 @@ func TestIssueAlertsService_Get(t *testing.T) { "name": "The issue affects more than 100.0 percent of sessions in 1h", }, }, - Filters: []*IssueAlertFilter{ + Filters: []map[string]interface{}{ { "comparison_type": "older", "time": "minute", @@ -300,7 +300,7 @@ func TestIssueAlertsService_Get(t *testing.T) { "name": "The event's level is equal to fatal", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "targetType": "IssueOwners", "id": "sentry.mail.actions.NotifyEmailAction", @@ -326,7 +326,7 @@ func TestIssueAlertsService_Get(t *testing.T) { }, ActionMatch: String("any"), FilterMatch: String("any"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("My Rule Name"), DateCreated: Time(mustParseTime("2022-05-23T19:54:30.860115Z")), Owner: String("team:1322366"), @@ -404,9 +404,9 @@ func TestIssueAlertsService_Create(t *testing.T) { params := &IssueAlert{ ActionMatch: String("all"), Environment: String("production"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", @@ -414,7 +414,7 @@ func TestIssueAlertsService_Create(t *testing.T) { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -433,9 +433,9 @@ func TestIssueAlertsService_Create(t *testing.T) { ID: String("123456"), ActionMatch: String("all"), Environment: String("production"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", @@ -443,7 +443,7 @@ func TestIssueAlertsService_Create(t *testing.T) { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -532,9 +532,9 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { params := &IssueAlert{ ActionMatch: String("all"), Environment: String("production"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", @@ -542,7 +542,7 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -561,9 +561,9 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { ID: String("123456"), ActionMatch: String("all"), Environment: String("production"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", @@ -571,7 +571,7 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -596,16 +596,16 @@ func TestIssueAlertsService_Update(t *testing.T) { ActionMatch: String("all"), FilterMatch: String("any"), Environment: String("staging"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", "value": 500, "interval": "1h", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -615,7 +615,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "workspace": "1234", }, }, - Filters: []*IssueAlertFilter{ + Filters: []map[string]interface{}{ { "id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "name": "The issue has happened at least 4 times", @@ -709,15 +709,15 @@ func TestIssueAlertsService_Update(t *testing.T) { ID: String("12345"), ActionMatch: String("any"), Environment: String("staging"), - Frequency: Int(30), + Frequency: JsonNumber(json.Number("30")), Name: String("Notify errors"), - Conditions: []*IssueAlertCondition{ + Conditions: []map[string]interface{}{ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", "name": "An issue is first seen", }, }, - Actions: []*IssueAlertAction{ + Actions: []map[string]interface{}{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go index 4f14779..938cc9d 100644 --- a/sentry/metric_alerts.go +++ b/sentry/metric_alerts.go @@ -2,6 +2,7 @@ package sentry import ( "context" + "errors" "fmt" "time" ) @@ -13,6 +14,7 @@ type MetricAlert struct { Name *string `json:"name,omitempty"` Environment *string `json:"environment,omitempty"` DataSet *string `json:"dataset,omitempty"` + EventTypes []string `json:"eventTypes,omitempty"` Query *string `json:"query,omitempty"` Aggregate *string `json:"aggregate,omitempty"` TimeWindow *float64 `json:"timeWindow,omitempty"` @@ -22,12 +24,21 @@ type MetricAlert struct { Projects []string `json:"projects,omitempty"` Owner *string `json:"owner,omitempty"` DateCreated *time.Time `json:"dateCreated,omitempty"` + TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the metric // Don't `omitempty` because we want to set this to null to force Sentry to register this // metric alert as a static alert when `ComparisonDelta` is empty. // We type this as a float instead of an int because Sentry, server-side, returns a float for this value. ComparisonDelta *float64 `json:"comparisonDelta"` } +// MetricAlertTaskDetail represents the inline struct Sentry defines for task details +// https://github.com/getsentry/sentry/blob/22.12.0/src/sentry/incidents/endpoints/project_alert_rule_task_details.py#L31 +type MetricAlertTaskDetail struct { + Status *string `json:"status,omitempty"` + AlertRule *MetricAlert `json:"alertRule,omitempty"` + Error *string `json:"error,omitempty"` +} + // MetricAlertTrigger represents a metric alert trigger. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger.py#L35-L47 type MetricAlertTrigger struct { @@ -44,16 +55,16 @@ type MetricAlertTrigger struct { // MetricAlertTriggerAction represents a metric alert trigger action. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger_action.py#L42-L66 type MetricAlertTriggerAction struct { - ID *string `json:"id,omitempty"` - AlertRuleTriggerID *string `json:"alertRuleTriggerId,omitempty"` - Type *string `json:"type,omitempty"` - TargetType *string `json:"targetType,omitempty"` - TargetIdentifier *string `json:"targetIdentifier,omitempty"` - InputChannelID *string `json:"inputChannelId,omitempty"` - IntegrationID *int `json:"integrationId,omitempty"` - SentryAppID *string `json:"sentryAppId,omitempty"` - DateCreated *time.Time `json:"dateCreated,omitempty"` - Description *string `json:"desc,omitempty"` + ID *string `json:"id,omitempty"` + AlertRuleTriggerID *string `json:"alertRuleTriggerId,omitempty"` + Type *string `json:"type,omitempty"` + TargetType *string `json:"targetType,omitempty"` + TargetIdentifier *Int64OrString `json:"targetIdentifier,omitempty"` + InputChannelID *string `json:"inputChannelId,omitempty"` + IntegrationID *int `json:"integrationId,omitempty"` + SentryAppID *string `json:"sentryAppId,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Description *string `json:"desc,omitempty"` } // List Alert Rules configured for a project @@ -79,7 +90,8 @@ func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, // Get details on an issue alert. func (s *MetricAlertsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*MetricAlert, *Response, error) { - u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, id) + // TODO: Remove projectSlug argument + u := fmt.Sprintf("0/organizations/%v/alert-rules/%v/", organizationSlug, id) req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err @@ -106,6 +118,15 @@ func (s *MetricAlertsService) Create(ctx context.Context, organizationSlug strin if err != nil { return nil, resp, err } + + if resp.StatusCode == 202 { + if alert.TaskUUID == nil { + return nil, resp, errors.New("missing task uuid") + } + // We just received a reference to an async task, we need to check another endpoint to retrieve the metric alert we created + return s.getMetricAlertFromMetricAlertTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) + } + return alert, resp, nil } @@ -122,9 +143,53 @@ func (s *MetricAlertsService) Update(ctx context.Context, organizationSlug strin if err != nil { return nil, resp, err } + + if resp.StatusCode == 202 { + if alert.TaskUUID == nil { + return nil, resp, errors.New("missing task uuid") + } + // We just received a reference to an async task, we need to check another endpoint to retrieve the metric alert we created + return s.getMetricAlertFromMetricAlertTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) + } + return alert, resp, nil } +func (s *MetricAlertsService) getMetricAlertFromMetricAlertTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUUID string) (*MetricAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rule-task/%v/", organizationSlug, projectSlug, taskUUID) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var resp *Response + for i := 0; i < 5; i++ { + time.Sleep(5 * time.Second) + + taskDetail := new(MetricAlertTaskDetail) + resp, err := s.client.Do(ctx, req, taskDetail) + if err != nil { + return nil, resp, err + } + + if resp.StatusCode == 404 { + return nil, resp, fmt.Errorf("cannot find metric alert creation task with UUID %v", taskUUID) + } + if taskDetail.Status != nil && taskDetail.AlertRule != nil { + if *taskDetail.Status == "success" { + return taskDetail.AlertRule, resp, err + } else if *taskDetail.Status == "failed" { + if taskDetail != nil { + return taskDetail.AlertRule, resp, errors.New(*taskDetail.Error) + } + + return taskDetail.AlertRule, resp, errors.New("error while running the metric alert creation task") + } + } + } + return nil, resp, errors.New("getting the status of the metric alert creation from Sentry took too long") +} + // Delete an Alert Rule. func (s *MetricAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, alertRuleID string) (*Response, error) { u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, alertRuleID) diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index 9beac59..599f923 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -24,6 +24,7 @@ func TestMetricAlertService_List(t *testing.T) { "name": "pump-station-alert", "environment": "production", "dataset": "transactions", + "eventTypes": ["transaction"], "query": "http.url:http://service/unreadmessages", "aggregate": "p50(transaction.duration)", "thresholdType": 0, @@ -76,6 +77,7 @@ func TestMetricAlertService_List(t *testing.T) { DataSet: String("transactions"), Query: String("http.url:http://service/unreadmessages"), Aggregate: String("p50(transaction.duration)"), + EventTypes: []string{"transaction"}, ThresholdType: Int(0), ResolveThreshold: Float64(100.0), TimeWindow: Float64(5.0), @@ -95,7 +97,7 @@ func TestMetricAlertService_List(t *testing.T) { AlertRuleTriggerID: String("12345"), Type: String("slack"), TargetType: String("specific"), - TargetIdentifier: String("#alert-rule-alerts"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, InputChannelID: String("C038NF00X4F"), IntegrationID: Int(123), DateCreated: Time(mustParseTime("2022-04-07T16:46:49.154638Z")), @@ -116,7 +118,7 @@ func TestMetricAlertService_Get(t *testing.T) { client, mux, _, teardown := setup() defer teardown() - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, ` @@ -125,6 +127,7 @@ func TestMetricAlertService_Get(t *testing.T) { "name": "pump-station-alert", "environment": "production", "dataset": "transactions", + "eventTypes": ["transaction"], "query": "http.url:http://service/unreadmessages", "aggregate": "p50(transaction.duration)", "timeWindow": 10, @@ -174,6 +177,7 @@ func TestMetricAlertService_Get(t *testing.T) { Name: String("pump-station-alert"), Environment: String("production"), DataSet: String("transactions"), + EventTypes: []string{"transaction"}, Query: String("http.url:http://service/unreadmessages"), Aggregate: String("p50(transaction.duration)"), TimeWindow: Float64(10), @@ -195,7 +199,7 @@ func TestMetricAlertService_Get(t *testing.T) { AlertRuleTriggerID: String("56789"), Type: String("slack"), TargetType: String("specific"), - TargetIdentifier: String("#alert-rule-alerts"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, InputChannelID: String("C0XXXFKLXXX"), IntegrationID: Int(111), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), @@ -211,6 +215,193 @@ func TestMetricAlertService_Get(t *testing.T) { require.Equal(t, expected, alert) } +func TestMetricAlertsService_CreateWithAsyncTask(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rule-task/fakeuuid/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, ` + { + "status": "success", + "error": null, + "alertRule": { + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "eventTypes": ["transaction"], + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": 10, + "thresholdType": 0, + "resolveThreshold": 0, + "triggers": [ + { + "actions": [ + { + "alertRuleTriggerId": "56789", + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #alert-rule-alerts", + "id": "12389", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + "sentryAppId": null, + "targetIdentifier": "#alert-rule-alerts", + "targetType": "specific", + "type": "slack" + } + ], + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0 + } + ], + "projects": [ + "pump-station" + ], + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.05618Z" + } + } + `) + }) + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostJSONValue(t, map[string]interface{}{ + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "eventTypes": []string{"transaction"}, + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": 10, + "thresholdType": 0, + "resolveThreshold": 0, + "triggers": []map[string]interface{}{ + { + "actions": []map[string]interface{}{ + { + "alertRuleTriggerId": "56789", + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #alert-rule-alerts", + "id": "12389", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + "sentryAppId": nil, + "targetIdentifier": "#alert-rule-alerts", + "targetType": "specific", + "type": "slack", + }, + }, + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0, + }, + }, + "projects": []string{"pump-station"}, + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.05618Z", + }, r) + + w.WriteHeader(http.StatusAccepted) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"uuid": "fakeuuid"}`) + }) + + params := &MetricAlert{ + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + TimeWindow: Float64(10.0), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + Triggers: []*MetricAlertTrigger{ + { + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(55501.0), + ResolveThreshold: Float64(100.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(123), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, + }, + }, + }, + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + } + ctx := context.Background() + alertRule, _, err := client.MetricAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) + require.NoError(t, err) + + expected := &MetricAlert{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + EventTypes: []string{"transaction"}, + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + TimeWindow: Float64(10.0), + Triggers: []*MetricAlertTrigger{ + { + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(10000.0), + ResolveThreshold: Float64(0.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(111), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, + }, + }, + }, + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.05618Z")), + } + + require.Equal(t, expected, alertRule) +} + func TestMetricAlertService_Create(t *testing.T) { client, mux, _, teardown := setup() defer teardown() @@ -224,6 +415,7 @@ func TestMetricAlertService_Create(t *testing.T) { "name": "pump-station-alert", "environment": "production", "dataset": "transactions", + "eventTypes": ["transaction"], "query": "http.url:http://service/unreadmessages", "aggregate": "p50(transaction.duration)", "timeWindow": 10, @@ -289,7 +481,7 @@ func TestMetricAlertService_Create(t *testing.T) { AlertRuleTriggerID: String("56789"), Type: String("slack"), TargetType: String("specific"), - TargetIdentifier: String("#alert-rule-alerts"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, InputChannelID: String("C0XXXFKLXXX"), IntegrationID: Int(123), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), @@ -310,6 +502,7 @@ func TestMetricAlertService_Create(t *testing.T) { Name: String("pump-station-alert"), Environment: String("production"), DataSet: String("transactions"), + EventTypes: []string{"transaction"}, Query: String("http.url:http://service/unreadmessages"), Aggregate: String("p50(transaction.duration)"), ThresholdType: Int(0), @@ -331,7 +524,7 @@ func TestMetricAlertService_Create(t *testing.T) { AlertRuleTriggerID: String("56789"), Type: String("slack"), TargetType: String("specific"), - TargetIdentifier: String("#alert-rule-alerts"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, InputChannelID: String("C0XXXFKLXXX"), IntegrationID: Int(111), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), @@ -414,6 +607,7 @@ func TestMetricAlertService_Update(t *testing.T) { "name": "pump-station-alert", "environment": "production", "dataset": "transactions", + "eventTypes": ["transaction"], "query": "http.url:http://service/unreadmessages", "aggregate": "p50(transaction.duration)", "timeWindow": 10, @@ -462,6 +656,7 @@ func TestMetricAlertService_Update(t *testing.T) { Name: String("pump-station-alert"), Environment: String("production"), DataSet: String("transactions"), + EventTypes: []string{"transaction"}, Query: String("http.url:http://service/unreadmessages"), Aggregate: String("p50(transaction.duration)"), ThresholdType: Int(0), @@ -482,7 +677,7 @@ func TestMetricAlertService_Update(t *testing.T) { AlertRuleTriggerID: String("56789"), Type: String("slack"), TargetType: String("specific"), - TargetIdentifier: String("#alert-rule-alerts"), + TargetIdentifier: &Int64OrString{IsString: true, StringVal: "#alert-rule-alerts"}, InputChannelID: String("C0XXXFKLXXX"), IntegrationID: Int(111), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), diff --git a/sentry/notification_actions.go b/sentry/notification_actions.go new file mode 100644 index 0000000..c75ff38 --- /dev/null +++ b/sentry/notification_actions.go @@ -0,0 +1,88 @@ +package sentry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type NotificationActionsService service + +type CreateNotificationActionParams struct { + TriggerType *string `json:"triggerType"` + ServiceType *string `json:"serviceType"` + IntegrationId *json.Number `json:"integrationId,omitempty"` + TargetIdentifier interface{} `json:"targetIdentifier,omitempty"` + TargetDisplay *string `json:"targetDisplay,omitempty"` + TargetType *string `json:"targetType,omitempty"` + Projects []string `json:"projects"` +} + +type NotificationAction struct { + ID *json.Number `json:"id"` + TriggerType *string `json:"triggerType"` + ServiceType *string `json:"serviceType"` + IntegrationId *json.Number `json:"integrationId"` + TargetIdentifier interface{} `json:"targetIdentifier"` + TargetDisplay *string `json:"targetDisplay"` + TargetType *string `json:"targetType"` + Projects []json.Number `json:"projects"` +} + +func (s *NotificationActionsService) Get(ctx context.Context, organizationSlug string, actionId string) (*NotificationAction, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) + req, err := s.client.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + action := &NotificationAction{} + resp, err := s.client.Do(ctx, req, action) + if err != nil { + return nil, resp, err + } + return action, resp, nil +} + +func (s *NotificationActionsService) Create(ctx context.Context, organizationSlug string, params *CreateNotificationActionParams) (*NotificationAction, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/notifications/actions/", organizationSlug) + req, err := s.client.NewRequest(http.MethodPost, u, params) + if err != nil { + return nil, nil, err + } + + action := &NotificationAction{} + resp, err := s.client.Do(ctx, req, action) + if err != nil { + return nil, resp, err + } + return action, resp, nil +} + +type UpdateNotificationActionParams = CreateNotificationActionParams + +func (s *NotificationActionsService) Update(ctx context.Context, organizationSlug string, actionId string, params *UpdateNotificationActionParams) (*NotificationAction, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) + req, err := s.client.NewRequest(http.MethodPut, u, params) + if err != nil { + return nil, nil, err + } + + action := &NotificationAction{} + resp, err := s.client.Do(ctx, req, action) + if err != nil { + return nil, resp, err + } + return action, resp, nil +} + +func (s *NotificationActionsService) Delete(ctx context.Context, organizationSlug string, actionId string) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/notifications/actions/%v/", organizationSlug, actionId) + req, err := s.client.NewRequest(http.MethodDelete, u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/notification_actions_test.go b/sentry/notification_actions_test.go new file mode 100644 index 0000000..180aa2c --- /dev/null +++ b/sentry/notification_actions_test.go @@ -0,0 +1,113 @@ +package sentry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNotificationActionsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/action_id/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodGet, r) + fmt.Fprintf(w, `{ + "id": "836501735", + "organizationId": "62848264", + "serviceType": "sentry_notification", + "targetDisplay": "default", + "targetIdentifier": "default", + "targetType": "specific", + "triggerType": "spike-protection", + "projects": [ + 4505321021243392 + ] + }`) + }) + + ctx := context.Background() + action, _, err := client.NotificationActions.Get(ctx, "organization_slug", "action_id") + assert.NoError(t, err) + + expected := &NotificationAction{ + ID: JsonNumber(json.Number("836501735")), + TriggerType: String("spike-protection"), + ServiceType: String("sentry_notification"), + TargetIdentifier: "default", + TargetDisplay: String("default"), + TargetType: String("specific"), + Projects: []json.Number{"4505321021243392"}, + } + assert.Equal(t, expected, action) +} + +func TestNotificationActionsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodPost, r) + assertPostJSON(t, map[string]interface{}{ + "projects": []interface{}{"go"}, + "serviceType": "sentry_notification", + "targetDisplay": "default", + "targetIdentifier": "default", + "targetType": "specific", + "triggerType": "spike-protection", + }, r) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "id": "836501735", + "organizationId": "62848264", + "serviceType": "sentry_notification", + "targetDisplay": "default", + "targetIdentifier": "default", + "targetType": "specific", + "triggerType": "spike-protection", + "projects": [ + 4505321021243392 + ] + }`) + }) + + params := &CreateNotificationActionParams{ + TriggerType: String("spike-protection"), + ServiceType: String("sentry_notification"), + TargetIdentifier: String("default"), + TargetDisplay: String("default"), + TargetType: String("specific"), + Projects: []string{"go"}, + } + ctx := context.Background() + action, _, err := client.NotificationActions.Create(ctx, "organization_slug", params) + assert.NoError(t, err) + + expected := &NotificationAction{ + ID: JsonNumber(json.Number("836501735")), + TriggerType: String("spike-protection"), + ServiceType: String("sentry_notification"), + TargetIdentifier: "default", + TargetDisplay: String("default"), + TargetType: String("specific"), + Projects: []json.Number{"4505321021243392"}, + } + assert.Equal(t, expected, action) +} + +func TestNotificationActionsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/notifications/actions/action_id/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodDelete, r) + }) + + ctx := context.Background() + _, err := client.NotificationActions.Delete(ctx, "organization_slug", "action_id") + assert.NoError(t, err) +} diff --git a/sentry/organization_integrations.go b/sentry/organization_integrations.go index 2653889..687296e 100644 --- a/sentry/organization_integrations.go +++ b/sentry/organization_integrations.go @@ -16,29 +16,34 @@ type OrganizationIntegrationProvider struct { Features []string `json:"features"` } +// IntegrationConfigData for defining integration-specific configuration data. +type IntegrationConfigData map[string]interface{} + // OrganizationIntegration represents an integration added for the organization. // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L93 type OrganizationIntegration struct { // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L35 ID string `json:"id"` Name string `json:"name"` - Icon string `json:"icon"` + Icon *string `json:"icon"` DomainName string `json:"domainName"` - AccountType string `json:"accountType"` - Scopes []string `json:"scopes"` + AccountType *string `json:"accountType"` + Scopes []string `json:"scopes"` Status string `json:"status"` Provider OrganizationIntegrationProvider `json:"provider"` // https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/serializers/models/integration.py#L138 - ExternalId string `json:"externalId"` - OrganizationId int `json:"organizationId"` - OrganizationIntegrationStatus string `json:"organizationIntegrationStatus"` - GracePeriodEnd *time.Time `json:"gracePeriodEnd"` + ConfigData *IntegrationConfigData `json:"configData"` + ExternalId string `json:"externalId"` + OrganizationId int `json:"organizationId"` + OrganizationIntegrationStatus string `json:"organizationIntegrationStatus"` + GracePeriodEnd *time.Time `json:"gracePeriodEnd"` } // OrganizationIntegrationsService provides methods for accessing Sentry organization integrations API endpoints. -// Paths: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/urls.py#L1236-L1240 +// Paths: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/urls.py#L1236-L1245 // Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/index.py +// Endpoints: https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/details.py type OrganizationIntegrationsService service type ListOrganizationIntegrationsParams struct { @@ -66,3 +71,33 @@ func (s *OrganizationIntegrationsService) List(ctx context.Context, organization } return integrations, resp, nil } + +// Get organization integration details. +func (s *OrganizationIntegrationsService) Get(ctx context.Context, organizationSlug string, integrationID string) (*OrganizationIntegration, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/integrations/%v/", organizationSlug, integrationID) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + integration := new(OrganizationIntegration) + resp, err := s.client.Do(ctx, req, integration) + if err != nil { + return nil, resp, err + } + return integration, resp, nil +} + +type UpdateConfigOrganizationIntegrationsParams = IntegrationConfigData + +// UpdateConfig - update configData for organization integration. +// https://github.com/getsentry/sentry/blob/22.7.0/src/sentry/api/endpoints/integrations/organization_integrations/details.py#L94-L102 +func (s *OrganizationIntegrationsService) UpdateConfig(ctx context.Context, organizationSlug string, integrationID string, params *UpdateConfigOrganizationIntegrationsParams) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/integrations/%v/", organizationSlug, integrationID) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/organization_integrations_test.go b/sentry/organization_integrations_test.go index 028afcd..63ab695 100644 --- a/sentry/organization_integrations_test.go +++ b/sentry/organization_integrations_test.go @@ -2,6 +2,7 @@ package sentry import ( "context" + "encoding/json" "fmt" "net/http" "testing" @@ -62,9 +63,9 @@ func TestOrganizationIntegrationsService_List(t *testing.T) { { ID: "123456", Name: "octocat", - Icon: "https://avatars.githubusercontent.com/u/583231?v=4", + Icon: String("https://avatars.githubusercontent.com/u/583231?v=4"), DomainName: "github.com/octocat", - AccountType: "Organization", + AccountType: String("Organization"), Scopes: []string{"read", "write"}, Status: "active", Provider: OrganizationIntegrationProvider{ @@ -80,6 +81,7 @@ func TestOrganizationIntegrationsService_List(t *testing.T) { "stacktrace-link", }, }, + ConfigData: &IntegrationConfigData{}, ExternalId: "87654321", OrganizationId: 2, OrganizationIntegrationStatus: "active", @@ -88,3 +90,139 @@ func TestOrganizationIntegrationsService_List(t *testing.T) { } assert.Equal(t, expected, integrations) } + +func TestOrganizationIntegrationsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/integrations/456789/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "456789", + "name": "Interstellar PagerDuty", + "icon": null, + "domainName": "the-interstellar-jurisdiction", + "accountType": null, + "scopes": null, + "status": "active", + "provider": { + "key": "pagerduty", + "slug": "pagerduty", + "name": "PagerDuty", + "canAdd": true, + "canDisable": false, + "features": [ + "alert-rule", + "incident-management" + ], + "aspects": { + "alerts": [ + { + "type": "info", + "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." + } + ] + } + }, + "configOrganization": [ + { + "name": "service_table", + "type": "table", + "label": "PagerDuty services with the Sentry integration enabled", + "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.", + "addButtonText": "", + "columnLabels": { + "service": "Service", + "integration_key": "Integration Key" + }, + "columnKeys": [ + "service", + "integration_key" + ], + "confirmDeleteMessage": "Any alert rules associated with this service will stop working. The rules will still exist but will show a removed service." + } + ], + "configData": { + "service_table": [ + { + "service": "testing123", + "integration_key": "abc123xyz", + "id": 22222 + } + ] + }, + "externalId": "999999", + "organizationId": 2, + "organizationIntegrationStatus": "active", + "gracePeriodEnd": null + }`) + }) + + ctx := context.Background() + integration, _, err := client.OrganizationIntegrations.Get(ctx, "the-interstellar-jurisdiction", "456789") + assert.NoError(t, err) + expected := OrganizationIntegration{ + ID: "456789", + Name: "Interstellar PagerDuty", + Icon: nil, + DomainName: "the-interstellar-jurisdiction", + AccountType: nil, + Scopes: nil, + Status: "active", + Provider: OrganizationIntegrationProvider{ + Key: "pagerduty", + Slug: "pagerduty", + Name: "PagerDuty", + CanAdd: true, + CanDisable: false, + Features: []string{ + "alert-rule", + "incident-management", + }, + }, + ConfigData: &IntegrationConfigData{ + "service_table": []interface{}{ + map[string]interface{}{ + "service": "testing123", + "integration_key": "abc123xyz", + "id": json.Number("22222"), + }, + }, + }, + ExternalId: "999999", + OrganizationId: 2, + OrganizationIntegrationStatus: "active", + GracePeriodEnd: nil, + } + assert.Equal(t, &expected, integration) +} + +func TestOrganizationIntegrationsService_UpdateConfig(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/integrations/456789/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.Header().Set("Content-Type", "application/json") + }) + + updateConfigOrganizationIntegrationsParams := UpdateConfigOrganizationIntegrationsParams{ + "service_table": []interface{}{ + map[string]interface{}{ + "service": "testing123", + "integration_key": "abc123xyz", + "id": json.Number("22222"), + }, + map[string]interface{}{ + "service": "testing456", + "integration_key": "efg456lmn", + "id": "", + }, + }, + } + ctx := context.Background() + resp, err := client.OrganizationIntegrations.UpdateConfig(ctx, "the-interstellar-jurisdiction", "456789", &updateConfigOrganizationIntegrationsParams) + assert.NoError(t, err) + assert.Equal(t, int64(0), resp.ContentLength) +} diff --git a/sentry/organization_members.go b/sentry/organization_members.go index e954788..92a3796 100644 --- a/sentry/organization_members.go +++ b/sentry/organization_members.go @@ -9,27 +9,31 @@ import ( // OrganizationMember represents a User's membership to the organization. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization_member/response.py#L57-L69 type OrganizationMember struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - User User `json:"user"` - Role string `json:"role"` - RoleName string `json:"roleName"` - Pending bool `json:"pending"` - Expired bool `json:"expired"` - Flags map[string]bool `json:"flags"` - DateCreated time.Time `json:"dateCreated"` - InviteStatus string `json:"inviteStatus"` - InviterName *string `json:"inviterName"` - Teams []string `json:"teams"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + User User `json:"user"` + OrgRole string `json:"orgRole"` + OrgRoleList []OrganizationRoleListItem `json:"orgRoleList"` + Pending bool `json:"pending"` + Expired bool `json:"expired"` + Flags map[string]bool `json:"flags"` + DateCreated time.Time `json:"dateCreated"` + InviteStatus string `json:"inviteStatus"` + InviterName *string `json:"inviterName"` + TeamRoleList []TeamRoleListItem `json:"teamRoleList"` + TeamRoles []TeamRole `json:"teamRoles"` + Teams []string `json:"teams"` } const ( - RoleMember string = "member" - RoleBilling string = "billing" - RoleAdmin string = "admin" - RoleOwner string = "owner" - RoleManager string = "manager" + OrganizationRoleBilling string = "billing" + OrganizationRoleMember string = "member" + OrganizationRoleManager string = "manager" + OrganizationRoleOwner string = "owner" + + TeamRoleContributor string = "contributor" + TeamRoleAdmin string = "admin" ) // OrganizationMembersService provides methods for accessing Sentry membership API endpoints. @@ -92,9 +96,14 @@ func (s *OrganizationMembersService) Create(ctx context.Context, organizationSlu return member, resp, nil } +type TeamRole struct { + TeamSlug string `json:"teamSlug"` + Role *string `json:"role"` +} + type UpdateOrganizationMemberParams struct { - Role string `json:"role"` - Teams []string `json:"teams,omitempty"` + OrganizationRole string `json:"role"` + TeamRoles []TeamRole `json:"teamRoles"` } func (s *OrganizationMembersService) Update(ctx context.Context, organizationSlug string, memberID string, params *UpdateOrganizationMemberParams) (*OrganizationMember, *Response, error) { diff --git a/sentry/organization_members_test.go b/sentry/organization_members_test.go index b4bc7fe..e8c90c2 100644 --- a/sentry/organization_members_test.go +++ b/sentry/organization_members_test.go @@ -112,10 +112,8 @@ func TestOrganizationMembersService_List(t *testing.T) { }, }, }, - Role: "owner", - RoleName: "Owner", - Pending: false, - Expired: false, + Pending: false, + Expired: false, Flags: map[string]bool{ "sso:invalid": false, "sso:linked": false, @@ -226,10 +224,8 @@ func TestOrganizationMembersService_Get(t *testing.T) { }, }, }, - Role: "owner", - RoleName: "Owner", - Pending: false, - Expired: false, + Pending: false, + Expired: false, Flags: map[string]bool{ "sso:invalid": false, "sso:linked": false, @@ -273,7 +269,7 @@ func TestOrganizationMembersService_Create(t *testing.T) { createOrganizationMemberParams := CreateOrganizationMemberParams{ Email: "test@example.com", - Role: RoleMember, + Role: OrganizationRoleMember, } ctx := context.Background() member, _, err := client.OrganizationMembers.Create(ctx, "the-interstellar-jurisdiction", &createOrganizationMemberParams) @@ -281,14 +277,12 @@ func TestOrganizationMembersService_Create(t *testing.T) { inviterName := "John Doe" expected := OrganizationMember{ - ID: "1", - Email: "test@example.com", - Name: "test@example.com", - User: User{}, - Role: "member", - RoleName: "Member", - Pending: true, - Expired: false, + ID: "1", + Email: "test@example.com", + Name: "test@example.com", + User: User{}, + Pending: true, + Expired: false, Flags: map[string]bool{ "sso:linked": false, "sso:invalid": false, @@ -311,52 +305,391 @@ func TestOrganizationMembersService_Update(t *testing.T) { assertMethod(t, "PUT", r) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{ - "id": "1", - "email": "test@example.com", - "name": "test@example.com", - "user": null, - "role": "manager", - "roleName": "Manager", - "pending": true, + "id": "57377908164", + "email": "sirpenguin@antarcticarocks.com", + "name": "Sir Penguin", + "user": { + "id": "280094367316", + "name": "Sir Penguin", + "username": "sirpenguin@antarcticarocks.com", + "email": "sirpenguin@antarcticarocks.com", + "avatarUrl": "https://secure.gravatar.com/avatar/16aeb26c5fdba335c7078e9e9ddb5149?s=32&d=mm", + "isActive": true, + "hasPasswordAuth": true, + "isManaged": false, + "dateJoined": "2021-07-06T21:13:58.375239Z", + "lastLogin": "2021-08-02T18:25:00.051182Z", + "has2fa": false, + "lastActive": "2021-08-02T21:32:18.836829Z", + "isSuperuser": false, + "isStaff": false, + "experiments": {}, + "emails": [ + { + "id": "2153450836", + "email": "sirpenguin@antarcticarocks.com", + "is_verified": true + } + ], + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + }, + "authenticators": [], + "canReset2fa": true + }, + "role": "member", + "orgRole": "member", + "roleName": "Member", + "pending": false, "expired": false, "flags": { + "idp:provisioned": false, + "idp:role-restricted": false, "sso:linked": false, "sso:invalid": false, - "member-limit:restricted": false + "member-limit:restricted": false, + "partnership:restricted": false }, - "teams": [], - "dateCreated": "2020-01-01T00:00:00.000000Z", + "dateCreated": "2021-07-06T21:13:01.120263Z", "inviteStatus": "approved", - "inviterName": "John Doe" + "inviterName": "maininviter@antarcticarocks.com", + "teams": [ + "cool-team", + "ancient-gabelers" + ], + "teamRoles": [ + { + "teamSlug": "ancient-gabelers", + "role": "admin" + }, + { + "teamSlug": "powerful-abolitionist", + "role": "contributor" + } + ], + "invite_link": null, + "isOnlyOwner": false, + "orgRoleList": [ + { + "id": "billing", + "name": "Billing", + "desc": "Can manage subscription and billing details.", + "scopes": [ + "org:billing" + ], + "allowed": true, + "isAllowed": true, + "isRetired": false, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "contributor" + }, + { + "id": "member", + "name": "Member", + "desc": "Members can view and act on events, as well as view most other data within the organization.", + "scopes": [ + "team:read", + "project:releases", + "org:read", + "event:read", + "alerts:write", + "member:read", + "alerts:read", + "event:admin", + "project:read", + "event:write" + ], + "allowed": true, + "isAllowed": true, + "isRetired": false, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "contributor" + }, + { + "id": "admin", + "name": "Admin", + "desc": "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.", + "scopes": [ + "team:admin", + "org:integrations", + "project:admin", + "team:read", + "project:releases", + "org:read", + "team:write", + "event:read", + "alerts:write", + "member:read", + "alerts:read", + "event:admin", + "project:read", + "event:write", + "project:write" + ], + "allowed": true, + "isAllowed": true, + "isRetired": true, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "admin" + }, + { + "id": "manager", + "name": "Manager", + "desc": "Gains admin access on all teams as well as the ability to add and remove members.", + "scopes": [ + "team:admin", + "org:integrations", + "project:releases", + "team:write", + "member:read", + "org:write", + "project:write", + "project:admin", + "team:read", + "org:read", + "event:read", + "member:write", + "alerts:write", + "alerts:read", + "event:admin", + "project:read", + "event:write", + "member:admin" + ], + "allowed": true, + "isAllowed": true, + "isRetired": false, + "is_global": true, + "isGlobal": true, + "minimumTeamRole": "admin" + }, + { + "id": "owner", + "name": "Owner", + "desc": "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.", + "scopes": [ + "team:admin", + "org:integrations", + "project:releases", + "org:admin", + "team:write", + "member:read", + "org:write", + "project:write", + "project:admin", + "team:read", + "org:read", + "event:read", + "member:write", + "alerts:write", + "org:billing", + "alerts:read", + "event:admin", + "project:read", + "event:write", + "member:admin" + ], + "allowed": true, + "isAllowed": true, + "isRetired": false, + "is_global": true, + "isGlobal": true, + "minimumTeamRole": "admin" + } + ], + "teamRoleList": [ + { + "id": "contributor", + "name": "Contributor", + "desc": "Contributors can view and act on events, as well as view most other data within the team's projects.", + "scopes": [ + "team:read", + "project:releases", + "org:read", + "event:read", + "member:read", + "alerts:read", + "project:read", + "event:write" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "isMinimumRoleFor": null + }, + { + "id": "admin", + "name": "Team Admin", + "desc": "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships. They cannot invite members to the organization.", + "scopes": [ + "team:admin", + "org:integrations", + "project:admin", + "team:read", + "project:releases", + "org:read", + "team:write", + "event:read", + "alerts:write", + "member:read", + "alerts:read", + "event:admin", + "project:read", + "event:write", + "project:write" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "isMinimumRoleFor": "admin" + } + ] }`) }) updateOrganizationMemberParams := UpdateOrganizationMemberParams{ - Role: RoleMember, + OrganizationRole: OrganizationRoleMember, } ctx := context.Background() member, _, err := client.OrganizationMembers.Update(ctx, "the-interstellar-jurisdiction", "1", &updateOrganizationMemberParams) assert.NoError(t, err) - inviterName := "John Doe" + inviterName := "maininviter@antarcticarocks.com" expected := OrganizationMember{ - ID: "1", - Email: "test@example.com", - Name: "test@example.com", - User: User{}, - Role: "manager", - RoleName: "Manager", - Pending: true, - Expired: false, + ID: "57377908164", + Email: "sirpenguin@antarcticarocks.com", + Name: "Sir Penguin", + User: User{ + ID: "280094367316", + Name: "Sir Penguin", + Username: "sirpenguin@antarcticarocks.com", + Email: "sirpenguin@antarcticarocks.com", + AvatarURL: "https://secure.gravatar.com/avatar/16aeb26c5fdba335c7078e9e9ddb5149?s=32&d=mm", + IsActive: true, + HasPasswordAuth: true, + IsManaged: false, + DateJoined: mustParseTime("2021-07-06T21:13:58.375239Z"), + LastLogin: mustParseTime("2021-08-02T18:25:00.051182Z"), + Has2FA: false, + LastActive: mustParseTime("2021-08-02T21:32:18.836829Z"), + IsSuperuser: false, + IsStaff: false, + Avatar: Avatar{ + Type: "letter_avatar", + UUID: nil, + }, + Emails: []UserEmail{ + { + ID: "2153450836", + Email: "sirpenguin@antarcticarocks.com", + IsVerified: true, + }, + }, + }, + OrgRole: OrganizationRoleMember, + OrgRoleList: []OrganizationRoleListItem{ + { + ID: "billing", + Name: "Billing", + Desc: "Can manage subscription and billing details.", + Scopes: []string{"org:billing"}, + IsAllowed: true, + IsRetired: false, + IsGlobal: false, + MinimumTeamRole: "contributor", + }, + { + ID: "member", + Name: "Member", + Desc: "Members can view and act on events, as well as view most other data within the organization.", + Scopes: []string{"team:read", "project:releases", "org:read", "event:read", "alerts:write", "member:read", "alerts:read", "event:admin", "project:read", "event:write"}, + IsAllowed: true, + IsRetired: false, + IsGlobal: false, + MinimumTeamRole: "contributor", + }, + { + ID: "admin", + Name: "Admin", + Desc: "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.", + Scopes: []string{"team:admin", "org:integrations", "project:admin", "team:read", "project:releases", "org:read", "team:write", "event:read", "alerts:write", "member:read", "alerts:read", "event:admin", "project:read", "event:write", "project:write"}, + IsAllowed: true, + IsRetired: true, + IsGlobal: false, + MinimumTeamRole: "admin", + }, + { + ID: "manager", + Name: "Manager", + Desc: "Gains admin access on all teams as well as the ability to add and remove members.", + Scopes: []string{"team:admin", "org:integrations", "project:releases", "team:write", "member:read", "org:write", "project:write", "project:admin", "team:read", "org:read", "event:read", "member:write", "alerts:write", "alerts:read", "event:admin", "project:read", "event:write", "member:admin"}, + IsAllowed: true, + IsRetired: false, + IsGlobal: true, + MinimumTeamRole: "admin", + }, + { + ID: "owner", + Name: "Owner", + Desc: "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.", + Scopes: []string{"team:admin", "org:integrations", "project:releases", "org:admin", "team:write", "member:read", "org:write", "project:write", "project:admin", "team:read", "org:read", "event:read", "member:write", "alerts:write", "org:billing", "alerts:read", "event:admin", "project:read", "event:write", "member:admin"}, + IsAllowed: true, + IsRetired: false, + IsGlobal: true, + MinimumTeamRole: "admin", + }, + }, + Pending: false, + Expired: false, Flags: map[string]bool{ + "idp:provisioned": false, + "idp:role-restricted": false, "sso:linked": false, "sso:invalid": false, "member-limit:restricted": false, + "partnership:restricted": false, }, - Teams: []string{}, - DateCreated: mustParseTime("2020-01-01T00:00:00.000000Z"), + DateCreated: mustParseTime("2021-07-06T21:13:01.120263Z"), InviteStatus: "approved", InviterName: &inviterName, + TeamRoleList: []TeamRoleListItem{ + { + ID: "contributor", + Name: "Contributor", + Desc: "Contributors can view and act on events, as well as view most other data within the team's projects.", + Scopes: []string{"team:read", "project:releases", "org:read", "event:read", "member:read", "alerts:read", "project:read", "event:write"}, + IsAllowed: false, + IsRetired: false, + IsMinimumRoleFor: nil, + }, + { + ID: "admin", + Name: "Team Admin", + Desc: "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships. They cannot invite members to the organization.", + Scopes: []string{"team:admin", "org:integrations", "project:admin", "team:read", "project:releases", "org:read", "team:write", "event:read", "alerts:write", "member:read", "alerts:read", "event:admin", "project:read", "event:write", "project:write"}, + IsAllowed: false, + IsRetired: false, + IsMinimumRoleFor: String("admin"), + }, + }, + TeamRoles: []TeamRole{ + { + TeamSlug: "ancient-gabelers", + Role: String(TeamRoleAdmin), + }, + { + TeamSlug: "powerful-abolitionist", + Role: String(TeamRoleContributor), + }, + }, + Teams: []string{ + "cool-team", + "ancient-gabelers", + }, } assert.Equal(t, &expected, member) diff --git a/sentry/organizations.go b/sentry/organizations.go index 52b00b3..f30cede 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -47,6 +47,8 @@ type Organization struct { IsDefault *bool `json:"isDefault,omitempty"` DefaultRole *string `json:"defaultRole,omitempty"` AvailableRoles []OrganizationAvailableRole `json:"availableRoles,omitempty"` + OrgRoleList []OrganizationRoleListItem `json:"orgRoleList,omitempty"` + TeamRoleList []TeamRoleListItem `json:"teamRoleList,omitempty"` OpenMembership *bool `json:"openMembership,omitempty"` AllowSharedIssues *bool `json:"allowSharedIssues,omitempty"` EnhancedPrivacy *bool `json:"enhancedPrivacy,omitempty"` diff --git a/sentry/organizations_test.go b/sentry/organizations_test.go index e05926a..b3943ad 100644 --- a/sentry/organizations_test.go +++ b/sentry/organizations_test.go @@ -149,6 +149,184 @@ func TestOrganizationsService_Get(t *testing.T) { "name": "Owner" } ], + "orgRoleList": [ + { + "id": "billing", + "name": "Billing", + "desc": "Can manage subscription and billing details.", + "scopes": [ + "org:billing" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "contributor" + }, + { + "id": "member", + "name": "Member", + "desc": "Members can view and act on events, as well as view most other data within the organization.", + "scopes": [ + "project:releases", + "alerts:read", + "event:write", + "member:read", + "team:read", + "alerts:write", + "event:admin", + "project:read", + "org:read", + "event:read" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "contributor" + }, + { + "id": "admin", + "name": "Admin", + "desc": "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.", + "scopes": [ + "org:integrations", + "project:admin", + "project:releases", + "alerts:read", + "team:write", + "event:write", + "team:read", + "member:read", + "alerts:write", + "event:admin", + "team:admin", + "project:read", + "org:read", + "event:read", + "project:write" + ], + "allowed": false, + "isAllowed": false, + "isRetired": true, + "is_global": false, + "isGlobal": false, + "minimumTeamRole": "admin" + }, + { + "id": "manager", + "name": "Manager", + "desc": "Gains admin access on all teams as well as the ability to add and remove members.", + "scopes": [ + "member:admin", + "alerts:read", + "member:read", + "team:admin", + "alerts:write", + "project:read", + "org:read", + "event:read", + "org:integrations", + "project:admin", + "org:write", + "member:write", + "team:write", + "event:write", + "team:read", + "event:admin", + "project:releases", + "project:write" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "is_global": true, + "isGlobal": true, + "minimumTeamRole": "admin" + }, + { + "id": "owner", + "name": "Owner", + "desc": "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.", + "scopes": [ + "member:admin", + "alerts:read", + "org:admin", + "team:admin", + "member:read", + "alerts:write", + "project:read", + "org:read", + "event:read", + "org:billing", + "org:integrations", + "project:admin", + "org:write", + "member:write", + "team:write", + "event:write", + "team:read", + "event:admin", + "project:releases", + "project:write" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "is_global": true, + "isGlobal": true, + "minimumTeamRole": "admin" + } + ], + "teamRoleList": [ + { + "id": "contributor", + "name": "Contributor", + "desc": "Contributors can view and act on events, as well as view most other data within the team's projects.", + "scopes": [ + "project:releases", + "event:write", + "org:read", + "alerts:read", + "event:read", + "team:read", + "project:read", + "member:read" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "isMinimumRoleFor": null + }, + { + "id": "admin", + "name": "Team Admin", + "desc": "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships. They cannot invite members to the organization.", + "scopes": [ + "project:releases", + "event:write", + "project:write", + "project:admin", + "team:write", + "org:read", + "org:integrations", + "project:read", + "alerts:read", + "event:read", + "team:read", + "event:admin", + "member:read", + "alerts:write", + "team:admin" + ], + "allowed": false, + "isAllowed": false, + "isRetired": false, + "isMinimumRoleFor": "admin" + } + ], "openMembership": true, "allowSharedIssues": true, "enhancedPrivacy": false, @@ -281,6 +459,78 @@ func TestOrganizationsService_Get(t *testing.T) { Name: String("Owner"), }, }, + OrgRoleList: []OrganizationRoleListItem{ + { + ID: "billing", + Name: "Billing", + Desc: "Can manage subscription and billing details.", + Scopes: []string{"org:billing"}, + IsAllowed: false, + IsRetired: false, + IsGlobal: false, + MinimumTeamRole: "contributor", + }, + { + ID: "member", + Name: "Member", + Desc: "Members can view and act on events, as well as view most other data within the organization.", + Scopes: []string{"project:releases", "alerts:read", "event:write", "member:read", "team:read", "alerts:write", "event:admin", "project:read", "org:read", "event:read"}, + IsAllowed: false, + IsRetired: false, + IsGlobal: false, + MinimumTeamRole: "contributor", + }, + { + ID: "admin", + Name: "Admin", + Desc: "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.", + Scopes: []string{"org:integrations", "project:admin", "project:releases", "alerts:read", "team:write", "event:write", "team:read", "member:read", "alerts:write", "event:admin", "team:admin", "project:read", "org:read", "event:read", "project:write"}, + IsAllowed: false, + IsRetired: true, + IsGlobal: false, + MinimumTeamRole: "admin", + }, + { + ID: "manager", + Name: "Manager", + Desc: "Gains admin access on all teams as well as the ability to add and remove members.", + Scopes: []string{"member:admin", "alerts:read", "member:read", "team:admin", "alerts:write", "project:read", "org:read", "event:read", "org:integrations", "project:admin", "org:write", "member:write", "team:write", "event:write", "team:read", "event:admin", "project:releases", "project:write"}, + IsAllowed: false, + IsRetired: false, + IsGlobal: true, + MinimumTeamRole: "admin", + }, + { + ID: "owner", + Name: "Owner", + Desc: "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.", + Scopes: []string{"member:admin", "alerts:read", "org:admin", "team:admin", "member:read", "alerts:write", "project:read", "org:read", "event:read", "org:billing", "org:integrations", "project:admin", "org:write", "member:write", "team:write", "event:write", "team:read", "event:admin", "project:releases", "project:write"}, + IsAllowed: false, + IsRetired: false, + IsGlobal: true, + MinimumTeamRole: "admin", + }, + }, + TeamRoleList: []TeamRoleListItem{ + { + ID: "contributor", + Name: "Contributor", + Desc: "Contributors can view and act on events, as well as view most other data within the team's projects.", + Scopes: []string{"project:releases", "event:write", "org:read", "alerts:read", "event:read", "team:read", "project:read", "member:read"}, + IsAllowed: false, + IsRetired: false, + IsMinimumRoleFor: nil, + }, + { + ID: "admin", + Name: "Team Admin", + Desc: "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships. They cannot invite members to the organization.", + Scopes: []string{"project:releases", "event:write", "project:write", "project:admin", "team:write", "org:read", "org:integrations", "project:read", "alerts:read", "event:read", "team:read", "event:admin", "member:read", "alerts:write", "team:admin"}, + IsAllowed: false, + IsRetired: false, + IsMinimumRoleFor: String("admin"), + }, + }, OpenMembership: Bool(true), AllowSharedIssues: Bool(true), EnhancedPrivacy: Bool(false), diff --git a/sentry/project_filter_test.go b/sentry/project_filter_test.go index 709f5b1..3cae9b1 100644 --- a/sentry/project_filter_test.go +++ b/sentry/project_filter_test.go @@ -22,7 +22,7 @@ func TestProjectFilterService_GetWithLegacyExtension(t *testing.T) { }) ctx := context.Background() - filterConfig, _, err := client.ProjectFilter.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") + filterConfig, _, err := client.ProjectFilters.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") assert.NoError(t, err) expected := FilterConfig{ @@ -43,7 +43,7 @@ func TestProjectFilterService_GetWithoutLegacyExtension(t *testing.T) { }) ctx := context.Background() - filterConfig, _, err := client.ProjectFilter.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") + filterConfig, _, err := client.ProjectFilters.GetFilterConfig(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") assert.NoError(t, err) expected := FilterConfig{ @@ -77,7 +77,7 @@ func TestBrowserExtensionFilter(t *testing.T) { }) ctx := context.Background() - _, err := client.ProjectFilter.UpdateBrowserExtensions(ctx, "test_org", "test_project", true) + _, err := client.ProjectFilters.UpdateBrowserExtensions(ctx, "test_org", "test_project", true) assert.NoError(t, err) } @@ -95,7 +95,7 @@ func TestLegacyBrowserFilter(t *testing.T) { ctx := context.Background() browsers := []string{"ie_pre_9", "ie10"} - _, err := client.ProjectFilter.UpdateLegacyBrowser(ctx, "test_org", "test_project", browsers) + _, err := client.ProjectFilters.UpdateLegacyBrowser(ctx, "test_org", "test_project", browsers) assert.NoError(t, err) } diff --git a/sentry/project_inbound_data_filters.go b/sentry/project_inbound_data_filters.go new file mode 100644 index 0000000..13f7182 --- /dev/null +++ b/sentry/project_inbound_data_filters.go @@ -0,0 +1,42 @@ +package sentry + +import ( + "context" + "net/http" +) + +type ProjectInboundDataFilter struct { + ID string `json:"id"` + Active BoolOrStringSlice `json:"active"` +} + +type ProjectInboundDataFiltersService service + +func (s *ProjectInboundDataFiltersService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectInboundDataFilter, *Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/filters/" + req, err := s.client.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + filters := []*ProjectInboundDataFilter{} + resp, err := s.client.Do(ctx, req, &filters) + if err != nil { + return nil, resp, err + } + return filters, resp, nil +} + +type UpdateProjectInboundDataFilterParams struct { + Active *bool `json:"active,omitempty"` + Subfilters []string `json:"subfilters,omitempty"` +} + +func (s *ProjectInboundDataFiltersService) Update(ctx context.Context, organizationSlug string, projectSlug string, filterID string, params *UpdateProjectInboundDataFilterParams) (*Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/filters/" + filterID + "/" + req, err := s.client.NewRequest(http.MethodPut, u, params) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/project_inbound_data_filters_test.go b/sentry/project_inbound_data_filters_test.go new file mode 100644 index 0000000..cce96d2 --- /dev/null +++ b/sentry/project_inbound_data_filters_test.go @@ -0,0 +1,110 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectInboundDataFiltersService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodGet, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[ + { + "id": "browser-extensions", + "active": false + }, + { + "id": "filtered-transaction", + "active": true + }, + { + "id": "legacy-browsers", + "active": [ + "ie_pre_9" + ] + }, + { + "id": "localhost", + "active": false + }, + { + "id": "web-crawlers", + "active": false + } + ]`) + }) + + ctx := context.Background() + filters, _, err := client.ProjectInboundDataFilters.List(ctx, "organization_slug", "project_slug") + assert.NoError(t, err) + + expected := []*ProjectInboundDataFilter{ + { + ID: "browser-extensions", + Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, + }, + { + ID: "filtered-transaction", + Active: BoolOrStringSlice{IsBool: true, BoolVal: true}, + }, + { + ID: "legacy-browsers", + Active: BoolOrStringSlice{IsStringSlice: true, StringSliceVal: []string{"ie_pre_9"}}, + }, + { + ID: "localhost", + Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, + }, + { + ID: "web-crawlers", + Active: BoolOrStringSlice{IsBool: true, BoolVal: false}, + }, + } + assert.Equal(t, expected, filters) +} + +func TestProjectInboundDataFiltersService_UpdateActive(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/filter_id/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodPut, r) + assertPostJSON(t, map[string]interface{}{ + "active": true, + }, r) + }) + + ctx := context.Background() + params := &UpdateProjectInboundDataFilterParams{ + Active: Bool(true), + } + _, err := client.ProjectInboundDataFilters.Update(ctx, "organization_slug", "project_slug", "filter_id", params) + assert.NoError(t, err) +} + +func TestProjectInboundDataFiltersService_UpdateSubfilters(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/filters/filter_id/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodPut, r) + assertPostJSON(t, map[string]interface{}{ + "subfilters": []interface{}{"ie_pre_9"}, + }, r) + }) + + ctx := context.Background() + params := &UpdateProjectInboundDataFilterParams{ + Subfilters: []string{"ie_pre_9"}, + } + _, err := client.ProjectInboundDataFilters.Update(ctx, "organization_slug", "project_slug", "filter_id", params) + assert.NoError(t, err) +} diff --git a/sentry/project_keys.go b/sentry/project_keys.go index a064d59..09839c0 100644 --- a/sentry/project_keys.go +++ b/sentry/project_keys.go @@ -2,6 +2,7 @@ package sentry import ( "context" + "encoding/json" "fmt" "time" ) @@ -30,7 +31,7 @@ type ProjectKey struct { Label string `json:"label"` Public string `json:"public"` Secret string `json:"secret"` - ProjectID int `json:"projectId"` + ProjectID json.Number `json:"projectId"` IsActive bool `json:"isActive"` RateLimit *ProjectKeyRateLimit `json:"rateLimit"` DSN ProjectKeyDSN `json:"dsn"` diff --git a/sentry/project_keys_test.go b/sentry/project_keys_test.go index 05dd642..e87a881 100644 --- a/sentry/project_keys_test.go +++ b/sentry/project_keys_test.go @@ -63,7 +63,7 @@ func TestProjectKeysService_List(t *testing.T) { Label: "Fabulous Key", Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, + ProjectID: json.Number("2"), IsActive: true, DSN: ProjectKeyDSN{ Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", @@ -135,7 +135,7 @@ func TestProjectKeysService_Create(t *testing.T) { Label: "Fabulous Key", Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, + ProjectID: json.Number("2"), IsActive: true, DSN: ProjectKeyDSN{ Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", @@ -206,7 +206,7 @@ func TestProjectKeysService_Update(t *testing.T) { Label: "Fabulous Key", Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, + ProjectID: json.Number("2"), IsActive: true, DSN: ProjectKeyDSN{ Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", @@ -289,7 +289,7 @@ func TestProjectKeysService_Update_RateLimit(t *testing.T) { Label: "Fabulous Key", Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, + ProjectID: json.Number("2"), IsActive: true, RateLimit: &rateLimit, DSN: ProjectKeyDSN{ diff --git a/sentry/project_ownerships.go b/sentry/project_ownerships.go index 5078705..b0c2397 100644 --- a/sentry/project_ownerships.go +++ b/sentry/project_ownerships.go @@ -13,7 +13,7 @@ type ProjectOwnership struct { DateCreated time.Time `json:"dateCreated"` LastUpdated time.Time `json:"lastUpdated"` IsActive bool `json:"isActive"` - AutoAssignment bool `json:"autoAssignment"` + AutoAssignment string `json:"autoAssignment"` CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` } @@ -39,10 +39,10 @@ func (s *ProjectOwnershipsService) Get(ctx context.Context, organizationSlug str // CreateProjectParams are the parameters for ProjectOwnershipService.Update. type UpdateProjectOwnershipParams struct { - Raw string `json:"raw,omitempty"` - FallThrough *bool `json:"fallthrough,omitempty"` - AutoAssignment *bool `json:"autoAssignment,omitempty"` - CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` + Raw string `json:"raw,omitempty"` + FallThrough *bool `json:"fallthrough,omitempty"` + AutoAssignment *string `json:"autoAssignment,omitempty"` + CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` } // Update a Project's Ownership configuration diff --git a/sentry/project_ownerships_test.go b/sentry/project_ownerships_test.go index f3583c0..979f775 100644 --- a/sentry/project_ownerships_test.go +++ b/sentry/project_ownerships_test.go @@ -22,7 +22,7 @@ func TestProjectOwnershipsService_Get(t *testing.T) { "dateCreated": "2021-11-18T13:09:16.819818Z", "lastUpdated": "2022-03-01T14:00:31.317734Z", "isActive": true, - "autoAssignment": true, + "autoAssignment": "Auto Assign to Issue Owner", "codeownersAutoSync": null }`) }) @@ -35,7 +35,7 @@ func TestProjectOwnershipsService_Get(t *testing.T) { Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", FallThrough: false, IsActive: true, - AutoAssignment: true, + AutoAssignment: "Auto Assign to Issue Owner", CodeownersAutoSync: nil, DateCreated: mustParseTime("2021-11-18T13:09:16.819818Z"), LastUpdated: mustParseTime("2022-03-01T14:00:31.317734Z"), @@ -60,7 +60,7 @@ func TestProjectOwnershipsService_Update(t *testing.T) { "dateCreated": "2021-11-18T13:09:16.819818Z", "lastUpdated": "2022-03-01T14:00:31.317734Z", "isActive": true, - "autoAssignment": true, + "autoAssignment": "Auto Assign to Issue Owner", "codeownersAutoSync": null }`) }) @@ -75,7 +75,7 @@ func TestProjectOwnershipsService_Update(t *testing.T) { Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", FallThrough: false, IsActive: true, - AutoAssignment: true, + AutoAssignment: "Auto Assign to Issue Owner", CodeownersAutoSync: nil, DateCreated: mustParseTime("2021-11-18T13:09:16.819818Z"), LastUpdated: mustParseTime("2022-03-01T14:00:31.317734Z"), diff --git a/sentry/project_symbol_sources.go b/sentry/project_symbol_sources.go new file mode 100644 index 0000000..8a4ce2a --- /dev/null +++ b/sentry/project_symbol_sources.go @@ -0,0 +1,157 @@ +package sentry + +import ( + "context" + "net/http" +) + +type ProjectSymbolSourceLayout struct { + Type *string `json:"type"` + Casing *string `json:"casing"` +} + +type ProjectSymbolSourceHiddenSecret struct { + HiddenSecret *bool `json:"hidden-secret"` +} + +type ProjectSymbolSource struct { + ID *string `json:"id"` + Type *string `json:"type"` + Name *string `json:"name"` + Layout *ProjectSymbolSourceLayout `json:"layout"` + + AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` + AppConnectPrivateKey *ProjectSymbolSourceHiddenSecret `json:"appconnectPrivateKey,omitempty"` + AppId *string `json:"appId,omitempty"` + Url *string `json:"url,omitempty"` + Username *string `json:"username,omitempty"` + Password *ProjectSymbolSourceHiddenSecret `json:"password,omitempty"` + Bucket *string `json:"bucket,omitempty"` + Region *string `json:"region,omitempty"` + AccessKey *string `json:"access_key,omitempty"` + SecretKey *ProjectSymbolSourceHiddenSecret `json:"secret_key,omitempty"` + Prefix *string `json:"prefix,omitempty"` + ClientEmail *string `json:"client_email,omitempty"` + PrivateKey *ProjectSymbolSourceHiddenSecret `json:"private_key,omitempty"` +} + +type ProjectSymbolSourcesService service + +type ProjectSymbolSourceQueryParams struct { + ID *string `url:"id,omitempty"` +} + +func (s *ProjectSymbolSourcesService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ProjectSymbolSourceQueryParams) ([]*ProjectSymbolSource, *Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + filters := []*ProjectSymbolSource{} + resp, err := s.client.Do(ctx, req, &filters) + if err != nil { + return nil, resp, err + } + return filters, resp, nil +} + +type CreateProjectSymbolSourceParams struct { + Type *string `json:"type"` + Name *string `json:"name"` + Layout *ProjectSymbolSourceLayout `json:"layout"` + + AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` + AppConnectPrivateKey *string `json:"appconnectPrivateKey,omitempty"` + AppId *string `json:"appId,omitempty"` + Url *string `json:"url,omitempty"` + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` + Bucket *string `json:"bucket,omitempty"` + Region *string `json:"region,omitempty"` + AccessKey *string `json:"access_key,omitempty"` + SecretKey *string `json:"secret_key,omitempty"` + Prefix *string `json:"prefix,omitempty"` + ClientEmail *string `json:"client_email,omitempty"` + PrivateKey *string `json:"private_key,omitempty"` +} + +func (s *ProjectSymbolSourcesService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateProjectSymbolSourceParams) (*ProjectSymbolSource, *Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" + req, err := s.client.NewRequest(http.MethodPost, u, params) + if err != nil { + return nil, nil, err + } + + filter := &ProjectSymbolSource{} + resp, err := s.client.Do(ctx, req, filter) + if err != nil { + return nil, resp, err + } + return filter, resp, nil +} + +type UpdateProjectSymbolSourceParams struct { + ID *string `json:"id"` + Type *string `json:"type"` + Name *string `json:"name"` + Layout *ProjectSymbolSourceLayout `json:"layout"` + + AppConnectIssuer *string `json:"appconnectIssuer,omitempty"` + AppConnectPrivateKey *string `json:"appconnectPrivateKey,omitempty"` + AppId *string `json:"appId,omitempty"` + Url *string `json:"url,omitempty"` + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` + Bucket *string `json:"bucket,omitempty"` + Region *string `json:"region,omitempty"` + AccessKey *string `json:"access_key,omitempty"` + SecretKey *string `json:"secret_key,omitempty"` + Prefix *string `json:"prefix,omitempty"` + ClientEmail *string `json:"client_email,omitempty"` + PrivateKey *string `json:"private_key,omitempty"` +} + +func (s *ProjectSymbolSourcesService) Update(ctx context.Context, organizationSlug string, projectSlug string, symbolSourceId string, params *UpdateProjectSymbolSourceParams) (*ProjectSymbolSource, *Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" + u, err := addQuery(u, &ProjectSymbolSourceQueryParams{ + ID: String(symbolSourceId), + }) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodPut, u, params) + if err != nil { + return nil, nil, err + } + + filter := &ProjectSymbolSource{} + resp, err := s.client.Do(ctx, req, filter) + if err != nil { + return nil, resp, err + } + return filter, resp, nil +} + +func (s *ProjectSymbolSourcesService) Delete(ctx context.Context, organizationSlug string, projectSlug string, symbolSourceId string) (*Response, error) { + u := "0/projects/" + organizationSlug + "/" + projectSlug + "/symbol-sources/" + u, err := addQuery(u, &ProjectSymbolSourceQueryParams{ + ID: String(symbolSourceId), + }) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequest(http.MethodDelete, u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/project_symbol_sources_test.go b/sentry/project_symbol_sources_test.go new file mode 100644 index 0000000..3ca3d64 --- /dev/null +++ b/sentry/project_symbol_sources_test.go @@ -0,0 +1,259 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectSymbolSourcesService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodGet, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[ + { + "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", + "name": "s3", + "bucket": "bucket", + "region": "us-east-2", + "access_key": "access_key", + "type": "s3", + "layout": { + "casing": "default", + "type": "native" + }, + "secret_key": { + "hidden-secret": true + } + }, + { + "private_key": { + "hidden-secret": true + }, + "id": "f9df862d-45f7-496c-bf9b-ecade4c9f136", + "layout": { + "type": "native", + "casing": "default" + }, + "name": "gcs", + "bucket": "gcs-bucket-name", + "client_email": "test@example.com", + "type": "gcs" + }, + { + "id": "1ccb6083-91ac-4394-a276-40fe0bb10ece", + "name": "http", + "url": "https://example.com", + "layout": { + "type": "native", + "casing": "default" + }, + "username": "admin", + "password": { + "hidden-secret": true + }, + "type": "http" + } + ]`) + }) + + ctx := context.Background() + sources, _, err := client.ProjectSymbolSources.List(ctx, "organization_slug", "project_slug", nil) + assert.NoError(t, err) + + expected := []*ProjectSymbolSource{ + { + ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), + Type: String("s3"), + Name: String("s3"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("bucket"), + Region: String("us-east-2"), + AccessKey: String("access_key"), + SecretKey: &ProjectSymbolSourceHiddenSecret{ + HiddenSecret: Bool(true), + }, + }, + { + ID: String("f9df862d-45f7-496c-bf9b-ecade4c9f136"), + Type: String("gcs"), + Name: String("gcs"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("gcs-bucket-name"), + ClientEmail: String("test@example.com"), + PrivateKey: &ProjectSymbolSourceHiddenSecret{ + HiddenSecret: Bool(true), + }, + }, + { + ID: String("1ccb6083-91ac-4394-a276-40fe0bb10ece"), + Type: String("http"), + Name: String("http"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Url: String("https://example.com"), + Username: String("admin"), + Password: &ProjectSymbolSourceHiddenSecret{ + HiddenSecret: Bool(true), + }, + }, + } + assert.Equal(t, expected, sources) +} + +func TestProjectSymbolSourcesService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodPost, r) + assertPostJSON(t, map[string]interface{}{ + "name": "s3", + "bucket": "bucket", + "region": "us-east-2", + "access_key": "access_key", + "type": "s3", + "layout": map[string]interface{}{ + "casing": "default", + "type": "native", + }, + "secret_key": "secret_key", + }, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", + "name": "s3", + "bucket": "bucket", + "region": "us-east-2", + "access_key": "access_key", + "type": "s3", + "layout": { + "casing": "default", + "type": "native" + }, + "secret_key": { + "hidden-secret": true + } + }`) + }) + + ctx := context.Background() + params := &CreateProjectSymbolSourceParams{ + Type: String("s3"), + Name: String("s3"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("bucket"), + Region: String("us-east-2"), + AccessKey: String("access_key"), + SecretKey: String("secret_key"), + } + source, _, err := client.ProjectSymbolSources.Create(ctx, "organization_slug", "project_slug", params) + assert.NoError(t, err) + + expected := &ProjectSymbolSource{ + ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), + Type: String("s3"), + Name: String("s3"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("bucket"), + Region: String("us-east-2"), + AccessKey: String("access_key"), + SecretKey: &ProjectSymbolSourceHiddenSecret{ + HiddenSecret: Bool(true), + }, + } + assert.Equal(t, expected, source) +} + +func TestProjectSymbolSourcesService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/organization_slug/project_slug/symbol-sources/", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "27c5692e-de41-4087-bc14-74ed0fa421ba", r.URL.Query().Get("id")) + assertMethod(t, http.MethodPut, r) + assertPostJSON(t, map[string]interface{}{ + "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", + "name": "s3", + "bucket": "bucket", + "region": "us-east-2", + "access_key": "access_key", + "type": "s3", + "layout": map[string]interface{}{ + "casing": "default", + "type": "native", + }, + "secret_key": "secret_key", + }, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "27c5692e-de41-4087-bc14-74ed0fa421ba", + "name": "s3", + "bucket": "bucket", + "region": "us-east-2", + "access_key": "access_key", + "type": "s3", + "layout": { + "casing": "default", + "type": "native" + }, + "secret_key": { + "hidden-secret": true + } + }`) + }) + + ctx := context.Background() + params := &UpdateProjectSymbolSourceParams{ + ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), + Type: String("s3"), + Name: String("s3"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("bucket"), + Region: String("us-east-2"), + AccessKey: String("access_key"), + SecretKey: String("secret_key"), + } + source, _, err := client.ProjectSymbolSources.Update(ctx, "organization_slug", "project_slug", "27c5692e-de41-4087-bc14-74ed0fa421ba", params) + assert.NoError(t, err) + + expected := &ProjectSymbolSource{ + ID: String("27c5692e-de41-4087-bc14-74ed0fa421ba"), + Type: String("s3"), + Name: String("s3"), + Layout: &ProjectSymbolSourceLayout{ + Type: String("native"), + Casing: String("default"), + }, + Bucket: String("bucket"), + Region: String("us-east-2"), + AccessKey: String("access_key"), + SecretKey: &ProjectSymbolSourceHiddenSecret{ + HiddenSecret: Bool(true), + }, + } + assert.Equal(t, expected, source) +} diff --git a/sentry/projects.go b/sentry/projects.go index e6af213..e2532aa 100644 --- a/sentry/projects.go +++ b/sentry/projects.go @@ -40,6 +40,7 @@ type Project struct { ResolveAge int `json:"resolveAge"` DataScrubber bool `json:"dataScrubber"` DataScrubberDefaults bool `json:"dataScrubberDefaults"` + FingerprintingRules string `json:"fingerprintingRules"` SafeFields []string `json:"safeFields"` SensitiveFields []string `json:"sensitiveFields"` SubjectTemplate string `json:"subjectTemplate"` @@ -94,8 +95,13 @@ type ProjectsService service // List projects available. // https://docs.sentry.io/api/projects/list-your-projects/ -func (s *ProjectsService) List(ctx context.Context) ([]*Project, *Response, error) { +func (s *ProjectsService) List(ctx context.Context, params *ListCursorParams) ([]*Project, *Response, error) { u := "0/projects/" + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err @@ -128,9 +134,10 @@ func (s *ProjectsService) Get(ctx context.Context, organizationSlug string, slug // CreateProjectParams are the parameters for ProjectService.Create. type CreateProjectParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` - Platform string `json:"platform,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Platform string `json:"platform,omitempty"` + DefaultRules *bool `json:"default_rules,omitempty"` } // Create a new project bound to a team. @@ -151,17 +158,17 @@ func (s *ProjectsService) Create(ctx context.Context, organizationSlug string, t // UpdateProjectParams are the parameters for ProjectService.Update. type UpdateProjectParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` - Platform string `json:"platform,omitempty"` - IsBookmarked *bool `json:"isBookmarked,omitempty"` - DigestsMinDelay *int `json:"digestsMinDelay,omitempty"` - DigestsMaxDelay *int `json:"digestsMaxDelay,omitempty"` - ResolveAge *int `json:"resolveAge,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` - AllowedDomains []string `json:"allowedDomains,omitempty"` - - GroupingEnhancements string `json:"groupingEnhancements,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Platform string `json:"platform,omitempty"` + IsBookmarked *bool `json:"isBookmarked,omitempty"` + DigestsMinDelay *int `json:"digestsMinDelay,omitempty"` + DigestsMaxDelay *int `json:"digestsMaxDelay,omitempty"` + ResolveAge *int `json:"resolveAge,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` + AllowedDomains []string `json:"allowedDomains,omitempty"` + FingerprintingRules string `json:"fingerprintingRules,omitempty"` + GroupingEnhancements string `json:"groupingEnhancements,omitempty"` } // Update various attributes and configurable settings for a given project. diff --git a/sentry/projects_test.go b/sentry/projects_test.go index 131a42b..18c9e4a 100644 --- a/sentry/projects_test.go +++ b/sentry/projects_test.go @@ -134,6 +134,7 @@ func TestProjectsService_List(t *testing.T) { }, "platform": null, "slug": "pump-station", + "fingerprintingRules": "fingerprinting rule", "groupingEnhancements": "pump station grouping enhancement rule", "status": "active" } @@ -141,7 +142,7 @@ func TestProjectsService_List(t *testing.T) { }) ctx := context.Background() - projects, _, err := client.Projects.List(ctx) + projects, _, err := client.Projects.List(ctx, nil) assert.NoError(t, err) expectedOrganization := Organization{ @@ -215,8 +216,8 @@ func TestProjectsService_List(t *testing.T) { Avatar: Avatar{ Type: "letter_avatar", }, - Organization: expectedOrganization, - + Organization: expectedOrganization, + FingerprintingRules: "fingerprinting rule", GroupingEnhancements: "pump station grouping enhancement rule", }, } @@ -362,6 +363,7 @@ func TestProjectsService_Get(t *testing.T) { "name": "Powerful Abolitionist", "slug": "powerful-abolitionist" }], + "fingerprintingRules": "fingerprinting rule", "groupingEnhancements": "pump-station grouping enhancement rule", "verifySSL": false }`) @@ -418,6 +420,7 @@ func TestProjectsService_Get(t *testing.T) { AllowedDomains: []string{"*"}, DataScrubber: true, DataScrubberDefaults: true, + FingerprintingRules: "fingerprinting rule", SafeFields: []string{}, SensitiveFields: []string{}, SubjectTemplate: "$shortID - $title", @@ -540,6 +543,7 @@ func TestProjectsService_Update(t *testing.T) { "callSignReviewed": false, "id": "5", "subjectTemplate": "[$project] ${tag:level}: $title", + "fingerprintingRules": "fingerprinting rule", "groupingEnhancements": "Plane Proxy grouping enhancement rule", "name": "Plane Proxy" }`) @@ -573,11 +577,11 @@ func TestProjectsService_Update(t *testing.T) { "sentry:origins": "http://example.com\nhttp://example.invalid", "sentry:resolve_age": json.Number("720"), }, - DigestsMinDelay: 300, - DigestsMaxDelay: 1800, - ResolveAge: 720, - SubjectTemplate: "[$project] ${tag:level}: $title", - + DigestsMinDelay: 300, + DigestsMaxDelay: 1800, + ResolveAge: 720, + SubjectTemplate: "[$project] ${tag:level}: $title", + FingerprintingRules: "fingerprinting rule", GroupingEnhancements: "Plane Proxy grouping enhancement rule", } assert.Equal(t, expected, project) diff --git a/sentry/release_deployment.go b/sentry/release_deployment.go new file mode 100644 index 0000000..104230f --- /dev/null +++ b/sentry/release_deployment.go @@ -0,0 +1,80 @@ +package sentry + +import ( + "context" + "fmt" + "time" +) + +type ReleaseDeploymentsService service + +type ReleaseDeployment struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Environment string `json:"environment,omitempty"` + URL *string `json:"url,omitempty"` + Projects []string `json:"projects,omitempty"` + DateStarted *time.Time `json:"dateStarted,omitempty"` + DateFinished *time.Time `json:"dateFinished,omitempty"` +} + +// Get a Release Deploy for a project. +func (s *ReleaseDeploymentsService) Get(ctx context.Context, organizationSlug string, version string, deployID string) (*ReleaseDeployment, *Response, error) { + + lastCursor := "" + + // Search for the deployment ID by using the list endpoint. When we have + // found the first match return immediately + for { + params := ListCursorParams{ + Cursor: lastCursor, + } + + u := fmt.Sprintf("0/organizations/%v/releases/%s/deploys/", organizationSlug, version) + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + deployments := new([]ReleaseDeployment) + resp, err := s.client.Do(ctx, req, deployments) + if err != nil { + return nil, resp, err + } + + for i := range *deployments { + d := (*deployments)[i] + if d.ID == deployID { + return &d, resp, nil + } + } + + // No matches in the current page and no further pages to check + if resp.Cursor == "" { + return nil, resp, nil + } + lastCursor = resp.Cursor + } +} + +// Create a new Release Deploy to a project. +func (s *ReleaseDeploymentsService) Create(ctx context.Context, organizationSlug string, version string, params *ReleaseDeployment) (*ReleaseDeployment, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/releases/%s/deploys/", organizationSlug, version) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + + deploy := new(ReleaseDeployment) + resp, err := s.client.Do(ctx, req, deploy) + if err != nil { + return nil, resp, err + } + + return deploy, resp, nil +} diff --git a/sentry/roles.go b/sentry/roles.go new file mode 100644 index 0000000..3d7989b --- /dev/null +++ b/sentry/roles.go @@ -0,0 +1,24 @@ +package sentry + +// https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/role.py#L62-L74 +type OrganizationRoleListItem struct { + ID string `json:"id"` + Name string `json:"name"` + Desc string `json:"desc"` + Scopes []string `json:"scopes"` + IsAllowed bool `json:"isAllowed"` + IsRetired bool `json:"isRetired"` + IsGlobal bool `json:"isGlobal"` + MinimumTeamRole string `json:"minimumTeamRole"` +} + +// https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/role.py#L77-L85 +type TeamRoleListItem struct { + ID string `json:"id"` + Name string `json:"name"` + Desc string `json:"desc"` + Scopes []string `json:"scopes"` + IsAllowed bool `json:"isAllowed"` + IsRetired bool `json:"isRetired"` + IsMinimumRoleFor *string `json:"isMinimumRoleFor"` +} diff --git a/sentry/sentry.go b/sentry/sentry.go index 1d63be1..6a8e96e 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -43,28 +43,31 @@ type Client struct { // User agent used when communicating with Sentry. UserAgent string - // Latest rate limit - rate Rate - // Common struct used by all services. common service // Services - DashboardWidgets *DashboardWidgetsService - Dashboards *DashboardsService - IssueAlerts *IssueAlertsService - MetricAlerts *MetricAlertsService - OrganizationCodeMappings *OrganizationCodeMappingsService - OrganizationIntegrations *OrganizationIntegrationsService - OrganizationMembers *OrganizationMembersService - OrganizationRepositories *OrganizationRepositoriesService - Organizations *OrganizationsService - ProjectKeys *ProjectKeysService - ProjectOwnerships *ProjectOwnershipsService - ProjectPlugins *ProjectPluginsService - Projects *ProjectsService - ProjectFilter *ProjectFilterService - Teams *TeamsService + Dashboards *DashboardsService + DashboardWidgets *DashboardWidgetsService + IssueAlerts *IssueAlertsService + MetricAlerts *MetricAlertsService + NotificationActions *NotificationActionsService + OrganizationCodeMappings *OrganizationCodeMappingsService + OrganizationIntegrations *OrganizationIntegrationsService + OrganizationMembers *OrganizationMembersService + OrganizationRepositories *OrganizationRepositoriesService + Organizations *OrganizationsService + ProjectFilters *ProjectFilterService + ProjectInboundDataFilters *ProjectInboundDataFiltersService + ProjectKeys *ProjectKeysService + ProjectOwnerships *ProjectOwnershipsService + ProjectPlugins *ProjectPluginsService + Projects *ProjectsService + ProjectSymbolSources *ProjectSymbolSourcesService + ReleaseDeployments *ReleaseDeploymentsService + SpikeProtections *SpikeProtectionsService + TeamMembers *TeamMembersService + Teams *TeamsService } type service struct { @@ -85,20 +88,26 @@ func NewClient(httpClient *http.Client) *Client { UserAgent: userAgent, } c.common.client = c - c.DashboardWidgets = (*DashboardWidgetsService)(&c.common) c.Dashboards = (*DashboardsService)(&c.common) + c.DashboardWidgets = (*DashboardWidgetsService)(&c.common) c.IssueAlerts = (*IssueAlertsService)(&c.common) c.MetricAlerts = (*MetricAlertsService)(&c.common) + c.NotificationActions = (*NotificationActionsService)(&c.common) c.OrganizationCodeMappings = (*OrganizationCodeMappingsService)(&c.common) c.OrganizationIntegrations = (*OrganizationIntegrationsService)(&c.common) c.OrganizationMembers = (*OrganizationMembersService)(&c.common) c.OrganizationRepositories = (*OrganizationRepositoriesService)(&c.common) c.Organizations = (*OrganizationsService)(&c.common) - c.ProjectFilter = (*ProjectFilterService)(&c.common) + c.ProjectFilters = (*ProjectFilterService)(&c.common) + c.ProjectInboundDataFilters = (*ProjectInboundDataFiltersService)(&c.common) c.ProjectKeys = (*ProjectKeysService)(&c.common) c.ProjectOwnerships = (*ProjectOwnershipsService)(&c.common) c.ProjectPlugins = (*ProjectPluginsService)(&c.common) c.Projects = (*ProjectsService)(&c.common) + c.ProjectSymbolSources = (*ProjectSymbolSourcesService)(&c.common) + c.ReleaseDeployments = (*ReleaseDeploymentsService)(&c.common) + c.SpikeProtections = (*SpikeProtectionsService)(&c.common) + c.TeamMembers = (*TeamMembersService)(&c.common) c.Teams = (*TeamsService)(&c.common) return c } @@ -202,7 +211,7 @@ type Response struct { func newResponse(r *http.Response) *Response { response := &Response{Response: r} - response.Rate = parseRate(r) + response.Rate = ParseRate(r) response.populatePaginationCursor() return response } @@ -214,8 +223,8 @@ func (r *Response) populatePaginationCursor() { } } -// parseRate parses the rate limit headers. -func parseRate(r *http.Response) Rate { +// ParseRate parses the rate limit headers. +func ParseRate(r *http.Response) Rate { var rate Rate if limit := r.Header.Get(headerRateLimit); limit != "" { rate.Limit, _ = strconv.Atoi(limit) @@ -234,7 +243,6 @@ func parseRate(r *http.Response) Rate { if concurrentRemaining := r.Header.Get(headerRateConcurrentRemaining); concurrentRemaining != "" { rate.ConcurrentRemaining, _ = strconv.Atoi(concurrentRemaining) } - return rate } @@ -243,14 +251,6 @@ func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, erro return nil, errNonNilContext } - // Check rate limit - if err := c.checkRateLimit(req); err != nil { - return &Response{ - Response: err.Response, - Rate: err.Rate, - }, err - } - resp, err := c.client.Do(req) if err != nil { // If we got an error, and the context has been canceled, @@ -264,33 +264,10 @@ func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, erro } response := newResponse(resp) - - c.rate = response.Rate - err = CheckResponse(resp) - return response, err } -func (c *Client) checkRateLimit(req *http.Request) *RateLimitError { - if !c.rate.Reset.IsZero() && c.rate.Remaining == 0 && time.Now().Before(c.rate.Reset) { - resp := &http.Response{ - Status: http.StatusText(http.StatusTooManyRequests), - StatusCode: http.StatusTooManyRequests, - Request: req, - Header: http.Header{}, - Body: ioutil.NopCloser(strings.NewReader("")), - } - return &RateLimitError{ - Rate: c.rate, - Response: resp, - Detail: fmt.Sprintf("API rate limit of %v and concurrent limit of %v still exceeded until %v, not making remote request.", - c.rate.Limit, c.rate.ConcurrentLimit, c.rate.Reset), - } - } - return nil -} - func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { resp, err := c.BareDo(ctx, req) if err != nil { @@ -397,7 +374,7 @@ func CheckResponse(r *http.Response) error { case r.StatusCode == http.StatusTooManyRequests && (r.Header.Get(headerRateRemaining) == "0" || r.Header.Get(headerRateConcurrentRemaining) == "0"): return &RateLimitError{ - Rate: parseRate(r), + Rate: ParseRate(r), Response: errorResponse.Response, Detail: errorResponse.Detail, } @@ -485,3 +462,15 @@ func TimeValue(v *time.Time) time.Time { } return time.Time{} } + +// JsonNumber returns a pointer to the json.Number value passed in. +func JsonNumber(v json.Number) *json.Number { return &v } + +// JsonNumberValue returns the value of the json.Number pointer passed in or +// json.Number("") if the pointer is nil. +func JsonNumberValue(v *json.Number) json.Number { + if v != nil { + return *v + } + return json.Number("") +} diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index bd0fd46..a0b0312 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -184,44 +184,6 @@ func TestDo_rateLimit(t *testing.T) { assert.Equal(t, resp.Rate.ConcurrentRemaining, 24) } -func TestDo_rateLimit_noNetworkCall(t *testing.T) { - client, mux, _, teardown := setup() - defer teardown() - - reset := time.Now().UTC().Add(time.Minute).Round(time.Second) - - mux.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set(headerRateLimit, "40") - w.Header().Set(headerRateRemaining, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.WriteHeader(http.StatusTooManyRequests) - fmt.Fprint(w, `{"detail": "Rate limit exceeded"}`) - }) - - madeNetworkCall := false - mux.HandleFunc("/second", func(w http.ResponseWriter, r *http.Request) { - madeNetworkCall = true - }) - - // First request to determine the rate limit. - req, _ := client.NewRequest("GET", "/first", nil) - ctx := context.Background() - client.Do(ctx, req, nil) - - // Second request should not make a network call. - req, _ = client.NewRequest("GET", "/second", nil) - _, err := client.Do(ctx, req, nil) - - assert.False(t, madeNetworkCall) - assert.Error(t, err) - - if rateLimitErr, ok := err.(*RateLimitError); assert.True(t, ok) { - assert.Equal(t, 40, rateLimitErr.Rate.Limit) - assert.Equal(t, 0, rateLimitErr.Rate.Remaining) - assert.Equal(t, reset, rateLimitErr.Rate.Reset) - } -} - func TestDo_nilContext(t *testing.T) { client, _, _, teardown := setup() defer teardown() @@ -358,7 +320,7 @@ func TestCheckResponse_rateLimit(t *testing.T) { err := CheckResponse(res) expected := &RateLimitError{ - Rate: parseRate(res), + Rate: ParseRate(res), Response: res, Detail: "Rate limit exceeded", } diff --git a/sentry/spike_protections.go b/sentry/spike_protections.go new file mode 100644 index 0000000..20c0d6b --- /dev/null +++ b/sentry/spike_protections.go @@ -0,0 +1,33 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" +) + +type SpikeProtectionsService service + +type SpikeProtectionParams struct { + Projects []string `json:"projects"` +} + +func (s *SpikeProtectionsService) Enable(ctx context.Context, organizationSlug string, params *SpikeProtectionParams) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/spike-protections/", organizationSlug) + req, err := s.client.NewRequest(http.MethodPost, u, params) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +func (s *SpikeProtectionsService) Disable(ctx context.Context, organizationSlug string, params *SpikeProtectionParams) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/spike-protections/", organizationSlug) + req, err := s.client.NewRequest(http.MethodDelete, u, params) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/spike_protections_test.go b/sentry/spike_protections_test.go new file mode 100644 index 0000000..ba9b012 --- /dev/null +++ b/sentry/spike_protections_test.go @@ -0,0 +1,47 @@ +package sentry + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSpikeProtectionsService_Enable(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/spike-protections/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodPost, r) + assertPostJSON(t, map[string]interface{}{ + "projects": []interface{}{"$all"}, + }, r) + }) + + params := &SpikeProtectionParams{ + Projects: []string{"$all"}, + } + ctx := context.Background() + _, err := client.SpikeProtections.Enable(ctx, "organization_slug", params) + assert.NoError(t, err) +} + +func TestSpikeProtectionsService_Disable(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/spike-protections/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, http.MethodDelete, r) + assertPostJSON(t, map[string]interface{}{ + "projects": []interface{}{"$all"}, + }, r) + }) + + params := &SpikeProtectionParams{ + Projects: []string{"$all"}, + } + ctx := context.Background() + _, err := client.SpikeProtections.Disable(ctx, "organization_slug", params) + assert.NoError(t, err) +} diff --git a/sentry/team_members.go b/sentry/team_members.go new file mode 100644 index 0000000..c9dc1da --- /dev/null +++ b/sentry/team_members.go @@ -0,0 +1,80 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "time" +) + +type TeamMember struct { + ID *string `json:"id"` + Slug *string `json:"slug"` + Name *string `json:"name"` + DateCreated *time.Time `json:"dateCreated"` + IsMember *bool `json:"isMember"` + TeamRole *string `json:"teamRole"` + Flags map[string]bool `json:"flags"` + Access []string `json:"access"` + HasAccess *bool `json:"hasAccess"` + IsPending *bool `json:"isPending"` + MemberCount *int `json:"memberCount"` + Avatar *Avatar `json:"avatar"` +} + +// TeamMember provides methods for accessing Sentry team member API endpoints. +type TeamMembersService service + +func (s *TeamMembersService) Create(ctx context.Context, organizationSlug string, memberID string, teamSlug string) (*TeamMember, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) + req, err := s.client.NewRequest(http.MethodPost, u, nil) + if err != nil { + return nil, nil, err + } + + member := new(TeamMember) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil +} + +type UpdateTeamMemberParams struct { + TeamRole *string `json:"teamRole,omitempty"` +} + +type UpdateTeamMemberResponse struct { + IsActive *bool `json:"isActive,omitempty"` + TeamRole *string `json:"teamRole,omitempty"` +} + +func (s *TeamMembersService) Update(ctx context.Context, organizationSlug string, memberID string, teamSlug string, params *UpdateTeamMemberParams) (*UpdateTeamMemberResponse, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) + req, err := s.client.NewRequest(http.MethodPut, u, params) + if err != nil { + return nil, nil, err + } + + member := new(UpdateTeamMemberResponse) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil +} + +func (s *TeamMembersService) Delete(ctx context.Context, organizationSlug string, memberID string, teamSlug string) (*TeamMember, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/teams/%v/", organizationSlug, memberID, teamSlug) + req, err := s.client.NewRequest(http.MethodDelete, u, nil) + if err != nil { + return nil, nil, err + } + + member := new(TeamMember) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil +} diff --git a/sentry/team_members_test.go b/sentry/team_members_test.go new file mode 100644 index 0000000..08186c7 --- /dev/null +++ b/sentry/team_members_test.go @@ -0,0 +1,154 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTeamMembersService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/members/member_id/teams/team_slug/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "4502349234123", + "slug": "ancient-gabelers", + "name": "Ancient Gabelers", + "dateCreated": "2023-05-31T19:47:53.621181Z", + "isMember": true, + "teamRole": "contributor", + "flags": { + "idp:provisioned": false + }, + "access": [ + "alerts:read", + "event:write", + "project:read", + "team:read", + "member:read", + "project:releases", + "event:read", + "org:read" + ], + "hasAccess": true, + "isPending": false, + "memberCount": 3, + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + } + }`) + }) + + ctx := context.Background() + team, _, err := client.TeamMembers.Create(ctx, "organization_slug", "member_id", "team_slug") + assert.NoError(t, err) + + expected := &TeamMember{ + ID: String("4502349234123"), + Slug: String("ancient-gabelers"), + Name: String("Ancient Gabelers"), + DateCreated: Time(mustParseTime("2023-05-31T19:47:53.621181Z")), + IsMember: Bool(true), + TeamRole: String("contributor"), + Flags: map[string]bool{ + "idp:provisioned": false, + }, + Access: []string{ + "alerts:read", + "event:write", + "project:read", + "team:read", + "member:read", + "project:releases", + "event:read", + "org:read", + }, + HasAccess: Bool(true), + IsPending: Bool(false), + MemberCount: Int(3), + Avatar: &Avatar{ + UUID: nil, + Type: "letter_avatar", + }, + } + assert.Equal(t, expected, team) +} + +func TestTeamMembersService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/organization_slug/members/member_id/teams/team_slug/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "4502349234123", + "slug": "ancient-gabelers", + "name": "Ancient Gabelers", + "dateCreated": "2023-05-31T19:47:53.621181Z", + "isMember": false, + "teamRole": null, + "flags": { + "idp:provisioned": false + }, + "access": [ + "alerts:read", + "event:write", + "project:read", + "team:read", + "member:read", + "project:releases", + "event:read", + "org:read" + ], + "hasAccess": true, + "isPending": false, + "memberCount": 3, + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + } + }`) + }) + + ctx := context.Background() + team, _, err := client.TeamMembers.Delete(ctx, "organization_slug", "member_id", "team_slug") + assert.NoError(t, err) + + expected := &TeamMember{ + ID: String("4502349234123"), + Slug: String("ancient-gabelers"), + Name: String("Ancient Gabelers"), + DateCreated: Time(mustParseTime("2023-05-31T19:47:53.621181Z")), + IsMember: Bool(false), + TeamRole: nil, + Flags: map[string]bool{ + "idp:provisioned": false, + }, + Access: []string{ + "alerts:read", + "event:write", + "project:read", + "team:read", + "member:read", + "project:releases", + "event:read", + "org:read", + }, + HasAccess: Bool(true), + IsPending: Bool(false), + MemberCount: Int(3), + Avatar: &Avatar{ + UUID: nil, + Type: "letter_avatar", + }, + } + assert.Equal(t, expected, team) +} diff --git a/sentry/teams.go b/sentry/teams.go index 5576bae..556535d 100644 --- a/sentry/teams.go +++ b/sentry/teams.go @@ -7,7 +7,7 @@ import ( ) // Team represents a Sentry team that is bound to an organization. -// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/team.py#L109-L119 +// https://github.com/getsentry/sentry/blob/23.12.1/src/sentry/api/serializers/models/team.py#L155C7-L190 type Team struct { ID *string `json:"id,omitempty"` Slug *string `json:"slug,omitempty"` @@ -19,6 +19,7 @@ type Team struct { IsPending *bool `json:"isPending,omitempty"` MemberCount *int `json:"memberCount,omitempty"` Avatar *Avatar `json:"avatar,omitempty"` + OrgRole *string `json:"orgRole,omitempty"` // TODO: externalTeams // TODO: projects } @@ -29,8 +30,13 @@ type TeamsService service // List returns a list of teams bound to an organization. // https://docs.sentry.io/api/teams/list-an-organizations-teams/ -func (s *TeamsService) List(ctx context.Context, organizationSlug string) ([]*Team, *Response, error) { +func (s *TeamsService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*Team, *Response, error) { u := fmt.Sprintf("0/organizations/%v/teams/", organizationSlug) + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err diff --git a/sentry/teams_test.go b/sentry/teams_test.go index e26639a..89fad9e 100644 --- a/sentry/teams_test.go +++ b/sentry/teams_test.go @@ -118,7 +118,7 @@ func TestTeamsService_List(t *testing.T) { }) ctx := context.Background() - teams, _, err := client.Teams.List(ctx, "the-interstellar-jurisdiction") + teams, _, err := client.Teams.List(ctx, "the-interstellar-jurisdiction", nil) assert.NoError(t, err) expected := []*Team{ diff --git a/sentry/types.go b/sentry/types.go new file mode 100644 index 0000000..937caab --- /dev/null +++ b/sentry/types.go @@ -0,0 +1,91 @@ +package sentry + +import ( + "encoding/json" + "fmt" +) + +// BoolOrStringSlice is a type that can be unmarshaled from either a bool or a +// string slice. +type BoolOrStringSlice struct { + IsBool bool + IsStringSlice bool + BoolVal bool + StringSliceVal []string +} + +var _ json.Unmarshaler = (*BoolOrStringSlice)(nil) +var _ json.Marshaler = (*BoolOrStringSlice)(nil) + +// UnmarshalJSON implements json.Unmarshaler. +func (bos *BoolOrStringSlice) UnmarshalJSON(data []byte) error { + // Try to unmarshal as a bool + var boolVal bool + if err := json.Unmarshal(data, &boolVal); err == nil { + bos.IsBool = true + bos.IsStringSlice = false + bos.BoolVal = boolVal + return nil + } + + // Try to unmarshal as a string slice + var sliceVal []string + if err := json.Unmarshal(data, &sliceVal); err == nil { + bos.IsBool = false + bos.IsStringSlice = true + bos.StringSliceVal = sliceVal + return nil + } + + // If neither worked, return an error + return fmt.Errorf("unable to unmarshal as bool or string slice: %s", string(data)) +} + +func (bos BoolOrStringSlice) MarshalJSON() ([]byte, error) { + if bos.IsBool { + return json.Marshal(bos.BoolVal) + } + return json.Marshal(bos.StringSliceVal) +} + +// Int64OrString is a type that can be unmarshaled from either an int64 or a +// string. +type Int64OrString struct { + IsInt64 bool + IsString bool + Int64Val int64 + StringVal string +} + +var _ json.Unmarshaler = (*Int64OrString)(nil) +var _ json.Marshaler = (*Int64OrString)(nil) + +func (ios *Int64OrString) UnmarshalJSON(data []byte) error { + // Try to unmarshal as an int64 + var int64Val int64 + if err := json.Unmarshal(data, &int64Val); err == nil { + ios.IsInt64 = true + ios.IsString = false + ios.Int64Val = int64Val + return nil + } + + // Try to unmarshal as a string + var stringVal string + if err := json.Unmarshal(data, &stringVal); err == nil { + ios.IsInt64 = false + ios.IsString = true + ios.StringVal = stringVal + return nil + } + + // If neither worked, return an error + return fmt.Errorf("unable to unmarshal as int64 or string: %s", string(data)) +} + +func (ios Int64OrString) MarshalJSON() ([]byte, error) { + if ios.IsInt64 { + return json.Marshal(ios.Int64Val) + } + return json.Marshal(ios.StringVal) +}