diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bd6deb4f..dfbfb035b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,5 +122,5 @@ jobs: SENTRY_TEST_PAGERDUTY_ORGANIZATION: ${{ secrets.SENTRY_TEST_PAGERDUTY_ORGANIZATION }} SENTRY_TEST_VSTS_INSTALLATION_ID: ${{ secrets.SENTRY_TEST_VSTS_INSTALLATION_ID }} SENTRY_TEST_VSTS_REPOSITORY_IDENTIFIER: ${{ secrets.SENTRY_TEST_VSTS_REPOSITORY_IDENTIFIER }} - run: go test -v -cover ./internal/provider/ - timeout-minutes: 10 + run: go test -v -cover -timeout 60m ./internal/provider/ + timeout-minutes: 60 diff --git a/docs/data-sources/issue_alert.md b/docs/data-sources/issue_alert.md index 50fdee6d0..9d216ef67 100644 --- a/docs/data-sources/issue_alert.md +++ b/docs/data-sources/issue_alert.md @@ -14,29 +14,10 @@ Sentry Issue Alert data source. See the [Sentry documentation](https://docs.sent ```terraform # Retrieve an Issue Alert -# URL format: https://sentry.io/organizations/[organization]/alerts/rules/[project]/[internal_id]/details/ data "sentry_issue_alert" "original" { organization = "my-organization" project = "my-project" - internal_id = "42" -} - -# Create a copy of an Issue Alert -resource "sentry_issue_alert" "copy" { - organization = data.sentry_issue_alert.original.organization - project = data.sentry_issue_alert.original.project - - # Copy and modify attributes as necessary. - - name = "${data.sentry_issue_alert.original.name}-copy" - - action_match = data.sentry_issue_alert.original.action_match - filter_match = data.sentry_issue_alert.original.filter_match - frequency = data.sentry_issue_alert.original.frequency - - conditions = data.sentry_issue_alert.original.conditions - filters = data.sentry_issue_alert.original.filters - actions = data.sentry_issue_alert.original.actions + id = "42" } ``` @@ -53,10 +34,380 @@ resource "sentry_issue_alert" "copy" { - `action_match` (String) Trigger actions when an event is captured by Sentry and `any` or `all` of the specified conditions happen. - `actions` (String) List of actions. In JSON string format. +- `actions_v2` (Attributes List) A list of actions that take place when all required conditions and filters for the rule are met. (see [below for nested schema](#nestedatt--actions_v2)) - `conditions` (String) List of conditions. In JSON string format. +- `conditions_v2` (Attributes List) A list of triggers that determine when the rule fires. (see [below for nested schema](#nestedatt--conditions_v2)) - `environment` (String) Perform issue alert in a specific environment. - `filter_match` (String) A string determining which filters need to be true before any actions take place. Required when a value is provided for `filters`. - `filters` (String) A list of filters that determine if a rule fires after the necessary conditions have been met. In JSON string format. +- `filters_v2` (Attributes List) A list of filters that determine if a rule fires after the necessary conditions have been met. (see [below for nested schema](#nestedatt--filters_v2)) - `frequency` (Number) Perform actions at most once every `X` minutes for this issue. - `name` (String) The issue alert name. - `owner` (String) The ID of the team or user that owns the rule. + + +### Nested Schema for `actions_v2` + +Read-Only: + +- `azure_devops_create_ticket` (Attributes) Create an Azure DevOps work item in `integration`. (see [below for nested schema](#nestedatt--actions_v2--azure_devops_create_ticket)) +- `discord_notify_service` (Attributes) Send a notification to the `server` Discord server in the channel with ID or URL: `channel_id` and show tags `tags` in the notification. (see [below for nested schema](#nestedatt--actions_v2--discord_notify_service)) +- `github_create_ticket` (Attributes) Create a GitHub issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--github_create_ticket)) +- `github_enterprise_create_ticket` (Attributes) Create a GitHub Enterprise issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--github_enterprise_create_ticket)) +- `jira_create_ticket` (Attributes) Create a Jira issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--jira_create_ticket)) +- `jira_server_create_ticket` (Attributes) Create a Jira Server issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--jira_server_create_ticket)) +- `msteams_notify_service` (Attributes) Send a notification to the `team` Team to `channel`. (see [below for nested schema](#nestedatt--actions_v2--msteams_notify_service)) +- `notify_email` (Attributes) Send a notification to `target_type` and if none can be found then send a notification to `fallthrough_type`. (see [below for nested schema](#nestedatt--actions_v2--notify_email)) +- `notify_event` (Attributes) Send a notification to all legacy integrations. (see [below for nested schema](#nestedatt--actions_v2--notify_event)) +- `notify_event_sentry_app` (Attributes) Send a notification to a Sentry app. (see [below for nested schema](#nestedatt--actions_v2--notify_event_sentry_app)) +- `notify_event_service` (Attributes) Send a notification via an integration. (see [below for nested schema](#nestedatt--actions_v2--notify_event_service)) +- `opsgenie_notify_team` (Attributes) Send a notification to Opsgenie account `account` and team `team` with `priority` priority. (see [below for nested schema](#nestedatt--actions_v2--opsgenie_notify_team)) +- `pagerduty_notify_service` (Attributes) Send a notification to PagerDuty account `account` and service `service` with `severity` severity. (see [below for nested schema](#nestedatt--actions_v2--pagerduty_notify_service)) +- `slack_notify_service` (Attributes) Send a notification to the `workspace` Slack workspace to `channel` (optionally, an ID: `channel_id`) and show tags `tags` and notes `notes` in notification. (see [below for nested schema](#nestedatt--actions_v2--slack_notify_service)) + + +### Nested Schema for `actions_v2.azure_devops_create_ticket` + +Read-Only: + +- `integration` (String) +- `name` (String) +- `work_item_type` (String) + + + +### Nested Schema for `actions_v2.discord_notify_service` + +Read-Only: + +- `channel_id` (String) +- `name` (String) +- `server` (String) +- `tags` (Set of String) + + + +### Nested Schema for `actions_v2.github_create_ticket` + +Read-Only: + +- `assignee` (String) +- `integration` (String) +- `labels` (Set of String) +- `name` (String) +- `repo` (String) + + + +### Nested Schema for `actions_v2.github_enterprise_create_ticket` + +Read-Only: + +- `assignee` (String) +- `integration` (String) +- `labels` (Set of String) +- `name` (String) +- `repo` (String) + + + +### Nested Schema for `actions_v2.jira_create_ticket` + +Read-Only: + +- `integration` (String) +- `issue_type` (String) +- `name` (String) +- `project` (String) + + + +### Nested Schema for `actions_v2.jira_server_create_ticket` + +Read-Only: + +- `integration` (String) +- `issue_type` (String) +- `name` (String) +- `project` (String) + + + +### Nested Schema for `actions_v2.msteams_notify_service` + +Read-Only: + +- `channel` (String) +- `channel_id` (String) +- `name` (String) +- `team` (String) + + + +### Nested Schema for `actions_v2.notify_email` + +Read-Only: + +- `fallthrough_type` (String) +- `name` (String) +- `target_identifier` (String) +- `target_type` (String) + + + +### Nested Schema for `actions_v2.notify_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.notify_event_sentry_app` + +Read-Only: + +- `name` (String) +- `sentry_app_installation_uuid` (String) +- `settings` (Map of String) + + + +### Nested Schema for `actions_v2.notify_event_service` + +Read-Only: + +- `name` (String) +- `service` (String) + + + +### Nested Schema for `actions_v2.opsgenie_notify_team` + +Read-Only: + +- `account` (String) +- `name` (String) +- `priority` (String) +- `team` (String) + + + +### Nested Schema for `actions_v2.pagerduty_notify_service` + +Read-Only: + +- `account` (String) +- `name` (String) +- `service` (String) +- `severity` (String) + + + +### Nested Schema for `actions_v2.slack_notify_service` + +Read-Only: + +- `channel` (String) +- `channel_id` (String) +- `name` (String) +- `notes` (String) +- `tags` (Set of String) +- `workspace` (String) + + + + +### Nested Schema for `conditions_v2` + +Read-Only: + +- `event_frequency` (Attributes) When the `comparison_type` is `count`, the number of events in an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of events in an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_frequency)) +- `event_frequency_percent` (Attributes) When the `comparison_type` is `count`, the percent of sessions affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the percent of sessions affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_frequency_percent)) +- `event_unique_user_frequency` (Attributes) When the `comparison_type` is `count`, the number of users affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of users affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_unique_user_frequency)) +- `existing_high_priority_issue` (Attributes) Sentry marks an existing issue as high priority. (see [below for nested schema](#nestedatt--conditions_v2--existing_high_priority_issue)) +- `first_seen_event` (Attributes) A new issue is created. (see [below for nested schema](#nestedatt--conditions_v2--first_seen_event)) +- `new_high_priority_issue` (Attributes) Sentry marks a new issue as high priority. (see [below for nested schema](#nestedatt--conditions_v2--new_high_priority_issue)) +- `reappeared_event` (Attributes) The issue changes state from ignored to unresolved. (see [below for nested schema](#nestedatt--conditions_v2--reappeared_event)) +- `regression_event` (Attributes) The issue changes state from resolved to unresolved. (see [below for nested schema](#nestedatt--conditions_v2--regression_event)) + + +### Nested Schema for `conditions_v2.event_frequency` + +Read-Only: + +- `comparison_interval` (String) +- `comparison_type` (String) +- `interval` (String) +- `name` (String) +- `value` (Number) + + + +### Nested Schema for `conditions_v2.event_frequency_percent` + +Read-Only: + +- `comparison_interval` (String) +- `comparison_type` (String) +- `interval` (String) +- `name` (String) +- `value` (Number) + + + +### Nested Schema for `conditions_v2.event_unique_user_frequency` + +Read-Only: + +- `comparison_interval` (String) +- `comparison_type` (String) +- `interval` (String) +- `name` (String) +- `value` (Number) + + + +### Nested Schema for `conditions_v2.existing_high_priority_issue` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.first_seen_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.new_high_priority_issue` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.reappeared_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.regression_event` + +Read-Only: + +- `name` (String) + + + + +### Nested Schema for `filters_v2` + +Read-Only: + +- `age_comparison` (Attributes) The issue is older or newer than `value` `time`. (see [below for nested schema](#nestedatt--filters_v2--age_comparison)) +- `assigned_to` (Attributes) The issue is assigned to no one, team, or member. (see [below for nested schema](#nestedatt--filters_v2--assigned_to)) +- `event_attribute` (Attributes) The event's `attribute` value `match` `value`. (see [below for nested schema](#nestedatt--filters_v2--event_attribute)) +- `issue_category` (Attributes) The issue's category is equal to `value`. (see [below for nested schema](#nestedatt--filters_v2--issue_category)) +- `issue_occurrences` (Attributes) The issue has happened at least `value` times (Note: this is approximate). (see [below for nested schema](#nestedatt--filters_v2--issue_occurrences)) +- `latest_adopted_release` (Attributes) The {oldest_or_newest} adopted release associated with the event's issue is {older_or_newer} than the latest adopted release in {environment}. (see [below for nested schema](#nestedatt--filters_v2--latest_adopted_release)) +- `latest_release` (Attributes) The event is from the latest release. (see [below for nested schema](#nestedatt--filters_v2--latest_release)) +- `level` (Attributes) The event's level is `match` `level`. (see [below for nested schema](#nestedatt--filters_v2--level)) +- `tagged_event` (Attributes) The event's tags match `key` `match` `value`. (see [below for nested schema](#nestedatt--filters_v2--tagged_event)) + + +### Nested Schema for `filters_v2.age_comparison` + +Read-Only: + +- `comparison_type` (String) +- `name` (String) +- `time` (String) +- `value` (Number) + + + +### Nested Schema for `filters_v2.assigned_to` + +Read-Only: + +- `name` (String) +- `target_identifier` (Number) +- `target_type` (Number) + + + +### Nested Schema for `filters_v2.event_attribute` + +Read-Only: + +- `attribute` (String) +- `match` (String) +- `name` (String) +- `value` (String) + + + +### Nested Schema for `filters_v2.issue_category` + +Read-Only: + +- `name` (String) +- `value` (String) + + + +### Nested Schema for `filters_v2.issue_occurrences` + +Read-Only: + +- `name` (String) +- `value` (Number) + + + +### Nested Schema for `filters_v2.latest_adopted_release` + +Read-Only: + +- `environment` (Number) +- `name` (String) +- `older_or_newer` (Number) +- `oldest_or_newest` (Number) + + + +### Nested Schema for `filters_v2.latest_release` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.level` + +Read-Only: + +- `level` (String) +- `match` (String) +- `name` (String) + + + +### Nested Schema for `filters_v2.tagged_event` + +Read-Only: + +- `key` (String) +- `match` (String) +- `name` (String) +- `value` (String) diff --git a/docs/resources/issue_alert.md b/docs/resources/issue_alert.md index 91b06bb24..b598207d0 100644 --- a/docs/resources/issue_alert.md +++ b/docs/resources/issue_alert.md @@ -4,18 +4,14 @@ page_title: "sentry_issue_alert Resource - terraform-provider-sentry" subcategory: "" description: |- Create an Issue Alert Rule for a Project. See the Sentry Documentation https://docs.sentry.io/api/alerts/create-an-issue-alert-rule-for-a-project/ for more information. - Please note the following changes since v0.12.0: - The attributes conditions, filters, and actions are in JSON string format. The types must match the Sentry API, otherwise Terraform will incorrectly detect a drift. Use parseint("string", 10) to convert a string to an integer. Avoid using jsonencode() as it is unable to distinguish between an integer and a float.The attribute internal_id has been removed. Use id instead.The attribute id is now the ID of the issue alert. Previously, it was a combination of the organization, project, and issue alert ID. + NOTE: Since v0.15.0, the conditions, filters, and actions attributes which are JSON strings have been deprecated in favor of conditions_v2, filters_v2, and actions_v2 which are lists of objects. --- # sentry_issue_alert (Resource) Create an Issue Alert Rule for a Project. See the [Sentry Documentation](https://docs.sentry.io/api/alerts/create-an-issue-alert-rule-for-a-project/) for more information. -Please note the following changes since v0.12.0: -- The attributes `conditions`, `filters`, and `actions` are in JSON string format. The types must match the Sentry API, otherwise Terraform will incorrectly detect a drift. Use `parseint("string", 10)` to convert a string to an integer. Avoid using `jsonencode()` as it is unable to distinguish between an integer and a float. -- The attribute `internal_id` has been removed. Use `id` instead. -- The attribute `id` is now the ID of the issue alert. Previously, it was a combination of the organization, project, and issue alert ID. +**NOTE:** Since v0.15.0, the `conditions`, `filters`, and `actions` attributes which are JSON strings have been deprecated in favor of `conditions_v2`, `filters_v2`, and `actions_v2` which are lists of objects. ## Example Usage @@ -29,81 +25,132 @@ resource "sentry_issue_alert" "main" { filter_match = "any" frequency = 30 - conditions = < for triage information" - } -] -EOT + actions_v2 = [ + { + slack_notify_service = { + workspace = data.sentry_organization_integration.slack.id + channel = "#warning" + tags = ["environment", "level"] + notes = "Please for triage information" + } + }, + ] // ... } @@ -200,6 +243,7 @@ EOT # Send a Microsoft Teams notification # +# Retrieve a MS Teams integration data "sentry_organization_integration" "msteams" { organization = sentry_project.test.organization @@ -207,16 +251,15 @@ data "sentry_organization_integration" "msteams" { name = "My Team" # Name of your Microsoft Teams team } -resource "sentry_issue_alert" "msteams_alert" { - actions = </ + service = "my-service" + } + }, + ] // ... } # -# Send a notification to a Sentry app with a custom webhook payload +# Send a notification to a Sentry app # -resource "sentry_issue_alert" "notification_alert" { - actions = < +### Nested Schema for `actions_v2` + +Optional: + +- `azure_devops_create_ticket` (Attributes) Create an Azure DevOps work item in `integration`. (see [below for nested schema](#nestedatt--actions_v2--azure_devops_create_ticket)) +- `discord_notify_service` (Attributes) Send a notification to the `server` Discord server in the channel with ID or URL: `channel_id` and show tags `tags` in the notification. (see [below for nested schema](#nestedatt--actions_v2--discord_notify_service)) +- `github_create_ticket` (Attributes) Create a GitHub issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--github_create_ticket)) +- `github_enterprise_create_ticket` (Attributes) Create a GitHub Enterprise issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--github_enterprise_create_ticket)) +- `jira_create_ticket` (Attributes) Create a Jira issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--jira_create_ticket)) +- `jira_server_create_ticket` (Attributes) Create a Jira Server issue in `integration`. (see [below for nested schema](#nestedatt--actions_v2--jira_server_create_ticket)) +- `msteams_notify_service` (Attributes) Send a notification to the `team` Team to `channel`. (see [below for nested schema](#nestedatt--actions_v2--msteams_notify_service)) +- `notify_email` (Attributes) Send a notification to `target_type` and if none can be found then send a notification to `fallthrough_type`. (see [below for nested schema](#nestedatt--actions_v2--notify_email)) +- `notify_event` (Attributes) Send a notification to all legacy integrations. (see [below for nested schema](#nestedatt--actions_v2--notify_event)) +- `notify_event_sentry_app` (Attributes) Send a notification to a Sentry app. (see [below for nested schema](#nestedatt--actions_v2--notify_event_sentry_app)) +- `notify_event_service` (Attributes) Send a notification via an integration. (see [below for nested schema](#nestedatt--actions_v2--notify_event_service)) +- `opsgenie_notify_team` (Attributes) Send a notification to Opsgenie account `account` and team `team` with `priority` priority. (see [below for nested schema](#nestedatt--actions_v2--opsgenie_notify_team)) +- `pagerduty_notify_service` (Attributes) Send a notification to PagerDuty account `account` and service `service` with `severity` severity. (see [below for nested schema](#nestedatt--actions_v2--pagerduty_notify_service)) +- `slack_notify_service` (Attributes) Send a notification to the `workspace` Slack workspace to `channel` (optionally, an ID: `channel_id`) and show tags `tags` and notes `notes` in notification. (see [below for nested schema](#nestedatt--actions_v2--slack_notify_service)) + + +### Nested Schema for `actions_v2.azure_devops_create_ticket` + +Required: + +- `integration` (String) The integration ID. +- `project` (String) The ID of the Azure DevOps project. +- `work_item_type` (String) The type of work item to create. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.discord_notify_service` + +Required: + +- `channel_id` (String) The ID of the channel to send the notification to. You must enter either a channel ID or a channel URL, not a channel name +- `server` (String) The integration ID associated with the Discord server. + +Optional: + +- `tags` (Set of String) A string of tags to show in the notification. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.github_create_ticket` + +Required: + +- `integration` (String) The integration ID associated with GitHub. +- `repo` (String) The name of the repository to create the issue in. + +Optional: + +- `assignee` (String) The GitHub user to assign the issue to. +- `labels` (Set of String) A list of labels to assign to the issue. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.github_enterprise_create_ticket` + +Required: + +- `integration` (String) The integration ID associated with GitHub Enterprise. +- `repo` (String) The name of the repository to create the issue in. + +Optional: + +- `assignee` (String) The GitHub user to assign the issue to. +- `labels` (Set of String) A list of labels to assign to the issue. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.jira_create_ticket` + +Required: + +- `integration` (String) The integration ID associated with Jira. +- `issue_type` (String) The ID of the type of issue that the ticket should be created as. +- `project` (String) The ID of the Jira project. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.jira_server_create_ticket` + +Required: + +- `integration` (String) The integration ID associated with Jira Server. +- `issue_type` (String) The ID of the type of issue that the ticket should be created as. +- `project` (String) The ID of the Jira Server project. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.msteams_notify_service` + +Required: + +- `channel` (String) The name of the channel to send the notification to. +- `team` (String) The integration ID associated with the Microsoft Teams team. + +Read-Only: + +- `channel_id` (String) +- `name` (String) + + + +### Nested Schema for `actions_v2.notify_email` + +Required: + +- `target_type` (String) Valid values are: `IssueOwners`, `Team`, and `Member`. + +Optional: + +- `fallthrough_type` (String) Who the notification should be sent to if there are no suggested assignees. Valid values are: `AllMembers`, `ActiveMembers`, and `NoOne`. +- `target_identifier` (String) The ID of the Member or Team the notification should be sent to. Only required when `target_type` is `Team` or `Member`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.notify_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.notify_event_sentry_app` + +Required: + +- `sentry_app_installation_uuid` (String) + +Optional: + +- `settings` (Map of String) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.notify_event_service` + +Required: + +- `service` (String) The slug of the integration service. Sourced from `https://terraform-provider-sentry.sentry.io/settings/developer-settings//`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.opsgenie_notify_team` + +Required: + +- `account` (String) +- `priority` (String) +- `team` (String) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.pagerduty_notify_service` + +Required: + +- `account` (String) +- `service` (String) +- `severity` (String) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `actions_v2.slack_notify_service` + +Required: + +- `channel` (String) The name of the channel to send the notification to (e.g., #critical, Jane Schmidt). +- `workspace` (String) The integration ID associated with the Slack workspace. + +Optional: + +- `notes` (String) Text to show alongside the notification. To @ a user, include their user id like `@`. To include a clickable link, format the link and title like ``. +- `tags` (Set of String) A string of tags to show in the notification. + +Read-Only: + +- `channel_id` (String) The ID of the channel to send the notification to. +- `name` (String) + + + + +### Nested Schema for `conditions_v2` + +Optional: + +- `event_frequency` (Attributes) When the `comparison_type` is `count`, the number of events in an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of events in an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_frequency)) +- `event_frequency_percent` (Attributes) When the `comparison_type` is `count`, the percent of sessions affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the percent of sessions affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_frequency_percent)) +- `event_unique_user_frequency` (Attributes) When the `comparison_type` is `count`, the number of users affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of users affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago. (see [below for nested schema](#nestedatt--conditions_v2--event_unique_user_frequency)) +- `existing_high_priority_issue` (Attributes) Sentry marks an existing issue as high priority. (see [below for nested schema](#nestedatt--conditions_v2--existing_high_priority_issue)) +- `first_seen_event` (Attributes) A new issue is created. (see [below for nested schema](#nestedatt--conditions_v2--first_seen_event)) +- `new_high_priority_issue` (Attributes) Sentry marks a new issue as high priority. (see [below for nested schema](#nestedatt--conditions_v2--new_high_priority_issue)) +- `reappeared_event` (Attributes) The issue changes state from ignored to unresolved. (see [below for nested schema](#nestedatt--conditions_v2--reappeared_event)) +- `regression_event` (Attributes) The issue changes state from resolved to unresolved. (see [below for nested schema](#nestedatt--conditions_v2--regression_event)) + + +### Nested Schema for `conditions_v2.event_frequency` + +Required: + +- `comparison_type` (String) Valid values are: `count`, and `percent`. +- `value` (Number) + +Optional: + +- `comparison_interval` (String) `m` for minutes, `h` for hours, `d` for days, and `w` for weeks. Valid values are: `5m`, `15m`, `1h`, `1d`, `1w`, and `30d`. +- `interval` (String) `m` for minutes, `h` for hours, `d` for days, and `w` for weeks. Valid values are: `1m`, `5m`, `15m`, `1h`, `1d`, `1w`, and `30d`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.event_frequency_percent` + +Required: + +- `comparison_type` (String) Valid values are: `count`, and `percent`. +- `interval` (String) `m` for minutes, `h` for hours. Valid values are: `5m`, `10m`, `30m`, and `1h`. +- `value` (Number) + +Optional: + +- `comparison_interval` (String) `m` for minutes, `h` for hours, `d` for days, and `w` for weeks. Valid values are: `5m`, `15m`, `1h`, `1d`, `1w`, and `30d`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.event_unique_user_frequency` + +Required: + +- `comparison_type` (String) Valid values are: `count`, and `percent`. +- `value` (Number) + +Optional: + +- `comparison_interval` (String) `m` for minutes, `h` for hours, `d` for days, and `w` for weeks. Valid values are: `5m`, `15m`, `1h`, `1d`, `1w`, and `30d`. +- `interval` (String) `m` for minutes, `h` for hours, `d` for days, and `w` for weeks. Valid values are: `1m`, `5m`, `15m`, `1h`, `1d`, `1w`, and `30d`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.existing_high_priority_issue` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.first_seen_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.new_high_priority_issue` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.reappeared_event` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `conditions_v2.regression_event` + +Read-Only: + +- `name` (String) + + + + +### Nested Schema for `filters_v2` + +Optional: + +- `age_comparison` (Attributes) The issue is older or newer than `value` `time`. (see [below for nested schema](#nestedatt--filters_v2--age_comparison)) +- `assigned_to` (Attributes) The issue is assigned to no one, team, or member. (see [below for nested schema](#nestedatt--filters_v2--assigned_to)) +- `event_attribute` (Attributes) The event's `attribute` value `match` `value`. (see [below for nested schema](#nestedatt--filters_v2--event_attribute)) +- `issue_category` (Attributes) The issue's category is equal to `value`. (see [below for nested schema](#nestedatt--filters_v2--issue_category)) +- `issue_occurrences` (Attributes) The issue has happened at least `value` times (Note: this is approximate). (see [below for nested schema](#nestedatt--filters_v2--issue_occurrences)) +- `latest_adopted_release` (Attributes) The {oldest_or_newest} adopted release associated with the event's issue is {older_or_newer} than the latest adopted release in {environment}. (see [below for nested schema](#nestedatt--filters_v2--latest_adopted_release)) +- `latest_release` (Attributes) The event is from the latest release. (see [below for nested schema](#nestedatt--filters_v2--latest_release)) +- `level` (Attributes) The event's level is `match` `level`. (see [below for nested schema](#nestedatt--filters_v2--level)) +- `tagged_event` (Attributes) The event's tags match `key` `match` `value`. (see [below for nested schema](#nestedatt--filters_v2--tagged_event)) + + +### Nested Schema for `filters_v2.age_comparison` + +Required: + +- `comparison_type` (String) Valid values are: `older`, and `newer`. +- `time` (String) Valid values are: `minute`, `hour`, `day`, and `week`. +- `value` (Number) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.assigned_to` + +Required: + +- `target_type` (String) Valid values are: `Unassigned`, `Team`, and `Member`. + +Optional: + +- `target_identifier` (String) The target's ID. Only required when `target_type` is `Team` or `Member`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.event_attribute` + +Required: + +- `attribute` (String) Valid values are: `message`, `platform`, `environment`, `type`, `error.handled`, `error.unhandled`, `error.main_thread`, `exception.type`, `exception.value`, `user.id`, `user.email`, `user.username`, `user.ip_address`, `http.method`, `http.url`, `http.status_code`, `sdk.name`, `stacktrace.code`, `stacktrace.module`, `stacktrace.filename`, `stacktrace.abs_path`, `stacktrace.package`, `unreal.crashtype`, `app.in_foreground`, `os.distribution_name`, and `os.distribution_version`. +- `match` (String) The comparison operator. Valid values are: `CONTAINS`, `ENDS_WITH`, `EQUAL`, `GREATER_OR_EQUAL`, `GREATER`, `IS_SET`, `IS_IN`, `LESS_OR_EQUAL`, `LESS`, `NOT_CONTAINS`, `NOT_ENDS_WITH`, `NOT_EQUAL`, `NOT_SET`, `NOT_STARTS_WITH`, `NOT_IN`, and `STARTS_WITH`. + +Optional: + +- `value` (String) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.issue_category` + +Required: + +- `value` (String) Valid values are: `Error`, `Performance`, `Profile`, `Cron`, `Replay`, `Feedback`, `Uptime`, and `Metric_Alert`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.issue_occurrences` + +Required: + +- `value` (Number) + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.latest_adopted_release` + +Required: + +- `environment` (String) +- `older_or_newer` (String) Valid values are: `older`, and `newer`. +- `oldest_or_newest` (String) Valid values are: `oldest`, and `newest`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.latest_release` + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.level` + +Required: + +- `level` (String) Valid values are: `sample`, `debug`, `info`, `warning`, `error`, and `fatal`. +- `match` (String) The comparison operator. Valid values are: `EQUAL`, `GREATER_OR_EQUAL`, and `LESS_OR_EQUAL`. + +Read-Only: + +- `name` (String) + + + +### Nested Schema for `filters_v2.tagged_event` + +Required: + +- `key` (String) The tag. +- `match` (String) The comparison operator. Valid values are: `CONTAINS`, `ENDS_WITH`, `EQUAL`, `GREATER_OR_EQUAL`, `GREATER`, `IS_SET`, `IS_IN`, `LESS_OR_EQUAL`, `LESS`, `NOT_CONTAINS`, `NOT_ENDS_WITH`, `NOT_EQUAL`, `NOT_SET`, `NOT_STARTS_WITH`, `NOT_IN`, and `STARTS_WITH`. + +Optional: + +- `value` (String) + +Read-Only: + +- `name` (String) + ## Import Import is supported using the following syntax: diff --git a/examples/data-sources/sentry_issue_alert/data-source.tf b/examples/data-sources/sentry_issue_alert/data-source.tf index e25553f13..e51578a73 100644 --- a/examples/data-sources/sentry_issue_alert/data-source.tf +++ b/examples/data-sources/sentry_issue_alert/data-source.tf @@ -1,25 +1,6 @@ # Retrieve an Issue Alert -# URL format: https://sentry.io/organizations/[organization]/alerts/rules/[project]/[internal_id]/details/ data "sentry_issue_alert" "original" { organization = "my-organization" project = "my-project" - internal_id = "42" -} - -# Create a copy of an Issue Alert -resource "sentry_issue_alert" "copy" { - organization = data.sentry_issue_alert.original.organization - project = data.sentry_issue_alert.original.project - - # Copy and modify attributes as necessary. - - name = "${data.sentry_issue_alert.original.name}-copy" - - action_match = data.sentry_issue_alert.original.action_match - filter_match = data.sentry_issue_alert.original.filter_match - frequency = data.sentry_issue_alert.original.frequency - - conditions = data.sentry_issue_alert.original.conditions - filters = data.sentry_issue_alert.original.filters - actions = data.sentry_issue_alert.original.actions + id = "42" } diff --git a/examples/resources/sentry_issue_alert/resource.tf b/examples/resources/sentry_issue_alert/resource.tf index badb34511..9f50f60e8 100644 --- a/examples/resources/sentry_issue_alert/resource.tf +++ b/examples/resources/sentry_issue_alert/resource.tf @@ -8,81 +8,132 @@ resource "sentry_issue_alert" "main" { filter_match = "any" frequency = 30 - conditions = < for triage information" - } -] -EOT + actions_v2 = [ + { + slack_notify_service = { + workspace = data.sentry_organization_integration.slack.id + channel = "#warning" + tags = ["environment", "level"] + notes = "Please for triage information" + } + }, + ] // ... } @@ -179,6 +226,7 @@ EOT # Send a Microsoft Teams notification # +# Retrieve a MS Teams integration data "sentry_organization_integration" "msteams" { organization = sentry_project.test.organization @@ -186,16 +234,15 @@ data "sentry_organization_integration" "msteams" { name = "My Team" # Name of your Microsoft Teams team } -resource "sentry_issue_alert" "msteams_alert" { - actions = </ + service = "my-service" + } + }, + ] // ... } # -# Send a notification to a Sentry app with a custom webhook payload +# Send a notification to a Sentry app # -resource "sentry_issue_alert" "notification_alert" { - actions = < 0 { - if conditions, err := json.Marshal(alert.Conditions); err == nil { - m.Conditions = sentrytypes.NewLossyJsonValue(string(conditions)) - } else { - return err - } - } - - m.Filters = sentrytypes.NewLossyJsonNull() - if len(alert.Filters) > 0 { - if filters, err := json.Marshal(alert.Filters); err == nil { - m.Filters = sentrytypes.NewLossyJsonValue(string(filters)) - } else { - return err - } - } - - m.Actions = sentrytypes.NewLossyJsonNull() - if len(alert.Actions) > 0 { - if actions, err := json.Marshal(alert.Actions); err == nil && len(actions) > 0 { - m.Actions = sentrytypes.NewLossyJsonValue(string(actions)) - } else { - return err - } - } - - frequency, err := alert.Frequency.Int64() - if err != nil { - return err - } - m.Frequency = types.Int64Value(frequency) - - m.Environment = types.StringPointerValue(alert.Environment) - m.Owner = types.StringPointerValue(alert.Owner) - - return nil -} - var _ datasource.DataSource = &IssueAlertDataSource{} var _ datasource.DataSourceWithConfigure = &IssueAlertDataSource{} @@ -92,6 +27,13 @@ func (d *IssueAlertDataSource) Metadata(ctx context.Context, req datasource.Meta } func (d *IssueAlertDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + stringAttribute := schema.StringAttribute{ + Computed: true, + } + int64Attribute := schema.Int64Attribute{ + Computed: true, + } + resp.Schema = schema.Schema{ MarkdownDescription: "Sentry Issue Alert data source. See the [Sentry documentation](https://docs.sentry.io/api/alerts/retrieve-an-issue-alert-rule-for-a-project/) for more information.", @@ -111,16 +53,351 @@ func (d *IssueAlertDataSource) Schema(ctx context.Context, req datasource.Schema Computed: true, CustomType: sentrytypes.LossyJsonType{}, }, + "conditions_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of triggers that determine when the rule fires.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "first_seen_event": schema.SingleNestedAttribute{ + MarkdownDescription: "A new issue is created.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "regression_event": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue changes state from resolved to unresolved.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "reappeared_event": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue changes state from ignored to unresolved.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "new_high_priority_issue": schema.SingleNestedAttribute{ + MarkdownDescription: "Sentry marks a new issue as high priority.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "existing_high_priority_issue": schema.SingleNestedAttribute{ + MarkdownDescription: "Sentry marks an existing issue as high priority.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "event_frequency": schema.SingleNestedAttribute{ + MarkdownDescription: "When the `comparison_type` is `count`, the number of events in an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of events in an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "comparison_type": stringAttribute, + "comparison_interval": stringAttribute, + "value": int64Attribute, + "interval": stringAttribute, + }, + }, + "event_unique_user_frequency": schema.SingleNestedAttribute{ + MarkdownDescription: "When the `comparison_type` is `count`, the number of users affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of users affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "comparison_type": stringAttribute, + "comparison_interval": stringAttribute, + "value": int64Attribute, + "interval": stringAttribute, + }, + }, + "event_frequency_percent": schema.SingleNestedAttribute{ + MarkdownDescription: "When the `comparison_type` is `count`, the percent of sessions affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the percent of sessions affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "comparison_type": stringAttribute, + "comparison_interval": stringAttribute, + "value": schema.Float64Attribute{ + Computed: true, + }, + "interval": stringAttribute, + }, + }, + }, + }, + }, "filters": schema.StringAttribute{ MarkdownDescription: "A list of filters that determine if a rule fires after the necessary conditions have been met. In JSON string format.", Computed: true, CustomType: sentrytypes.LossyJsonType{}, }, + "filters_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of filters that determine if a rule fires after the necessary conditions have been met.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "age_comparison": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue is older or newer than `value` `time`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "comparison_type": stringAttribute, + "value": int64Attribute, + "time": stringAttribute, + }, + }, + "issue_occurrences": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue has happened at least `value` times (Note: this is approximate).", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "value": int64Attribute, + }, + }, + "assigned_to": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue is assigned to no one, team, or member.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "target_type": int64Attribute, + "target_identifier": int64Attribute, + }, + }, + "latest_adopted_release": schema.SingleNestedAttribute{ + MarkdownDescription: "The {oldest_or_newest} adopted release associated with the event's issue is {older_or_newer} than the latest adopted release in {environment}.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "oldest_or_newest": int64Attribute, + "older_or_newer": int64Attribute, + "environment": int64Attribute, + }, + }, + "latest_release": schema.SingleNestedAttribute{ + MarkdownDescription: "The event is from the latest release.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "issue_category": schema.SingleNestedAttribute{ + MarkdownDescription: "The issue's category is equal to `value`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "value": stringAttribute, + }, + }, + "event_attribute": schema.SingleNestedAttribute{ + MarkdownDescription: "The event's `attribute` value `match` `value`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "attribute": stringAttribute, + "match": stringAttribute, + "value": stringAttribute, + }, + }, + "tagged_event": schema.SingleNestedAttribute{ + MarkdownDescription: "The event's tags match `key` `match` `value`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "key": stringAttribute, + "match": stringAttribute, + "value": stringAttribute, + }, + }, + "level": schema.SingleNestedAttribute{ + MarkdownDescription: "The event's level is `match` `level`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "match": stringAttribute, + "level": stringAttribute, + }, + }, + }, + }, + }, "actions": schema.StringAttribute{ MarkdownDescription: "List of actions. In JSON string format.", Computed: true, CustomType: sentrytypes.LossyJsonType{}, }, + "actions_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of actions that take place when all required conditions and filters for the rule are met.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "notify_email": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to `target_type` and if none can be found then send a notification to `fallthrough_type`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "target_type": stringAttribute, + "target_identifier": stringAttribute, + "fallthrough_type": stringAttribute, + }, + }, + "notify_event": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to all legacy integrations.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + }, + }, + "notify_event_service": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification via an integration.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "service": stringAttribute, + }, + }, + "notify_event_sentry_app": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to a Sentry app.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "sentry_app_installation_uuid": stringAttribute, + "settings": schema.MapAttribute{ + ElementType: types.StringType, + Computed: true, + }, + }, + }, + "opsgenie_notify_team": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to Opsgenie account `account` and team `team` with `priority` priority.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "account": stringAttribute, + "team": stringAttribute, + "priority": stringAttribute, + }, + }, + "pagerduty_notify_service": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to PagerDuty account `account` and service `service` with `severity` severity.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "account": stringAttribute, + "service": stringAttribute, + "severity": stringAttribute, + }, + }, + "slack_notify_service": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to the `workspace` Slack workspace to `channel` (optionally, an ID: `channel_id`) and show tags `tags` and notes `notes` in notification.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "workspace": stringAttribute, + "channel": stringAttribute, + "channel_id": stringAttribute, + "tags": schema.SetAttribute{ + Computed: true, + CustomType: sentrytypes.StringSetType{ + SetType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + "notes": stringAttribute, + }, + }, + "msteams_notify_service": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to the `team` Team to `channel`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "team": stringAttribute, + "channel": stringAttribute, + "channel_id": stringAttribute, + }, + }, + "discord_notify_service": schema.SingleNestedAttribute{ + MarkdownDescription: "Send a notification to the `server` Discord server in the channel with ID or URL: `channel_id` and show tags `tags` in the notification.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "server": stringAttribute, + "channel_id": stringAttribute, + "tags": schema.SetAttribute{ + Computed: true, + CustomType: sentrytypes.StringSetType{ + SetType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + }, + "jira_create_ticket": schema.SingleNestedAttribute{ + MarkdownDescription: "Create a Jira issue in `integration`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "integration": stringAttribute, + "project": stringAttribute, + "issue_type": stringAttribute, + }, + }, + "jira_server_create_ticket": schema.SingleNestedAttribute{ + MarkdownDescription: "Create a Jira Server issue in `integration`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "integration": stringAttribute, + "project": stringAttribute, + "issue_type": stringAttribute, + }, + }, + "github_create_ticket": schema.SingleNestedAttribute{ + MarkdownDescription: "Create a GitHub issue in `integration`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "integration": stringAttribute, + "repo": stringAttribute, + "assignee": stringAttribute, + "labels": schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + }, + }, + }, + "github_enterprise_create_ticket": schema.SingleNestedAttribute{ + MarkdownDescription: "Create a GitHub Enterprise issue in `integration`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "integration": stringAttribute, + "repo": stringAttribute, + "assignee": stringAttribute, + "labels": schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + }, + }, + }, + "azure_devops_create_ticket": schema.SingleNestedAttribute{ + MarkdownDescription: "Create an Azure DevOps work item in `integration`.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": stringAttribute, + "integration": stringAttribute, + "work_item_type": stringAttribute, + }, + }, + }, + }, + }, "action_match": schema.StringAttribute{ MarkdownDescription: "Trigger actions when an event is captured by Sentry and `any` or `all` of the specified conditions happen.", Computed: true, @@ -146,31 +423,33 @@ func (d *IssueAlertDataSource) Schema(ctx context.Context, req datasource.Schema } func (d *IssueAlertDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data IssueAlertResourceModel + var data IssueAlertModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - action, apiResp, err := d.client.IssueAlerts.Get( + httpResp, err := d.apiClient.GetProjectRuleWithResponse( ctx, data.Organization.ValueString(), data.Project.ValueString(), data.Id.ValueString(), ) - if apiResp.StatusCode == http.StatusNotFound { + if err != nil { + resp.Diagnostics.Append(diagutils.NewClientError("read", err)) + return + } else if httpResp.StatusCode() == http.StatusNotFound { resp.Diagnostics.Append(diagutils.NewNotFoundError("issue alert")) resp.State.RemoveResource(ctx) return - } - if err != nil { - resp.Diagnostics.Append(diagutils.NewClientError("read", err)) + } else if httpResp.StatusCode() != http.StatusOK || httpResp.JSON200 == nil { + resp.Diagnostics.Append(diagutils.NewClientStatusError("read", httpResp.StatusCode(), httpResp.Body)) return } - if err := data.Fill(data.Organization.ValueString(), *action); err != nil { - resp.Diagnostics.Append(diagutils.NewFillError(err)) + resp.Diagnostics.Append(data.Fill(ctx, *httpResp.JSON200)...) + if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/data_source_issue_alert_test.go b/internal/provider/data_source_issue_alert_test.go index 51eba70bd..db88e7a36 100644 --- a/internal/provider/data_source_issue_alert_test.go +++ b/internal/provider/data_source_issue_alert_test.go @@ -1,47 +1,24 @@ package provider import ( - "context" "fmt" "testing" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/jianyuan/terraform-provider-sentry/internal/acctest" - "github.com/jianyuan/terraform-provider-sentry/internal/sentrytypes" ) func TestAccIssueAlertDataSource(t *testing.T) { rn := "sentry_issue_alert.test" - rnCopy := "sentry_issue_alert.test_copy" dsn := "data.sentry_issue_alert.test" team := acctest.RandomWithPrefix("tf-team") project := acctest.RandomWithPrefix("tf-project") alert := acctest.RandomWithPrefix("tf-issue-alert") var alertId string - var alertIdCopy string - - checkResourceAttrJsonPair := func(a, b, attr string) resource.TestCheckFunc { - return func(s *terraform.State) error { - resA, ok := s.RootModule().Resources[a] - if !ok { - return fmt.Errorf("resource %s not found", a) - } - - resB, ok := s.RootModule().Resources[b] - if !ok { - return fmt.Errorf("resource %s not found", b) - } - - expected := sentrytypes.NewLossyJsonValue(resA.Primary.Attributes[attr]) - given := sentrytypes.NewLossyJsonValue(resB.Primary.Attributes[attr]) - match, diags := expected.StringSemanticEquals(context.Background(), given) - if !match { - return fmt.Errorf("expected %s, got %s: %s", expected, given, diags) - } - return nil - } - } resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, @@ -51,30 +28,16 @@ func TestAccIssueAlertDataSource(t *testing.T) { Config: testAccIssueAlertDataSourceConfig(team, project, alert), Check: resource.ComposeTestCheckFunc( testAccCheckIssueAlertExists(rn, &alertId), - resource.TestCheckResourceAttr(dsn, "organization", acctest.TestOrganization), - resource.TestCheckResourceAttr(dsn, "project", project), - resource.TestCheckResourceAttrPair(dsn, "organization", rn, "organization"), - resource.TestCheckResourceAttrPair(dsn, "project", rn, "project"), - checkResourceAttrJsonPair(dsn, rn, "conditions"), - checkResourceAttrJsonPair(dsn, rn, "filters"), - checkResourceAttrJsonPair(dsn, rn, "actions"), - resource.TestCheckResourceAttrPair(dsn, "action_match", rn, "action_match"), - resource.TestCheckResourceAttrPair(dsn, "filter_match", rn, "filter_match"), - resource.TestCheckResourceAttrPair(dsn, "frequency", rn, "frequency"), - resource.TestCheckResourceAttrPair(dsn, "name", rn, "name"), - resource.TestCheckResourceAttrPair(dsn, "environment", rn, "environment"), - testAccCheckIssueAlertExists(rnCopy, &alertIdCopy), - resource.TestCheckResourceAttrPair(rnCopy, "organization", rn, "organization"), - resource.TestCheckResourceAttrPair(rnCopy, "project", rn, "project"), - checkResourceAttrJsonPair(rnCopy, rn, "conditions"), - checkResourceAttrJsonPair(rnCopy, rn, "filters"), - checkResourceAttrJsonPair(rnCopy, rn, "actions"), - resource.TestCheckResourceAttr(rnCopy, "action_match", "all"), - resource.TestCheckResourceAttr(rnCopy, "filter_match", "all"), - resource.TestCheckResourceAttrPair(rnCopy, "frequency", rn, "frequency"), - resource.TestCheckResourceAttr(rnCopy, "name", alert+"-copy"), - resource.TestCheckResourceAttrPair(rnCopy, "environment", rn, "environment"), ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(dsn, tfjsonpath.New("organization"), knownvalue.StringExact(acctest.TestOrganization)), + statecheck.ExpectKnownValue(dsn, tfjsonpath.New("project"), knownvalue.StringExact(project)), + statecheck.CompareValuePairs(dsn, tfjsonpath.New("action_match"), rn, tfjsonpath.New("action_match"), compare.ValuesSame()), + statecheck.CompareValuePairs(dsn, tfjsonpath.New("filter_match"), rn, tfjsonpath.New("filter_match"), compare.ValuesSame()), + statecheck.CompareValuePairs(dsn, tfjsonpath.New("frequency"), rn, tfjsonpath.New("frequency"), compare.ValuesSame()), + statecheck.CompareValuePairs(dsn, tfjsonpath.New("name"), rn, tfjsonpath.New("name"), compare.ValuesSame()), + statecheck.CompareValuePairs(dsn, tfjsonpath.New("environment"), rn, tfjsonpath.New("environment"), compare.ValuesSame()), + }, }, }, }) @@ -197,19 +160,5 @@ data "sentry_issue_alert" "test" { organization = sentry_issue_alert.test.organization project = sentry_issue_alert.test.project } - -resource "sentry_issue_alert" "test_copy" { - organization = data.sentry_issue_alert.test.organization - project = data.sentry_issue_alert.test.project - name = "${data.sentry_issue_alert.test.name}-copy" - - action_match = "all" - filter_match = "all" - frequency = data.sentry_issue_alert.test.frequency - - conditions = data.sentry_issue_alert.test.conditions - filters = data.sentry_issue_alert.test.filters - actions = data.sentry_issue_alert.test.actions -} `, teamName, projectName, alertName) } diff --git a/internal/provider/model_issue_alert.go b/internal/provider/model_issue_alert.go new file mode 100644 index 000000000..48563ca39 --- /dev/null +++ b/internal/provider/model_issue_alert.go @@ -0,0 +1,1482 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/jianyuan/go-utils/ptr" + "github.com/jianyuan/go-utils/sliceutils" + "github.com/jianyuan/terraform-provider-sentry/internal/apiclient" + "github.com/jianyuan/terraform-provider-sentry/internal/sentrydata" + "github.com/jianyuan/terraform-provider-sentry/internal/sentrytypes" + "github.com/jianyuan/terraform-provider-sentry/internal/tfutils" +) + +// Conditions + +type IssueAlertConditionFirstSeenEventModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertConditionFirstSeenEventModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionFirstSeenEvent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + return +} + +func (m IssueAlertConditionFirstSeenEventModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionFirstSeenEvent(apiclient.ProjectRuleConditionFirstSeenEvent{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionRegressionEventModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertConditionRegressionEventModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionRegressionEvent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + return +} + +func (m IssueAlertConditionRegressionEventModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionRegressionEvent(apiclient.ProjectRuleConditionRegressionEvent{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionReappearedEventModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertConditionReappearedEventModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionReappearedEvent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + return +} + +func (m IssueAlertConditionReappearedEventModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionReappearedEvent(apiclient.ProjectRuleConditionReappearedEvent{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionNewHighPriorityIssueModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertConditionNewHighPriorityIssueModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionNewHighPriorityIssue) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + return +} + +func (m IssueAlertConditionNewHighPriorityIssueModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionNewHighPriorityIssue(apiclient.ProjectRuleConditionNewHighPriorityIssue{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionExistingHighPriorityIssueModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertConditionExistingHighPriorityIssueModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionExistingHighPriorityIssue) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + return +} + +func (m IssueAlertConditionExistingHighPriorityIssueModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionExistingHighPriorityIssue(apiclient.ProjectRuleConditionExistingHighPriorityIssue{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionEventFrequencyModel struct { + Name types.String `tfsdk:"name"` + ComparisonType types.String `tfsdk:"comparison_type"` + ComparisonInterval types.String `tfsdk:"comparison_interval"` + Value types.Int64 `tfsdk:"value"` + Interval types.String `tfsdk:"interval"` +} + +func (m *IssueAlertConditionEventFrequencyModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionEventFrequency) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + m.ComparisonType = types.StringValue(condition.ComparisonType) + m.ComparisonInterval = types.StringPointerValue(condition.ComparisonInterval) + m.Value = types.Int64Value(condition.Value) + m.Interval = types.StringValue(condition.Interval) + return +} + +func (m IssueAlertConditionEventFrequencyModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionEventFrequency(apiclient.ProjectRuleConditionEventFrequency{ + Name: m.Name.ValueStringPointer(), + ComparisonType: m.ComparisonType.ValueString(), + ComparisonInterval: m.ComparisonInterval.ValueStringPointer(), + Value: m.Value.ValueInt64(), + Interval: m.Interval.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionEventUniqueUserFrequencyModel struct { + Name types.String `tfsdk:"name"` + ComparisonType types.String `tfsdk:"comparison_type"` + ComparisonInterval types.String `tfsdk:"comparison_interval"` + Value types.Int64 `tfsdk:"value"` + Interval types.String `tfsdk:"interval"` +} + +func (m *IssueAlertConditionEventUniqueUserFrequencyModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionEventUniqueUserFrequency) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + m.ComparisonType = types.StringValue(condition.ComparisonType) + m.ComparisonInterval = types.StringPointerValue(condition.ComparisonInterval) + m.Value = types.Int64Value(condition.Value) + m.Interval = types.StringValue(condition.Interval) + return +} + +func (m IssueAlertConditionEventUniqueUserFrequencyModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionEventUniqueUserFrequency(apiclient.ProjectRuleConditionEventUniqueUserFrequency{ + Name: m.Name.ValueStringPointer(), + ComparisonType: m.ComparisonType.ValueString(), + ComparisonInterval: m.ComparisonInterval.ValueStringPointer(), + Value: m.Value.ValueInt64(), + Interval: m.Interval.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionEventFrequencyPercentModel struct { + Name types.String `tfsdk:"name"` + ComparisonType types.String `tfsdk:"comparison_type"` + ComparisonInterval types.String `tfsdk:"comparison_interval"` + Value types.Float64 `tfsdk:"value"` + Interval types.String `tfsdk:"interval"` +} + +func (m *IssueAlertConditionEventFrequencyPercentModel) Fill(ctx context.Context, condition apiclient.ProjectRuleConditionEventFrequencyPercent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(condition.Name) + m.ComparisonType = types.StringValue(condition.ComparisonType) + m.ComparisonInterval = types.StringPointerValue(condition.ComparisonInterval) + m.Value = types.Float64Value(condition.Value) + m.Interval = types.StringValue(condition.Interval) + return +} + +func (m IssueAlertConditionEventFrequencyPercentModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleCondition + err := v.FromProjectRuleConditionEventFrequencyPercent(apiclient.ProjectRuleConditionEventFrequencyPercent{ + Name: m.Name.ValueStringPointer(), + ComparisonType: m.ComparisonType.ValueString(), + ComparisonInterval: m.ComparisonInterval.ValueStringPointer(), + Value: m.Value.ValueFloat64(), + Interval: m.Interval.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertConditionModel struct { + FirstSeenEvent *IssueAlertConditionFirstSeenEventModel `tfsdk:"first_seen_event"` + RegressionEvent *IssueAlertConditionRegressionEventModel `tfsdk:"regression_event"` + ReappearedEvent *IssueAlertConditionReappearedEventModel `tfsdk:"reappeared_event"` + NewHighPriorityIssue *IssueAlertConditionNewHighPriorityIssueModel `tfsdk:"new_high_priority_issue"` + ExistingHighPriorityIssue *IssueAlertConditionExistingHighPriorityIssueModel `tfsdk:"existing_high_priority_issue"` + EventFrequency *IssueAlertConditionEventFrequencyModel `tfsdk:"event_frequency"` + EventUniqueUserFrequency *IssueAlertConditionEventUniqueUserFrequencyModel `tfsdk:"event_unique_user_frequency"` + EventFrequencyPercent *IssueAlertConditionEventFrequencyPercentModel `tfsdk:"event_frequency_percent"` +} + +func (m IssueAlertConditionModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleCondition, diag.Diagnostics) { + if m.FirstSeenEvent != nil { + return m.FirstSeenEvent.ToApi(ctx) + } else if m.RegressionEvent != nil { + return m.RegressionEvent.ToApi(ctx) + } else if m.ReappearedEvent != nil { + return m.ReappearedEvent.ToApi(ctx) + } else if m.NewHighPriorityIssue != nil { + return m.NewHighPriorityIssue.ToApi(ctx) + } else if m.ExistingHighPriorityIssue != nil { + return m.ExistingHighPriorityIssue.ToApi(ctx) + } else if m.EventFrequency != nil { + return m.EventFrequency.ToApi(ctx) + } else if m.EventUniqueUserFrequency != nil { + return m.EventUniqueUserFrequency.ToApi(ctx) + } else if m.EventFrequencyPercent != nil { + return m.EventFrequencyPercent.ToApi(ctx) + } else { + var diags diag.Diagnostics + diags.AddError("Exactly one condition must be set", "Exactly one condition must be set") + return nil, diags + } +} + +func (m *IssueAlertConditionModel) Fill(ctx context.Context, condition apiclient.ProjectRuleCondition) (diags diag.Diagnostics) { + conditionValue, err := condition.ValueByDiscriminator() + if err != nil { + diags.AddError("Invalid condition", err.Error()) + return + } + + m.FirstSeenEvent = nil + m.RegressionEvent = nil + m.ReappearedEvent = nil + m.NewHighPriorityIssue = nil + m.ExistingHighPriorityIssue = nil + m.EventFrequency = nil + m.EventUniqueUserFrequency = nil + m.EventFrequencyPercent = nil + + switch conditionValue := conditionValue.(type) { + case apiclient.ProjectRuleConditionFirstSeenEvent: + m.FirstSeenEvent = &IssueAlertConditionFirstSeenEventModel{} + diags.Append(m.FirstSeenEvent.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionRegressionEvent: + m.RegressionEvent = &IssueAlertConditionRegressionEventModel{} + diags.Append(m.RegressionEvent.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionReappearedEvent: + m.ReappearedEvent = &IssueAlertConditionReappearedEventModel{} + diags.Append(m.ReappearedEvent.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionNewHighPriorityIssue: + m.NewHighPriorityIssue = &IssueAlertConditionNewHighPriorityIssueModel{} + diags.Append(m.NewHighPriorityIssue.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionExistingHighPriorityIssue: + m.ExistingHighPriorityIssue = &IssueAlertConditionExistingHighPriorityIssueModel{} + diags.Append(m.ExistingHighPriorityIssue.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionEventFrequency: + m.EventFrequency = &IssueAlertConditionEventFrequencyModel{} + diags.Append(m.EventFrequency.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionEventUniqueUserFrequency: + m.EventUniqueUserFrequency = &IssueAlertConditionEventUniqueUserFrequencyModel{} + diags.Append(m.EventUniqueUserFrequency.Fill(ctx, conditionValue)...) + case apiclient.ProjectRuleConditionEventFrequencyPercent: + m.EventFrequencyPercent = &IssueAlertConditionEventFrequencyPercentModel{} + diags.Append(m.EventFrequencyPercent.Fill(ctx, conditionValue)...) + default: + diags.AddError("Unsupported condition", fmt.Sprintf("Unsupported condition type %T", conditionValue)) + } + + return +} + +// Filters + +type IssueAlertFilterAgeComparisonModel struct { + Name types.String `tfsdk:"name"` + ComparisonType types.String `tfsdk:"comparison_type"` + Value types.Int64 `tfsdk:"value"` + Time types.String `tfsdk:"time"` +} + +func (m *IssueAlertFilterAgeComparisonModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterAgeComparison) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.ComparisonType = types.StringValue(filter.ComparisonType) + m.Value = types.Int64Value(filter.Value) + m.Time = types.StringValue(filter.Time) + return +} + +func (m IssueAlertFilterAgeComparisonModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterAgeComparison(apiclient.ProjectRuleFilterAgeComparison{ + Name: m.Name.ValueStringPointer(), + ComparisonType: m.ComparisonType.ValueString(), + Value: m.Value.ValueInt64(), + Time: m.Time.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterIssueOccurrencesModel struct { + Name types.String `tfsdk:"name"` + Value types.Int64 `tfsdk:"value"` +} + +func (m *IssueAlertFilterIssueOccurrencesModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterIssueOccurrences) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.Value = types.Int64Value(filter.Value) + return +} + +func (m IssueAlertFilterIssueOccurrencesModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterIssueOccurrences(apiclient.ProjectRuleFilterIssueOccurrences{ + Name: m.Name.ValueStringPointer(), + Value: m.Value.ValueInt64(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterAssignedToModel struct { + Name types.String `tfsdk:"name"` + TargetType types.String `tfsdk:"target_type"` + TargetIdentifier types.String `tfsdk:"target_identifier"` +} + +func (m *IssueAlertFilterAssignedToModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterAssignedTo) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.TargetType = types.StringValue(filter.TargetType) + + if filter.TargetIdentifier == nil { + m.TargetIdentifier = types.StringNull() + } else if v, err := filter.TargetIdentifier.AsProjectRuleFilterAssignedToTargetIdentifier0(); err == nil { + if v == "" { + m.TargetIdentifier = types.StringNull() + } else { + m.TargetIdentifier = types.StringValue(v) + } + } else if v, err := filter.TargetIdentifier.AsProjectRuleFilterAssignedToTargetIdentifier1(); err == nil { + m.TargetIdentifier = types.StringValue(v.String()) + } + + return +} + +func (m IssueAlertFilterAssignedToModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + + var targetIdentifier *apiclient.ProjectRuleFilterAssignedTo_TargetIdentifier + + if !m.TargetIdentifier.IsNull() { + targetIdentifier = &apiclient.ProjectRuleFilterAssignedTo_TargetIdentifier{} + err := targetIdentifier.FromProjectRuleFilterAssignedToTargetIdentifier0(m.TargetIdentifier.ValueString()) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + } + + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterAssignedTo(apiclient.ProjectRuleFilterAssignedTo{ + Name: m.Name.ValueStringPointer(), + TargetType: m.TargetType.ValueString(), + TargetIdentifier: targetIdentifier, + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterLatestAdoptedReleaseModel struct { + Name types.String `tfsdk:"name"` + OldestOrNewest types.String `tfsdk:"oldest_or_newest"` + OlderOrNewer types.String `tfsdk:"older_or_newer"` + Environment types.String `tfsdk:"environment"` +} + +func (m *IssueAlertFilterLatestAdoptedReleaseModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterLatestAdoptedRelease) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.OldestOrNewest = types.StringValue(filter.OldestOrNewest) + m.OlderOrNewer = types.StringValue(filter.OlderOrNewer) + m.Environment = types.StringValue(filter.Environment) + return +} + +func (m IssueAlertFilterLatestAdoptedReleaseModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterLatestAdoptedRelease(apiclient.ProjectRuleFilterLatestAdoptedRelease{ + Name: m.Name.ValueStringPointer(), + OldestOrNewest: m.OldestOrNewest.ValueString(), + OlderOrNewer: m.OlderOrNewer.ValueString(), + Environment: m.Environment.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterLatestReleaseModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertFilterLatestReleaseModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterLatestRelease) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + return +} + +func (m IssueAlertFilterLatestReleaseModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterLatestRelease(apiclient.ProjectRuleFilterLatestRelease{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterIssueCategoryModel struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +func (m *IssueAlertFilterIssueCategoryModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterIssueCategory) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + + value, ok := sentrydata.IssueGroupCategoryIdToName[filter.Value] + if !ok { + diags.AddError("Invalid issue category", fmt.Sprintf("Invalid issue category %q. Please report this to the provider developers.", filter.Value)) + return + } + m.Value = types.StringValue(value) + + return +} + +func (m IssueAlertFilterIssueCategoryModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterIssueCategory(apiclient.ProjectRuleFilterIssueCategory{ + Name: m.Name.ValueStringPointer(), + Value: sentrydata.IssueGroupCategoryNameToId[m.Value.ValueString()], + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterEventAttributeModel struct { + Name types.String `tfsdk:"name"` + Attribute types.String `tfsdk:"attribute"` + Match types.String `tfsdk:"match"` + Value types.String `tfsdk:"value"` +} + +func (m *IssueAlertFilterEventAttributeModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterEventAttribute) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.Attribute = types.StringValue(filter.Attribute) + + match, ok := sentrydata.MatchTypeIdToName[filter.Match] + if !ok { + diags.AddError("Invalid match type", fmt.Sprintf("Invalid match type %q. Please report this to the provider developers.", filter.Match)) + return + } + m.Match = types.StringValue(match) + + if filter.Value == nil || *filter.Value == "" { + m.Value = types.StringNull() + } else { + m.Value = types.StringValue(*filter.Value) + } + return +} + +func (m IssueAlertFilterEventAttributeModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterEventAttribute(apiclient.ProjectRuleFilterEventAttribute{ + Name: m.Name.ValueStringPointer(), + Attribute: m.Attribute.ValueString(), + Match: sentrydata.MatchTypeNameToId[m.Match.ValueString()], + Value: m.Value.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterTaggedEventModel struct { + Name types.String `tfsdk:"name"` + Key types.String `tfsdk:"key"` + Match types.String `tfsdk:"match"` + Value types.String `tfsdk:"value"` +} + +func (m *IssueAlertFilterTaggedEventModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterTaggedEvent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + m.Key = types.StringValue(filter.Key) + + match, ok := sentrydata.MatchTypeIdToName[filter.Match] + if !ok { + diags.AddError("Invalid match type", fmt.Sprintf("Invalid match type %q. Please report this to the provider developers.", filter.Match)) + return + } + m.Match = types.StringValue(match) + + if filter.Value == nil || *filter.Value == "" { + m.Value = types.StringNull() + } else { + m.Value = types.StringValue(*filter.Value) + } + return +} + +func (m IssueAlertFilterTaggedEventModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterTaggedEvent(apiclient.ProjectRuleFilterTaggedEvent{ + Name: m.Name.ValueStringPointer(), + Key: m.Key.ValueString(), + Match: sentrydata.MatchTypeNameToId[m.Match.ValueString()], + Value: m.Value.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterLevelModel struct { + Name types.String `tfsdk:"name"` + Match types.String `tfsdk:"match"` + Level types.String `tfsdk:"level"` +} + +func (m *IssueAlertFilterLevelModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilterLevel) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(filter.Name) + + match, ok := sentrydata.MatchTypeIdToName[filter.Match] + if !ok { + diags.AddError("Invalid match type", fmt.Sprintf("Invalid match type %q. Please report this to the provider developers.", filter.Match)) + return + } + m.Match = types.StringValue(match) + + level, ok := sentrydata.LogLevelIdToName[filter.Level] + if !ok { + diags.AddError("Invalid level", fmt.Sprintf("Invalid level %q. Please report this to the provider developers.", filter.Level)) + return + } + m.Level = types.StringValue(level) + return +} + +func (m IssueAlertFilterLevelModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleFilter + err := v.FromProjectRuleFilterLevel(apiclient.ProjectRuleFilterLevel{ + Name: m.Name.ValueStringPointer(), + Match: sentrydata.MatchTypeNameToId[m.Match.ValueString()], + Level: sentrydata.LogLevelNameToId[m.Level.ValueString()], + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertFilterModel struct { + AgeComparison *IssueAlertFilterAgeComparisonModel `tfsdk:"age_comparison"` + IssueOccurrences *IssueAlertFilterIssueOccurrencesModel `tfsdk:"issue_occurrences"` + AssignedTo *IssueAlertFilterAssignedToModel `tfsdk:"assigned_to"` + LatestAdoptedRelease *IssueAlertFilterLatestAdoptedReleaseModel `tfsdk:"latest_adopted_release"` + LatestRelease *IssueAlertFilterLatestReleaseModel `tfsdk:"latest_release"` + IssueCategory *IssueAlertFilterIssueCategoryModel `tfsdk:"issue_category"` + EventAttribute *IssueAlertFilterEventAttributeModel `tfsdk:"event_attribute"` + TaggedEvent *IssueAlertFilterTaggedEventModel `tfsdk:"tagged_event"` + Level *IssueAlertFilterLevelModel `tfsdk:"level"` +} + +func (m IssueAlertFilterModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleFilter, diag.Diagnostics) { + if m.AgeComparison != nil { + return m.AgeComparison.ToApi(ctx) + } else if m.IssueOccurrences != nil { + return m.IssueOccurrences.ToApi(ctx) + } else if m.AssignedTo != nil { + return m.AssignedTo.ToApi(ctx) + } else if m.LatestAdoptedRelease != nil { + return m.LatestAdoptedRelease.ToApi(ctx) + } else if m.LatestRelease != nil { + return m.LatestRelease.ToApi(ctx) + } else if m.IssueCategory != nil { + return m.IssueCategory.ToApi(ctx) + } else if m.EventAttribute != nil { + return m.EventAttribute.ToApi(ctx) + } else if m.TaggedEvent != nil { + return m.TaggedEvent.ToApi(ctx) + } else if m.Level != nil { + return m.Level.ToApi(ctx) + } else { + var diags diag.Diagnostics + diags.AddError("Exactly one filter must be set", "Exactly one filter must be set") + return nil, diags + } +} + +func (m *IssueAlertFilterModel) Fill(ctx context.Context, filter apiclient.ProjectRuleFilter) (diags diag.Diagnostics) { + filterValue, err := filter.ValueByDiscriminator() + if err != nil { + diags.AddError("Invalid filter", err.Error()) + return + } + + m.AgeComparison = nil + m.IssueOccurrences = nil + m.AssignedTo = nil + m.LatestAdoptedRelease = nil + m.LatestRelease = nil + m.IssueCategory = nil + m.EventAttribute = nil + m.TaggedEvent = nil + m.Level = nil + + switch filterValue := filterValue.(type) { + case apiclient.ProjectRuleFilterAgeComparison: + m.AgeComparison = &IssueAlertFilterAgeComparisonModel{} + diags.Append(m.AgeComparison.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterIssueOccurrences: + m.IssueOccurrences = &IssueAlertFilterIssueOccurrencesModel{} + diags.Append(m.IssueOccurrences.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterAssignedTo: + m.AssignedTo = &IssueAlertFilterAssignedToModel{} + diags.Append(m.AssignedTo.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterLatestAdoptedRelease: + m.LatestAdoptedRelease = &IssueAlertFilterLatestAdoptedReleaseModel{} + diags.Append(m.LatestAdoptedRelease.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterLatestRelease: + m.LatestRelease = &IssueAlertFilterLatestReleaseModel{} + diags.Append(m.LatestRelease.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterIssueCategory: + m.IssueCategory = &IssueAlertFilterIssueCategoryModel{} + diags.Append(m.IssueCategory.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterEventAttribute: + m.EventAttribute = &IssueAlertFilterEventAttributeModel{} + diags.Append(m.EventAttribute.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterTaggedEvent: + m.TaggedEvent = &IssueAlertFilterTaggedEventModel{} + diags.Append(m.TaggedEvent.Fill(ctx, filterValue)...) + case apiclient.ProjectRuleFilterLevel: + m.Level = &IssueAlertFilterLevelModel{} + diags.Append(m.Level.Fill(ctx, filterValue)...) + default: + diags.AddError("Unsupported filter", fmt.Sprintf("Unsupported filter type %T", filterValue)) + } + + return +} + +// Actions + +type IssueAlertActionNotifyEmailModel struct { + Name types.String `tfsdk:"name"` + TargetType types.String `tfsdk:"target_type"` + TargetIdentifier types.String `tfsdk:"target_identifier"` + FallthroughType types.String `tfsdk:"fallthrough_type"` +} + +func (m *IssueAlertActionNotifyEmailModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionNotifyEmail) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.TargetType = types.StringValue(action.TargetType) + + if action.TargetIdentifier == nil { + m.TargetIdentifier = types.StringNull() + } else if v, err := action.TargetIdentifier.AsProjectRuleActionNotifyEmailTargetIdentifier0(); err == nil { + if v == "" { + m.TargetIdentifier = types.StringNull() + } else { + m.TargetIdentifier = types.StringValue(v) + } + } else if v, err := action.TargetIdentifier.AsProjectRuleActionNotifyEmailTargetIdentifier1(); err == nil { + m.TargetIdentifier = types.StringValue(v.String()) + } + + // Only set FallthroughType for IssueOwners + if action.TargetType == "IssueOwners" { + m.FallthroughType = types.StringPointerValue(action.FallthroughType) + } else { + m.FallthroughType = types.StringNull() + } + + return +} + +func (m IssueAlertActionNotifyEmailModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var targetIdentifier *apiclient.ProjectRuleActionNotifyEmail_TargetIdentifier + + if !m.TargetIdentifier.IsNull() { + targetIdentifier = &apiclient.ProjectRuleActionNotifyEmail_TargetIdentifier{} + err := targetIdentifier.FromProjectRuleActionNotifyEmailTargetIdentifier0(m.TargetIdentifier.ValueString()) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + } + + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionNotifyEmail(apiclient.ProjectRuleActionNotifyEmail{ + Name: m.Name.ValueStringPointer(), + TargetType: m.TargetType.ValueString(), + TargetIdentifier: targetIdentifier, + FallthroughType: m.FallthroughType.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionNotifyEventModel struct { + Name types.String `tfsdk:"name"` +} + +func (m *IssueAlertActionNotifyEventModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionNotifyEvent) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + return +} + +func (m IssueAlertActionNotifyEventModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionNotifyEvent(apiclient.ProjectRuleActionNotifyEvent{ + Name: m.Name.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionNotifyEventServiceModel struct { + Name types.String `tfsdk:"name"` + Service types.String `tfsdk:"service"` +} + +func (m *IssueAlertActionNotifyEventServiceModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionNotifyEventService) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Service = types.StringValue(action.Service) + return +} + +func (m IssueAlertActionNotifyEventServiceModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionNotifyEventService(apiclient.ProjectRuleActionNotifyEventService{ + Name: m.Name.ValueStringPointer(), + Service: m.Service.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionNotifyEventSentryAppModel struct { + Name types.String `tfsdk:"name"` + SentryAppInstallationUuid types.String `tfsdk:"sentry_app_installation_uuid"` + Settings types.Map `tfsdk:"settings"` +} + +func (m *IssueAlertActionNotifyEventSentryAppModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionNotifyEventSentryApp) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.SentryAppInstallationUuid = types.StringValue(action.SentryAppInstallationUuid) + + if action.Settings == nil { + m.Settings = types.MapNull(types.StringType) + } else { + var settingsMap = make(map[string]attr.Value) + for _, setting := range *action.Settings { + settingsMap[setting.Name] = types.StringValue(setting.Value) + } + m.Settings = types.MapValueMust(types.StringType, settingsMap) + } + return +} + +func (m IssueAlertActionNotifyEventSentryAppModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + + var settings *[]struct { + Name string `json:"name"` + Value string `json:"value"` + } + + if !m.Settings.IsNull() { + elements := make(map[string]string, len(m.Settings.Elements())) + diags.Append(m.Settings.ElementsAs(ctx, &elements, false)...) + if diags.HasError() { + return nil, diags + } + + settings = &[]struct { + Name string `json:"name"` + Value string `json:"value"` + }{} + + for k, v := range elements { + *settings = append(*settings, struct { + Name string `json:"name"` + Value string `json:"value"` + }{ + Name: k, + Value: v, + }) + } + } + + err := v.FromProjectRuleActionNotifyEventSentryApp(apiclient.ProjectRuleActionNotifyEventSentryApp{ + Name: m.Name.ValueStringPointer(), + SentryAppInstallationUuid: m.SentryAppInstallationUuid.ValueString(), + Settings: settings, + HasSchemaFormConfig: true, + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionOpsgenieNotifyTeam struct { + Name types.String `tfsdk:"name"` + Account types.String `tfsdk:"account"` + Team types.String `tfsdk:"team"` + Priority types.String `tfsdk:"priority"` +} + +func (m *IssueAlertActionOpsgenieNotifyTeam) Fill(ctx context.Context, action apiclient.ProjectRuleActionOpsgenieNotifyTeam) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Account = types.StringValue(action.Account) + m.Team = types.StringValue(action.Team) + m.Priority = types.StringValue(action.Priority) + return +} + +func (m IssueAlertActionOpsgenieNotifyTeam) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionOpsgenieNotifyTeam(apiclient.ProjectRuleActionOpsgenieNotifyTeam{ + Name: m.Name.ValueStringPointer(), + Account: m.Account.ValueString(), + Team: m.Team.ValueString(), + Priority: m.Priority.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionPagerDutyNotifyServiceModel struct { + Name types.String `tfsdk:"name"` + Account types.String `tfsdk:"account"` + Service types.String `tfsdk:"service"` + Severity types.String `tfsdk:"severity"` +} + +func (m *IssueAlertActionPagerDutyNotifyServiceModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionPagerDutyNotifyService) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Account = types.StringValue(action.Account) + m.Service = types.StringValue(action.Service) + m.Severity = types.StringValue(action.Severity) + return +} + +func (m IssueAlertActionPagerDutyNotifyServiceModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionPagerDutyNotifyService(apiclient.ProjectRuleActionPagerDutyNotifyService{ + Name: m.Name.ValueStringPointer(), + Account: m.Account.ValueString(), + Service: m.Service.ValueString(), + Severity: m.Severity.ValueString(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionSlackNotifyServiceModel struct { + Name types.String `tfsdk:"name"` + Workspace types.String `tfsdk:"workspace"` + Channel types.String `tfsdk:"channel"` + ChannelId types.String `tfsdk:"channel_id"` + Tags sentrytypes.StringSet `tfsdk:"tags"` + Notes types.String `tfsdk:"notes"` +} + +func (m *IssueAlertActionSlackNotifyServiceModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionSlackNotifyService) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Workspace = types.StringValue(action.Workspace) + m.Channel = types.StringValue(action.Channel) + m.ChannelId = types.StringPointerValue(action.ChannelId) + m.Tags = tfutils.MergeDiagnostics(sentrytypes.StringSetPointerValue(action.Tags))(&diags) + m.Notes = types.StringPointerValue(action.Notes) + return +} + +func (m IssueAlertActionSlackNotifyServiceModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionSlackNotifyService(apiclient.ProjectRuleActionSlackNotifyService{ + Name: m.Name.ValueStringPointer(), + Workspace: m.Workspace.ValueString(), + Channel: m.Channel.ValueString(), + ChannelId: m.ChannelId.ValueStringPointer(), + Tags: tfutils.MergeDiagnostics(m.Tags.ValueStringPointer(ctx))(&diags), + Notes: m.Notes.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionMsTeamsNotifyServiceModel struct { + Name types.String `tfsdk:"name"` + Team types.String `tfsdk:"team"` + Channel types.String `tfsdk:"channel"` + ChannelId types.String `tfsdk:"channel_id"` +} + +func (m *IssueAlertActionMsTeamsNotifyServiceModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionMsTeamsNotifyService) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Team = types.StringValue(action.Team) + m.Channel = types.StringValue(action.Channel) + m.ChannelId = types.StringPointerValue(action.ChannelId) + return +} + +func (m IssueAlertActionMsTeamsNotifyServiceModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionMsTeamsNotifyService(apiclient.ProjectRuleActionMsTeamsNotifyService{ + Name: m.Name.ValueStringPointer(), + Team: m.Team.ValueString(), + Channel: m.Channel.ValueString(), + ChannelId: m.ChannelId.ValueStringPointer(), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionDiscordNotifyServiceModel struct { + Name types.String `tfsdk:"name"` + Server types.String `tfsdk:"server"` + ChannelId types.String `tfsdk:"channel_id"` + Tags sentrytypes.StringSet `tfsdk:"tags"` +} + +func (m *IssueAlertActionDiscordNotifyServiceModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionDiscordNotifyService) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Server = types.StringValue(action.Server) + m.ChannelId = types.StringValue(action.ChannelId) + m.Tags = tfutils.MergeDiagnostics(sentrytypes.StringSetPointerValue(action.Tags))(&diags) + return +} + +func (m IssueAlertActionDiscordNotifyServiceModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionDiscordNotifyService(apiclient.ProjectRuleActionDiscordNotifyService{ + Name: m.Name.ValueStringPointer(), + Server: m.Server.ValueString(), + ChannelId: m.ChannelId.ValueString(), + Tags: tfutils.MergeDiagnostics(m.Tags.ValueStringPointer(ctx))(&diags), + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionJiraCreateTicketModel struct { + Name types.String `tfsdk:"name"` + Integration types.String `tfsdk:"integration"` + Project types.String `tfsdk:"project"` + IssueType types.String `tfsdk:"issue_type"` +} + +func (m *IssueAlertActionJiraCreateTicketModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionJiraCreateTicket) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Integration = types.StringValue(action.Integration) + m.Project = types.StringValue(action.Project) + m.IssueType = types.StringValue(action.IssueType) + return +} + +func (m IssueAlertActionJiraCreateTicketModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionJiraCreateTicket(apiclient.ProjectRuleActionJiraCreateTicket{ + Name: m.Name.ValueStringPointer(), + Integration: m.Integration.ValueString(), + Project: m.Project.ValueString(), + IssueType: m.IssueType.ValueString(), + DynamicFormFields: []map[string]interface{}{{"dummy": "dummy"}}, + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionJiraServerCreateTicketModel struct { + Name types.String `tfsdk:"name"` + Integration types.String `tfsdk:"integration"` + Project types.String `tfsdk:"project"` + IssueType types.String `tfsdk:"issue_type"` +} + +func (m *IssueAlertActionJiraServerCreateTicketModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionJiraServerCreateTicket) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Integration = types.StringValue(action.Integration) + m.Project = types.StringValue(action.Project) + m.IssueType = types.StringValue(action.IssueType) + return +} + +func (m IssueAlertActionJiraServerCreateTicketModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionJiraServerCreateTicket(apiclient.ProjectRuleActionJiraServerCreateTicket{ + Name: m.Name.ValueStringPointer(), + Integration: m.Integration.ValueString(), + Project: m.Project.ValueString(), + IssueType: m.IssueType.ValueString(), + DynamicFormFields: []map[string]interface{}{{"dummy": "dummy"}}, + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionGitHubCreateTicketModel struct { + Name types.String `tfsdk:"name"` + Integration types.String `tfsdk:"integration"` + Repo types.String `tfsdk:"repo"` + Assignee types.String `tfsdk:"assignee"` + Labels types.Set `tfsdk:"labels"` +} + +func (m *IssueAlertActionGitHubCreateTicketModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionGitHubCreateTicket) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Integration = types.StringValue(action.Integration) + m.Repo = types.StringValue(action.Repo) + m.Assignee = types.StringPointerValue(action.Assignee) + + if action.Labels == nil { + m.Labels = types.SetNull(types.StringType) + } else { + m.Labels = types.SetValueMust(types.StringType, sliceutils.Map(func(v string) attr.Value { + return types.StringValue(v) + }, *action.Labels)) + } + return +} + +func (m IssueAlertActionGitHubCreateTicketModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + + body := apiclient.ProjectRuleActionGitHubCreateTicket{ + Name: m.Name.ValueStringPointer(), + Integration: m.Integration.ValueString(), + Repo: m.Repo.ValueString(), + Assignee: m.Assignee.ValueStringPointer(), + DynamicFormFields: []map[string]interface{}{{"dummy": "dummy"}}, + } + + if !m.Labels.IsNull() { + var labels []string + diags.Append(m.Labels.ElementsAs(ctx, &labels, false)...) + if diags.HasError() { + return nil, diags + } + + body.Labels = &labels + } + + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionGitHubCreateTicket(body) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionGitHubEnterpriseCreateTicketModel struct { + Name types.String `tfsdk:"name"` + Integration types.String `tfsdk:"integration"` + Repo types.String `tfsdk:"repo"` + Assignee types.String `tfsdk:"assignee"` + Labels types.Set `tfsdk:"labels"` +} + +func (m *IssueAlertActionGitHubEnterpriseCreateTicketModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionGitHubEnterpriseCreateTicket) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Integration = types.StringValue(action.Integration) + m.Repo = types.StringValue(action.Repo) + m.Assignee = types.StringPointerValue(action.Assignee) + + if action.Labels == nil { + m.Labels = types.SetNull(types.StringType) + } else { + m.Labels = types.SetValueMust(types.StringType, sliceutils.Map(func(v string) attr.Value { + return types.StringValue(v) + }, *action.Labels)) + } + return +} + +func (m IssueAlertActionGitHubEnterpriseCreateTicketModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + + body := apiclient.ProjectRuleActionGitHubEnterpriseCreateTicket{ + Name: m.Name.ValueStringPointer(), + Integration: m.Integration.ValueString(), + Repo: m.Repo.ValueString(), + Assignee: m.Assignee.ValueStringPointer(), + DynamicFormFields: []map[string]interface{}{{"dummy": "dummy"}}, + } + + if !m.Labels.IsNull() { + var labels []string + diags.Append(m.Labels.ElementsAs(ctx, &labels, false)...) + if diags.HasError() { + return nil, diags + } + + body.Labels = &labels + } + + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionGitHubEnterpriseCreateTicket(body) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionAzureDevopsCreateTicketModel struct { + Name types.String `tfsdk:"name"` + Integration types.String `tfsdk:"integration"` + Project types.String `tfsdk:"project"` + WorkItemType types.String `tfsdk:"work_item_type"` +} + +func (m *IssueAlertActionAzureDevopsCreateTicketModel) Fill(ctx context.Context, action apiclient.ProjectRuleActionAzureDevopsCreateTicket) (diags diag.Diagnostics) { + m.Name = types.StringPointerValue(action.Name) + m.Integration = types.StringValue(action.Integration) + m.Project = types.StringValue(action.Project) + m.WorkItemType = types.StringValue(action.WorkItemType) + return +} + +func (m IssueAlertActionAzureDevopsCreateTicketModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + var diags diag.Diagnostics + var v apiclient.ProjectRuleAction + err := v.FromProjectRuleActionAzureDevopsCreateTicket(apiclient.ProjectRuleActionAzureDevopsCreateTicket{ + Name: m.Name.ValueStringPointer(), + Integration: m.Integration.ValueString(), + Project: m.Project.ValueString(), + WorkItemType: m.WorkItemType.ValueString(), + DynamicFormFields: []map[string]interface{}{{"dummy": "dummy"}}, + }) + if err != nil { + diags.AddError("Failed to convert to API model", err.Error()) + return nil, diags + } + return &v, diags +} + +type IssueAlertActionModel struct { + NotifyEmail *IssueAlertActionNotifyEmailModel `tfsdk:"notify_email"` + NotifyEvent *IssueAlertActionNotifyEventModel `tfsdk:"notify_event"` + NotifyEventService *IssueAlertActionNotifyEventServiceModel `tfsdk:"notify_event_service"` + NotifyEventSentryApp *IssueAlertActionNotifyEventSentryAppModel `tfsdk:"notify_event_sentry_app"` + OpsgenieNotifyTeam *IssueAlertActionOpsgenieNotifyTeam `tfsdk:"opsgenie_notify_team"` + PagerDutyNotifyService *IssueAlertActionPagerDutyNotifyServiceModel `tfsdk:"pagerduty_notify_service"` + SlackNotifyService *IssueAlertActionSlackNotifyServiceModel `tfsdk:"slack_notify_service"` + MsTeamsNotifyService *IssueAlertActionMsTeamsNotifyServiceModel `tfsdk:"msteams_notify_service"` + DiscordNotifyService *IssueAlertActionDiscordNotifyServiceModel `tfsdk:"discord_notify_service"` + JiraCreateTicket *IssueAlertActionJiraCreateTicketModel `tfsdk:"jira_create_ticket"` + JiraServerCreateTicket *IssueAlertActionJiraServerCreateTicketModel `tfsdk:"jira_server_create_ticket"` + GitHubCreateTicket *IssueAlertActionGitHubCreateTicketModel `tfsdk:"github_create_ticket"` + GitHubEnterpriseCreateTicket *IssueAlertActionGitHubEnterpriseCreateTicketModel `tfsdk:"github_enterprise_create_ticket"` + AzureDevopsCreateTicket *IssueAlertActionAzureDevopsCreateTicketModel `tfsdk:"azure_devops_create_ticket"` +} + +func (m IssueAlertActionModel) ToApi(ctx context.Context) (*apiclient.ProjectRuleAction, diag.Diagnostics) { + if m.NotifyEmail != nil { + return m.NotifyEmail.ToApi(ctx) + } else if m.NotifyEvent != nil { + return m.NotifyEvent.ToApi(ctx) + } else if m.NotifyEventService != nil { + return m.NotifyEventService.ToApi(ctx) + } else if m.NotifyEventSentryApp != nil { + return m.NotifyEventSentryApp.ToApi(ctx) + } else if m.OpsgenieNotifyTeam != nil { + return m.OpsgenieNotifyTeam.ToApi(ctx) + } else if m.PagerDutyNotifyService != nil { + return m.PagerDutyNotifyService.ToApi(ctx) + } else if m.SlackNotifyService != nil { + return m.SlackNotifyService.ToApi(ctx) + } else if m.MsTeamsNotifyService != nil { + return m.MsTeamsNotifyService.ToApi(ctx) + } else if m.DiscordNotifyService != nil { + return m.DiscordNotifyService.ToApi(ctx) + } else if m.JiraCreateTicket != nil { + return m.JiraCreateTicket.ToApi(ctx) + } else if m.JiraServerCreateTicket != nil { + return m.JiraServerCreateTicket.ToApi(ctx) + } else if m.GitHubCreateTicket != nil { + return m.GitHubCreateTicket.ToApi(ctx) + } else if m.GitHubEnterpriseCreateTicket != nil { + return m.GitHubEnterpriseCreateTicket.ToApi(ctx) + } else if m.AzureDevopsCreateTicket != nil { + return m.AzureDevopsCreateTicket.ToApi(ctx) + } else { + var diags diag.Diagnostics + diags.AddError("Exactly one action must be set", "Exactly one action must be set") + return nil, diags + } +} + +func (m *IssueAlertActionModel) Fill(ctx context.Context, action apiclient.ProjectRuleAction) (diags diag.Diagnostics) { + actionValue, err := action.ValueByDiscriminator() + if err != nil { + diags.AddError("Invalid action", err.Error()) + return + } + + m.NotifyEmail = nil + m.NotifyEvent = nil + m.NotifyEventService = nil + m.NotifyEventSentryApp = nil + m.OpsgenieNotifyTeam = nil + m.PagerDutyNotifyService = nil + m.SlackNotifyService = nil + m.MsTeamsNotifyService = nil + m.DiscordNotifyService = nil + m.JiraCreateTicket = nil + m.JiraServerCreateTicket = nil + m.GitHubCreateTicket = nil + m.GitHubEnterpriseCreateTicket = nil + m.AzureDevopsCreateTicket = nil + + switch actionValue := actionValue.(type) { + case apiclient.ProjectRuleActionNotifyEmail: + m.NotifyEmail = &IssueAlertActionNotifyEmailModel{} + diags.Append(m.NotifyEmail.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionNotifyEvent: + m.NotifyEvent = &IssueAlertActionNotifyEventModel{} + diags.Append(m.NotifyEvent.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionNotifyEventService: + m.NotifyEventService = &IssueAlertActionNotifyEventServiceModel{} + diags.Append(m.NotifyEventService.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionNotifyEventSentryApp: + m.NotifyEventSentryApp = &IssueAlertActionNotifyEventSentryAppModel{} + diags.Append(m.NotifyEventSentryApp.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionOpsgenieNotifyTeam: + m.OpsgenieNotifyTeam = &IssueAlertActionOpsgenieNotifyTeam{} + diags.Append(m.OpsgenieNotifyTeam.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionPagerDutyNotifyService: + m.PagerDutyNotifyService = &IssueAlertActionPagerDutyNotifyServiceModel{} + diags.Append(m.PagerDutyNotifyService.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionSlackNotifyService: + m.SlackNotifyService = &IssueAlertActionSlackNotifyServiceModel{} + diags.Append(m.SlackNotifyService.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionMsTeamsNotifyService: + m.MsTeamsNotifyService = &IssueAlertActionMsTeamsNotifyServiceModel{} + diags.Append(m.MsTeamsNotifyService.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionDiscordNotifyService: + m.DiscordNotifyService = &IssueAlertActionDiscordNotifyServiceModel{} + diags.Append(m.DiscordNotifyService.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionJiraCreateTicket: + m.JiraCreateTicket = &IssueAlertActionJiraCreateTicketModel{} + diags.Append(m.JiraCreateTicket.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionJiraServerCreateTicket: + m.JiraServerCreateTicket = &IssueAlertActionJiraServerCreateTicketModel{} + diags.Append(m.JiraServerCreateTicket.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionGitHubCreateTicket: + m.GitHubCreateTicket = &IssueAlertActionGitHubCreateTicketModel{} + diags.Append(m.GitHubCreateTicket.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionGitHubEnterpriseCreateTicket: + m.GitHubEnterpriseCreateTicket = &IssueAlertActionGitHubEnterpriseCreateTicketModel{} + diags.Append(m.GitHubEnterpriseCreateTicket.Fill(ctx, actionValue)...) + case apiclient.ProjectRuleActionAzureDevopsCreateTicket: + m.AzureDevopsCreateTicket = &IssueAlertActionAzureDevopsCreateTicketModel{} + diags.Append(m.AzureDevopsCreateTicket.Fill(ctx, actionValue)...) + default: + diags.AddError("Unsupported action", fmt.Sprintf("Unsupported action type %T", actionValue)) + } + + return +} + +// Model + +type IssueAlertModel struct { + Id types.String `tfsdk:"id"` + Organization types.String `tfsdk:"organization"` + Project types.String `tfsdk:"project"` + Name types.String `tfsdk:"name"` + Conditions sentrytypes.LossyJson `tfsdk:"conditions"` + Filters sentrytypes.LossyJson `tfsdk:"filters"` + Actions sentrytypes.LossyJson `tfsdk:"actions"` + ActionMatch types.String `tfsdk:"action_match"` + FilterMatch types.String `tfsdk:"filter_match"` + Frequency types.Int64 `tfsdk:"frequency"` + Environment types.String `tfsdk:"environment"` + Owner types.String `tfsdk:"owner"` + ConditionsV2 *[]IssueAlertConditionModel `tfsdk:"conditions_v2"` + FiltersV2 *[]IssueAlertFilterModel `tfsdk:"filters_v2"` + ActionsV2 *[]IssueAlertActionModel `tfsdk:"actions_v2"` +} + +func (m *IssueAlertModel) Fill(ctx context.Context, alert apiclient.ProjectRule) (diags diag.Diagnostics) { + m.Id = types.StringValue(alert.Id) + + if len(alert.Projects) != 1 { + diags.AddError("Invalid project count", fmt.Sprintf("Expected 1 project, got %d", len(alert.Projects))) + return + } + m.Project = types.StringValue(alert.Projects[0]) + m.Name = types.StringValue(alert.Name) + m.ActionMatch = types.StringValue(alert.ActionMatch) + m.FilterMatch = types.StringValue(alert.FilterMatch) + m.Frequency = types.Int64Value(alert.Frequency) + m.Environment = types.StringPointerValue(alert.Environment) + m.Owner = types.StringPointerValue(alert.Owner) + + if !m.Conditions.IsNull() { + if conditions, err := json.Marshal(alert.Conditions); err == nil { + m.Conditions = sentrytypes.NewLossyJsonValue(string(conditions)) + } else { + diags.AddError("Invalid conditions", err.Error()) + return + } + } else if m.ConditionsV2 != nil { + m.ConditionsV2 = ptr.Ptr(sliceutils.Map(func(condition apiclient.ProjectRuleCondition) IssueAlertConditionModel { + var conditionModel IssueAlertConditionModel + diags.Append(conditionModel.Fill(ctx, condition)...) + return conditionModel + }, alert.Conditions)) + + if diags.HasError() { + return + } + } + + if !m.Filters.IsNull() { + if filters, err := json.Marshal(alert.Filters); err == nil { + m.Filters = sentrytypes.NewLossyJsonValue(string(filters)) + } else { + diags.AddError("Invalid filters", err.Error()) + } + } else if m.FiltersV2 != nil { + m.FiltersV2 = ptr.Ptr(sliceutils.Map(func(filter apiclient.ProjectRuleFilter) IssueAlertFilterModel { + var filterModel IssueAlertFilterModel + diags.Append(filterModel.Fill(ctx, filter)...) + return filterModel + }, alert.Filters)) + + if diags.HasError() { + return + } + } + + if !m.Actions.IsNull() { + if actions, err := json.Marshal(alert.Actions); err == nil && len(actions) > 0 { + m.Actions = sentrytypes.NewLossyJsonValue(string(actions)) + } else { + diags.AddError("Invalid actions", err.Error()) + } + } else if m.ActionsV2 != nil { + m.ActionsV2 = ptr.Ptr(sliceutils.Map(func(action apiclient.ProjectRuleAction) IssueAlertActionModel { + var actionModel IssueAlertActionModel + diags.Append(actionModel.Fill(ctx, action)...) + return actionModel + }, alert.Actions)) + + if diags.HasError() { + return + } + } + + return +} diff --git a/internal/provider/resource_client_key.go b/internal/provider/resource_client_key.go index b7fba89ba..369c2e7f7 100644 --- a/internal/provider/resource_client_key.go +++ b/internal/provider/resource_client_key.go @@ -80,10 +80,7 @@ func (m *ClientKeyResourceModel) Fill(ctx context.Context, key apiclient.Project var javascriptLoaderScript ClientKeyJavascriptLoaderScriptResourceModel diags.Append(javascriptLoaderScript.Fill(ctx, key)...) - - var javascriptLoaderScriptDiags diag.Diagnostics - m.JavascriptLoaderScript, javascriptLoaderScriptDiags = types.ObjectValueFrom(ctx, javascriptLoaderScript.AttributeTypes(), javascriptLoaderScript) - diags.Append(javascriptLoaderScriptDiags...) + m.JavascriptLoaderScript = tfutils.MergeDiagnostics(types.ObjectValueFrom(ctx, javascriptLoaderScript.AttributeTypes(), javascriptLoaderScript))(&diags) m.Public = types.StringValue(key.Public) m.Secret = types.StringValue(key.Secret) diff --git a/internal/provider/resource_issue_alert.go b/internal/provider/resource_issue_alert.go index 861aeec80..447908b5c 100644 --- a/internal/provider/resource_issue_alert.go +++ b/internal/provider/resource_issue_alert.go @@ -6,82 +6,25 @@ import ( "fmt" "net/http" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/jianyuan/go-sentry/v2/sentry" "github.com/jianyuan/go-utils/must" + "github.com/jianyuan/terraform-provider-sentry/internal/apiclient" "github.com/jianyuan/terraform-provider-sentry/internal/diagutils" + "github.com/jianyuan/terraform-provider-sentry/internal/sentrydata" "github.com/jianyuan/terraform-provider-sentry/internal/sentrytypes" + "github.com/jianyuan/terraform-provider-sentry/internal/tfutils" ) -type IssueAlertResourceModel struct { - Id types.String `tfsdk:"id"` - Organization types.String `tfsdk:"organization"` - Project types.String `tfsdk:"project"` - Name types.String `tfsdk:"name"` - Conditions sentrytypes.LossyJson `tfsdk:"conditions"` - Filters sentrytypes.LossyJson `tfsdk:"filters"` - Actions sentrytypes.LossyJson `tfsdk:"actions"` - ActionMatch types.String `tfsdk:"action_match"` - FilterMatch types.String `tfsdk:"filter_match"` - Frequency types.Int64 `tfsdk:"frequency"` - Environment types.String `tfsdk:"environment"` - Owner types.String `tfsdk:"owner"` -} - -func (m *IssueAlertResourceModel) Fill(organization string, alert sentry.IssueAlert) error { - m.Id = types.StringPointerValue(alert.ID) - m.Organization = types.StringValue(organization) - m.Project = types.StringValue(alert.Projects[0]) - m.Name = types.StringPointerValue(alert.Name) - m.ActionMatch = types.StringPointerValue(alert.ActionMatch) - m.FilterMatch = types.StringPointerValue(alert.FilterMatch) - m.Owner = types.StringPointerValue(alert.Owner) - - m.Conditions = sentrytypes.NewLossyJsonValue("[]") - if len(alert.Conditions) > 0 { - if conditions, err := json.Marshal(alert.Conditions); err == nil { - m.Conditions = sentrytypes.NewLossyJsonValue(string(conditions)) - } else { - return err - } - } - - m.Filters = sentrytypes.NewLossyJsonNull() - if len(alert.Filters) > 0 { - if filters, err := json.Marshal(alert.Filters); err == nil { - m.Filters = sentrytypes.NewLossyJsonValue(string(filters)) - } else { - return err - } - } - - m.Actions = sentrytypes.NewLossyJsonNull() - if len(alert.Actions) > 0 { - if actions, err := json.Marshal(alert.Actions); err == nil && len(actions) > 0 { - m.Actions = sentrytypes.NewLossyJsonValue(string(actions)) - } else { - return err - } - } - - frequency, err := alert.Frequency.Int64() - if err != nil { - return err - } - m.Frequency = types.Int64Value(frequency) - - m.Environment = types.StringPointerValue(alert.Environment) - m.Owner = types.StringPointerValue(alert.Owner) - - return nil -} - var _ resource.Resource = &IssueAlertResource{} +var _ resource.ResourceWithConfigValidators = &IssueAlertResource{} +var _ resource.ResourceWithValidateConfig = &IssueAlertResource{} var _ resource.ResourceWithConfigure = &IssueAlertResource{} var _ resource.ResourceWithImportState = &IssueAlertResource{} var _ resource.ResourceWithUpgradeState = &IssueAlertResource{} @@ -99,14 +42,24 @@ func (r *IssueAlertResource) Metadata(ctx context.Context, req resource.Metadata } func (r *IssueAlertResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: `Create an Issue Alert Rule for a Project. See the [Sentry Documentation](https://docs.sentry.io/api/alerts/create-an-issue-alert-rule-for-a-project/) for more information. + nameStringAttribute := schema.StringAttribute{ + Computed: true, + } + intervalStringAttribute := tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "`m` for minutes, `h` for hours, `d` for days, and `w` for weeks.", + Optional: true, + }, []string{"1m", "5m", "15m", "1h", "1d", "1w", "30d"}) + conditionComparisonTypeStringAttribute := tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"count", "percent"}) + conditionComparisonIntervalStringAttribute := tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "`m` for minutes, `h` for hours, `d` for days, and `w` for weeks.", + Optional: true, + }, []string{"5m", "15m", "1h", "1d", "1w", "30d"}) -Please note the following changes since v0.12.0: -- The attributes ` + "`conditions`" + `, ` + "`filters`" + `, and ` + "`actions`" + ` are in JSON string format. The types must match the Sentry API, otherwise Terraform will incorrectly detect a drift. Use ` + "`parseint(\"string\", 10)`" + ` to convert a string to an integer. Avoid using ` + "`jsonencode()`" + ` as it is unable to distinguish between an integer and a float. -- The attribute ` + "`internal_id`" + ` has been removed. Use ` + "`id`" + ` instead. -- The attribute ` + "`id`" + ` is now the ID of the issue alert. Previously, it was a combination of the organization, project, and issue alert ID. - `, + resp.Schema = schema.Schema{ + MarkdownDescription: "Create an Issue Alert Rule for a Project. See the [Sentry Documentation](https://docs.sentry.io/api/alerts/create-an-issue-alert-rule-for-a-project/) for more information.\n\n" + + "**NOTE:** Since v0.15.0, the `conditions`, `filters`, and `actions` attributes which are JSON strings have been deprecated in favor of `conditions_v2`, `filters_v2`, and `actions_v2` which are lists of objects.", Version: 2, @@ -122,36 +75,536 @@ Please note the following changes since v0.12.0: }, }, "conditions": schema.StringAttribute{ - MarkdownDescription: "List of conditions. In JSON string format.", - Required: true, + MarkdownDescription: "**Deprecated** in favor of `conditions_v2`. A list of triggers that determine when the rule fires. In JSON string format.", + DeprecationMessage: "Use `conditions_v2` instead.", + Optional: true, CustomType: sentrytypes.LossyJsonType{ IgnoreKeys: []string{"name"}, }, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("conditions_v2")), + }, + }, + "conditions_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of triggers that determine when the rule fires.", + Optional: true, + Validators: []validator.List{ + listvalidator.ConflictsWith(path.MatchRoot("conditions")), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: tfutils.WithMutuallyExclusiveValidator(map[string]schema.SingleNestedAttribute{ + "first_seen_event": { + MarkdownDescription: "A new issue is created.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "regression_event": { + MarkdownDescription: "The issue changes state from resolved to unresolved.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "reappeared_event": { + MarkdownDescription: "The issue changes state from ignored to unresolved.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "new_high_priority_issue": { + MarkdownDescription: "Sentry marks a new issue as high priority.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "existing_high_priority_issue": { + MarkdownDescription: "Sentry marks an existing issue as high priority.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "event_frequency": { + MarkdownDescription: "When the `comparison_type` is `count`, the number of events in an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of events in an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "comparison_type": conditionComparisonTypeStringAttribute, + "comparison_interval": conditionComparisonIntervalStringAttribute, + "value": schema.Int64Attribute{ + Required: true, + }, + "interval": intervalStringAttribute, + }, + }, + "event_unique_user_frequency": { + MarkdownDescription: "When the `comparison_type` is `count`, the number of users affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the number of users affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "comparison_type": conditionComparisonTypeStringAttribute, + "comparison_interval": conditionComparisonIntervalStringAttribute, + "value": schema.Int64Attribute{ + Required: true, + }, + "interval": intervalStringAttribute, + }, + }, + "event_frequency_percent": { + MarkdownDescription: "When the `comparison_type` is `count`, the percent of sessions affected by an issue is more than `value` in `interval`. When the `comparison_type` is `percent`, the percent of sessions affected by an issue is `value` % higher in `interval` compared to `comparison_interval` ago.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "comparison_type": conditionComparisonTypeStringAttribute, + "comparison_interval": conditionComparisonIntervalStringAttribute, + "value": schema.Float64Attribute{ + Required: true, + }, + "interval": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "`m` for minutes, `h` for hours.", + Required: true, + }, []string{"5m", "10m", "30m", "1h"}), + }, + }, + }), + }, }, "filters": schema.StringAttribute{ - MarkdownDescription: "A list of filters that determine if a rule fires after the necessary conditions have been met. In JSON string format.", + MarkdownDescription: "**Deprecated** in favor of `filters_v2`. A list of filters that determine if a rule fires after the necessary conditions have been met. In JSON string format.", + DeprecationMessage: "Use `filters_v2` instead.", Optional: true, CustomType: sentrytypes.LossyJsonType{}, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("filters_v2")), + }, + }, + "filters_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of filters that determine if a rule fires after the necessary conditions have been met.", + Optional: true, + Validators: []validator.List{ + listvalidator.ConflictsWith(path.MatchRoot("filters")), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: tfutils.WithMutuallyExclusiveValidator(map[string]schema.SingleNestedAttribute{ + "age_comparison": { + MarkdownDescription: "The issue is older or newer than `value` `time`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "comparison_type": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"older", "newer"}), + "value": schema.Int64Attribute{ + Required: true, + }, + "time": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"minute", "hour", "day", "week"}), + }, + }, + "issue_occurrences": { + MarkdownDescription: "The issue has happened at least `value` times (Note: this is approximate).", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "value": schema.Int64Attribute{ + Required: true, + }, + }, + }, + "assigned_to": { + MarkdownDescription: "The issue is assigned to no one, team, or member.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "target_type": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"Unassigned", "Team", "Member"}), + "target_identifier": schema.StringAttribute{ + MarkdownDescription: "The target's ID. Only required when `target_type` is `Team` or `Member`.", + Optional: true, + }, + }, + }, + "latest_adopted_release": { + MarkdownDescription: "The {oldest_or_newest} adopted release associated with the event's issue is {older_or_newer} than the latest adopted release in {environment}.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "oldest_or_newest": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"oldest", "newest"}), + "older_or_newer": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"older", "newer"}), + "environment": schema.StringAttribute{ + Required: true, + }, + }, + }, + "latest_release": { + MarkdownDescription: "The event is from the latest release.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "issue_category": { + MarkdownDescription: "The issue's category is equal to `value`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "value": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, sentrydata.IssueGroupCategories), + }, + }, + "event_attribute": { + MarkdownDescription: "The event's `attribute` value `match` `value`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "attribute": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, sentrydata.EventAttributes), + "match": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "The comparison operator.", + Required: true, + }, sentrydata.MatchTypes), + "value": schema.StringAttribute{ + Optional: true, + }, + }, + }, + "tagged_event": { + MarkdownDescription: "The event's tags match `key` `match` `value`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "key": schema.StringAttribute{ + MarkdownDescription: "The tag.", + Required: true, + }, + "match": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "The comparison operator.", + Required: true, + }, sentrydata.MatchTypes), + "value": schema.StringAttribute{ + Optional: true, + }, + }, + }, + "level": { + MarkdownDescription: "The event's level is `match` `level`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "match": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "The comparison operator.", + Required: true, + }, sentrydata.LevelMatchTypes), + "level": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, sentrydata.LogLevels), + }, + }, + }), + }, }, "actions": schema.StringAttribute{ - MarkdownDescription: "List of actions. In JSON string format.", - Required: true, + MarkdownDescription: "**Deprecated** in favor of `actions_v2`. A list of actions that take place when all required conditions and filters for the rule are met. In JSON string format.", + DeprecationMessage: "Use `actions_v2` instead.", + Optional: true, CustomType: sentrytypes.LossyJsonType{}, - }, - "action_match": schema.StringAttribute{ - MarkdownDescription: "Trigger actions when an event is captured by Sentry and `any` or `all` of the specified conditions happen.", - Required: true, Validators: []validator.String{ - stringvalidator.OneOf("all", "any"), + stringvalidator.ConflictsWith(path.MatchRoot("actions_v2")), }, }, - "filter_match": schema.StringAttribute{ - MarkdownDescription: "A string determining which filters need to be true before any actions take place. Required when a value is provided for `filters`.", + "actions_v2": schema.ListNestedAttribute{ + MarkdownDescription: "A list of actions that take place when all required conditions and filters for the rule are met.", Optional: true, - Validators: []validator.String{ - stringvalidator.OneOf("all", "any", "none"), + Validators: []validator.List{ + listvalidator.ConflictsWith(path.MatchRoot("actions")), + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: tfutils.WithMutuallyExclusiveValidator(map[string]schema.SingleNestedAttribute{ + "notify_email": { + MarkdownDescription: "Send a notification to `target_type` and if none can be found then send a notification to `fallthrough_type`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "target_type": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + Required: true, + }, []string{"IssueOwners", "Team", "Member"}), + "target_identifier": schema.StringAttribute{ + MarkdownDescription: "The ID of the Member or Team the notification should be sent to. Only required when `target_type` is `Team` or `Member`.", + Optional: true, + }, + "fallthrough_type": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "Who the notification should be sent to if there are no suggested assignees.", + Optional: true, + }, []string{"AllMembers", "ActiveMembers", "NoOne"}), + }, + }, + "notify_event": { + MarkdownDescription: "Send a notification to all legacy integrations.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + }, + }, + "notify_event_service": { + MarkdownDescription: "Send a notification via an integration.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "service": schema.StringAttribute{ + MarkdownDescription: "The slug of the integration service. Sourced from `https://terraform-provider-sentry.sentry.io/settings/developer-settings//`.", + Required: true, + }, + }, + }, + "notify_event_sentry_app": { + MarkdownDescription: "Send a notification to a Sentry app.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "sentry_app_installation_uuid": schema.StringAttribute{ + Required: true, + }, + "settings": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + }, + }, + "opsgenie_notify_team": { + MarkdownDescription: "Send a notification to Opsgenie account `account` and team `team` with `priority` priority.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "account": schema.StringAttribute{ + Required: true, + }, + "team": schema.StringAttribute{ + Required: true, + }, + "priority": schema.StringAttribute{ + Required: true, + }, + }, + }, + "pagerduty_notify_service": { + MarkdownDescription: "Send a notification to PagerDuty account `account` and service `service` with `severity` severity.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "account": schema.StringAttribute{ + Required: true, + }, + "service": schema.StringAttribute{ + Required: true, + }, + "severity": schema.StringAttribute{ + Required: true, + }, + }, + }, + "slack_notify_service": { + MarkdownDescription: "Send a notification to the `workspace` Slack workspace to `channel` (optionally, an ID: `channel_id`) and show tags `tags` and notes `notes` in notification.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "workspace": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with the Slack workspace.", + Required: true, + }, + "channel": schema.StringAttribute{ + MarkdownDescription: "The name of the channel to send the notification to (e.g., #critical, Jane Schmidt).", + Required: true, + }, + "channel_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the channel to send the notification to.", + Computed: true, + }, + "tags": schema.SetAttribute{ + MarkdownDescription: "A string of tags to show in the notification.", + Optional: true, + CustomType: sentrytypes.StringSetType{ + SetType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + "notes": schema.StringAttribute{ + MarkdownDescription: "Text to show alongside the notification. To @ a user, include their user id like `@`. To include a clickable link, format the link and title like ``.", + Optional: true, + }, + }, + }, + "msteams_notify_service": { + MarkdownDescription: "Send a notification to the `team` Team to `channel`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "team": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with the Microsoft Teams team.", + Required: true, + }, + "channel": schema.StringAttribute{ + MarkdownDescription: "The name of the channel to send the notification to.", + Required: true, + }, + "channel_id": schema.StringAttribute{ + Computed: true, + }, + }, + }, + "discord_notify_service": { + MarkdownDescription: "Send a notification to the `server` Discord server in the channel with ID or URL: `channel_id` and show tags `tags` in the notification.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "server": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with the Discord server.", + Required: true, + }, + "channel_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the channel to send the notification to. You must enter either a channel ID or a channel URL, not a channel name", + Required: true, + }, + "tags": schema.SetAttribute{ + MarkdownDescription: "A string of tags to show in the notification.", + Optional: true, + CustomType: sentrytypes.StringSetType{ + SetType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + }, + "jira_create_ticket": { + MarkdownDescription: "Create a Jira issue in `integration`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "integration": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with Jira.", + Required: true, + }, + "project": schema.StringAttribute{ + MarkdownDescription: "The ID of the Jira project.", + Required: true, + }, + "issue_type": schema.StringAttribute{ + MarkdownDescription: "The ID of the type of issue that the ticket should be created as.", + Required: true, + }, + }, + }, + "jira_server_create_ticket": { + MarkdownDescription: "Create a Jira Server issue in `integration`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "integration": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with Jira Server.", + Required: true, + }, + "project": schema.StringAttribute{ + MarkdownDescription: "The ID of the Jira Server project.", + Required: true, + }, + "issue_type": schema.StringAttribute{ + MarkdownDescription: "The ID of the type of issue that the ticket should be created as.", + Required: true, + }, + }, + }, + "github_create_ticket": { + MarkdownDescription: "Create a GitHub issue in `integration`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "integration": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with GitHub.", + Required: true, + }, + "repo": schema.StringAttribute{ + MarkdownDescription: "The name of the repository to create the issue in.", + Required: true, + }, + "assignee": schema.StringAttribute{ + MarkdownDescription: "The GitHub user to assign the issue to.", + Optional: true, + }, + "labels": schema.SetAttribute{ + MarkdownDescription: "A list of labels to assign to the issue.", + Optional: true, + ElementType: types.StringType, + }, + }, + }, + "github_enterprise_create_ticket": { + MarkdownDescription: "Create a GitHub Enterprise issue in `integration`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "integration": schema.StringAttribute{ + MarkdownDescription: "The integration ID associated with GitHub Enterprise.", + Required: true, + }, + "repo": schema.StringAttribute{ + MarkdownDescription: "The name of the repository to create the issue in.", + Required: true, + }, + "assignee": schema.StringAttribute{ + MarkdownDescription: "The GitHub user to assign the issue to.", + Optional: true, + }, + "labels": schema.SetAttribute{ + MarkdownDescription: "A list of labels to assign to the issue.", + Optional: true, + ElementType: types.StringType, + }, + }, + }, + "azure_devops_create_ticket": { + MarkdownDescription: "Create an Azure DevOps work item in `integration`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": nameStringAttribute, + "integration": schema.StringAttribute{ + MarkdownDescription: "The integration ID.", + Required: true, + }, + "project": schema.StringAttribute{ + MarkdownDescription: "The ID of the Azure DevOps project.", + Required: true, + }, + "work_item_type": schema.StringAttribute{ + MarkdownDescription: "The type of work item to create.", + Required: true, + }, + }, + }, + }), }, }, + "action_match": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "Trigger actions when an event is captured by Sentry and `any` or `all` of the specified conditions happen.", + Required: true, + }, []string{"all", "any"}), + "filter_match": tfutils.WithEnumStringAttribute(schema.StringAttribute{ + MarkdownDescription: "A string determining which filters need to be true before any actions take place. Required when a value is provided for `filters`.", + Optional: true, + }, []string{"all", "any", "none"}), "frequency": schema.Int64Attribute{ MarkdownDescription: "Perform actions at most once every `X` minutes for this issue.", Required: true, @@ -168,53 +621,173 @@ Please note the following changes since v0.12.0: } } +func (r *IssueAlertResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("actions"), + path.MatchRoot("actions_v2"), + ), + } +} + +func (r *IssueAlertResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data IssueAlertModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if data.ConditionsV2 != nil { + for i, item := range *data.ConditionsV2 { + if _, diags := item.ToApi(ctx); diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("conditions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert condition: %s", diags), + ) + } + } + } + + if data.FiltersV2 != nil { + for i, item := range *data.FiltersV2 { + if _, diags := item.ToApi(ctx); diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("filters_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert filter: %s", diags), + ) + } + } + } + + if !data.Actions.IsNull() { + if ok, _ := data.Actions.StringSemanticEquals(ctx, sentrytypes.NewLossyJsonValue(`[]`)); ok { + resp.Diagnostics.AddAttributeError( + path.Root("actions"), + "Missing attribute configuration", + "You must add an action for this alert to fire", + ) + } + } else if data.ActionsV2 != nil { + if len(*data.ActionsV2) == 0 { + resp.Diagnostics.AddAttributeError( + path.Root("actions_v2"), + "Missing attribute configuration", + "You must add an action for this alert to fire", + ) + } + + for i, item := range *data.ActionsV2 { + if _, diags := item.ToApi(ctx); diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("actions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert action: %s", diags), + ) + } + } + } +} + func (r *IssueAlertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data IssueAlertResourceModel + var data IssueAlertModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - params := &sentry.IssueAlert{ - Name: data.Name.ValueStringPointer(), - ActionMatch: data.ActionMatch.ValueStringPointer(), - FilterMatch: data.FilterMatch.ValueStringPointer(), - Frequency: sentry.JsonNumber(json.Number(data.Frequency.String())), + body := apiclient.CreateProjectRuleJSONRequestBody{ + Name: data.Name.ValueString(), + ActionMatch: data.ActionMatch.ValueString(), + FilterMatch: data.FilterMatch.ValueString(), + Frequency: data.Frequency.ValueInt64(), Owner: data.Owner.ValueStringPointer(), Environment: data.Environment.ValueStringPointer(), - Projects: []string{data.Project.String()}, + Projects: []string{data.Project.ValueString()}, } + if !data.Conditions.IsNull() { - resp.Diagnostics.Append(data.Conditions.Unmarshal(¶ms.Conditions)...) + resp.Diagnostics.Append(data.Conditions.Unmarshal(&body.Conditions)...) + } else if data.ConditionsV2 != nil { + body.Conditions = []apiclient.ProjectRuleCondition{} + for i, item := range *data.ConditionsV2 { + condition, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("conditions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert condition: %s", diags), + ) + return + } + body.Conditions = append(body.Conditions, *condition) + } + } else { + body.Conditions = []apiclient.ProjectRuleCondition{} } + if !data.Filters.IsNull() { - resp.Diagnostics.Append(data.Filters.Unmarshal(¶ms.Filters)...) + resp.Diagnostics.Append(data.Filters.Unmarshal(&body.Filters)...) + } else if data.FiltersV2 != nil { + body.Filters = []apiclient.ProjectRuleFilter{} + for i, item := range *data.FiltersV2 { + filter, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("filters_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert filter: %s", diags), + ) + return + } + body.Filters = append(body.Filters, *filter) + } + } else { + body.Filters = []apiclient.ProjectRuleFilter{} } + if !data.Actions.IsNull() { - resp.Diagnostics.Append(data.Actions.Unmarshal(¶ms.Actions)...) + resp.Diagnostics.Append(data.Actions.Unmarshal(&body.Actions)...) + } else if data.ActionsV2 != nil { + body.Actions = []apiclient.ProjectRuleAction{} + for i, item := range *data.ActionsV2 { + action, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("actions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert action: %s", diags), + ) + return + } + body.Actions = append(body.Actions, *action) + } + } else { + body.Actions = []apiclient.ProjectRuleAction{} } if resp.Diagnostics.HasError() { return } - action, _, err := r.client.IssueAlerts.Create( + httpResp, err := r.apiClient.CreateProjectRuleWithResponse( ctx, data.Organization.ValueString(), data.Project.ValueString(), - params, + body, ) if err != nil { resp.Diagnostics.Append(diagutils.NewClientError("create", err)) return - } - - if err := data.Fill(data.Organization.ValueString(), *action); err != nil { - resp.Diagnostics.Append(diagutils.NewFillError(err)) + } else if httpResp.StatusCode() != http.StatusOK || httpResp.JSON200 == nil { + resp.Diagnostics.Append(diagutils.NewClientStatusError("create", httpResp.StatusCode(), httpResp.Body)) return } + resp.Diagnostics.Append(data.Fill(ctx, *httpResp.JSON200)...) if resp.Diagnostics.HasError() { return } @@ -223,31 +796,33 @@ func (r *IssueAlertResource) Create(ctx context.Context, req resource.CreateRequ } func (r *IssueAlertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data IssueAlertResourceModel + var data IssueAlertModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - action, apiResp, err := r.client.IssueAlerts.Get( + httpResp, err := r.apiClient.GetProjectRuleWithResponse( ctx, data.Organization.ValueString(), data.Project.ValueString(), data.Id.ValueString(), ) - if apiResp.StatusCode == http.StatusNotFound { + if err != nil { + resp.Diagnostics.Append(diagutils.NewClientError("read", err)) + return + } else if httpResp.StatusCode() == http.StatusNotFound { resp.Diagnostics.Append(diagutils.NewNotFoundError("issue alert")) resp.State.RemoveResource(ctx) return - } - if err != nil { - resp.Diagnostics.Append(diagutils.NewClientError("read", err)) + } else if httpResp.StatusCode() != http.StatusOK || httpResp.JSON200 == nil { + resp.Diagnostics.Append(diagutils.NewClientStatusError("read", httpResp.StatusCode(), httpResp.Body)) return } - if err := data.Fill(data.Organization.ValueString(), *action); err != nil { - resp.Diagnostics.Append(diagutils.NewFillError(err)) + resp.Diagnostics.Append(data.Fill(ctx, *httpResp.JSON200)...) + if resp.Diagnostics.HasError() { return } @@ -255,55 +830,108 @@ func (r *IssueAlertResource) Read(ctx context.Context, req resource.ReadRequest, } func (r *IssueAlertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data IssueAlertResourceModel + var data IssueAlertModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - params := &sentry.IssueAlert{ - Name: data.Name.ValueStringPointer(), - ActionMatch: data.ActionMatch.ValueStringPointer(), - FilterMatch: data.FilterMatch.ValueStringPointer(), - Frequency: sentry.JsonNumber(json.Number(data.Frequency.String())), + body := apiclient.UpdateProjectRuleJSONRequestBody{ + Name: data.Name.ValueString(), + ActionMatch: data.ActionMatch.ValueString(), + FilterMatch: data.FilterMatch.ValueString(), + Frequency: data.Frequency.ValueInt64(), Owner: data.Owner.ValueStringPointer(), Environment: data.Environment.ValueStringPointer(), - Projects: []string{data.Project.String()}, + Projects: []string{data.Project.ValueString()}, } + if !data.Conditions.IsNull() { - resp.Diagnostics.Append(data.Conditions.Unmarshal(¶ms.Conditions)...) + resp.Diagnostics.Append(data.Conditions.Unmarshal(&body.Conditions)...) + } else if data.ConditionsV2 != nil { + body.Conditions = []apiclient.ProjectRuleCondition{} + for i, item := range *data.ConditionsV2 { + condition, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("conditions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert condition: %s", diags), + ) + return + } + body.Conditions = append(body.Conditions, *condition) + } + } else { + body.Conditions = []apiclient.ProjectRuleCondition{} } + if !data.Filters.IsNull() { - resp.Diagnostics.Append(data.Filters.Unmarshal(¶ms.Filters)...) + resp.Diagnostics.Append(data.Filters.Unmarshal(&body.Filters)...) + } else if data.FiltersV2 != nil { + body.Filters = []apiclient.ProjectRuleFilter{} + for i, item := range *data.FiltersV2 { + filter, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("filters_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert filter: %s", diags), + ) + return + } + body.Filters = append(body.Filters, *filter) + } + } else { + body.Filters = []apiclient.ProjectRuleFilter{} } + if !data.Actions.IsNull() { - resp.Diagnostics.Append(data.Actions.Unmarshal(¶ms.Actions)...) + resp.Diagnostics.Append(data.Actions.Unmarshal(&body.Actions)...) + } else if data.ActionsV2 != nil { + body.Actions = []apiclient.ProjectRuleAction{} + for i, item := range *data.ActionsV2 { + action, diags := item.ToApi(ctx) + if diags.HasError() { + resp.Diagnostics.AddAttributeError( + path.Root("actions_v2").AtListIndex(i), + "Missing attribute configuration", + fmt.Sprintf("Failed to convert action: %s", diags), + ) + return + } + body.Actions = append(body.Actions, *action) + } + } else { + body.Actions = []apiclient.ProjectRuleAction{} } if resp.Diagnostics.HasError() { return } - action, apiResp, err := r.client.IssueAlerts.Update( + httpResp, err := r.apiClient.UpdateProjectRuleWithResponse( ctx, data.Organization.ValueString(), data.Project.ValueString(), data.Id.ValueString(), - params, + body, ) - if apiResp.StatusCode == http.StatusNotFound { + if err != nil { + resp.Diagnostics.Append(diagutils.NewClientError("update", err)) + return + } else if httpResp.StatusCode() == http.StatusNotFound { resp.Diagnostics.Append(diagutils.NewNotFoundError("issue alert")) resp.State.RemoveResource(ctx) return - } - if err != nil { - resp.Diagnostics.Append(diagutils.NewClientError("update", err)) + } else if httpResp.StatusCode() != http.StatusOK || httpResp.JSON200 == nil { + resp.Diagnostics.Append(diagutils.NewClientStatusError("update", httpResp.StatusCode(), httpResp.Body)) return } - if err := data.Fill(data.Organization.ValueString(), *action); err != nil { - resp.Diagnostics.Append(diagutils.NewFillError(err)) + resp.Diagnostics.Append(data.Fill(ctx, *httpResp.JSON200)...) + if resp.Diagnostics.HasError() { return } @@ -311,43 +939,32 @@ func (r *IssueAlertResource) Update(ctx context.Context, req resource.UpdateRequ } func (r *IssueAlertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data IssueAlertResourceModel + var data IssueAlertModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - apiResp, err := r.client.IssueAlerts.Delete( + httpResp, err := r.apiClient.DeleteProjectRuleWithResponse( ctx, data.Organization.ValueString(), data.Project.ValueString(), data.Id.ValueString(), ) - if apiResp.StatusCode == http.StatusNotFound { - return - } if err != nil { resp.Diagnostics.Append(diagutils.NewClientError("delete", err)) return + } else if httpResp.StatusCode() == http.StatusNotFound { + return + } else if httpResp.StatusCode() != http.StatusAccepted { + resp.Diagnostics.Append(diagutils.NewClientStatusError("delete", httpResp.StatusCode(), httpResp.Body)) + return } } func (r *IssueAlertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - organization, project, actionId, err := splitThreePartID(req.ID, "organization", "project-slug", "alert-id") - if err != nil { - resp.Diagnostics.Append(diagutils.NewFillError(err)) - return - } - resp.Diagnostics.Append(resp.State.SetAttribute( - ctx, path.Root("organization"), organization, - )...) - resp.Diagnostics.Append(resp.State.SetAttribute( - ctx, path.Root("project"), project, - )...) - resp.Diagnostics.Append(resp.State.SetAttribute( - ctx, path.Root("id"), actionId, - )...) + tfutils.ImportStateThreePartId(ctx, "organization", "project", req, resp) } func (r *IssueAlertResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { @@ -432,7 +1049,7 @@ func (r *IssueAlertResource) UpgradeState(ctx context.Context) map[int64]resourc return } - upgradedStateData := IssueAlertResourceModel{ + upgradedStateData := IssueAlertModel{ Id: types.StringValue(actionId), Organization: types.StringValue(organization), Project: types.StringValue(project), diff --git a/internal/provider/resource_issue_alert_test.go b/internal/provider/resource_issue_alert_test.go index 888bfdbc1..e2f276b51 100644 --- a/internal/provider/resource_issue_alert_test.go +++ b/internal/provider/resource_issue_alert_test.go @@ -4,19 +4,760 @@ import ( "context" "errors" "fmt" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/jianyuan/go-sentry/v2/sentry" "github.com/jianyuan/terraform-provider-sentry/internal/acctest" ) -func TestAccIssueAlertResource(t *testing.T) { +func TestAccIssueAlertResource_validation(t *testing.T) { + team := acctest.RandomWithPrefix("tf-team") + project := acctest.RandomWithPrefix("tf-project") + alert := acctest.RandomWithPrefix("tf-issue-alert") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccIssueAlertConfig(team, project, alert, ``), + ExpectError: acctest.ExpectLiteralError(`At least one of these attributes must be configured: [actions,actions_v2]`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions = "[]" + `), + ExpectError: acctest.ExpectLiteralError(`You must add an action for this alert to fire`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [] + `), + ExpectError: acctest.ExpectLiteralError(`You must add an action for this alert to fire`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + conditions = "[]" + conditions_v2 = [] + `), + ExpectError: acctest.ExpectLiteralError(`Attribute "conditions" cannot be specified when "conditions_v2" is specified`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + filters = "[]" + filters_v2 = [] + `), + ExpectError: acctest.ExpectLiteralError(`Attribute "filters" cannot be specified when "filters_v2" is specified`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions = "[]" + actions_v2 = [] + `), + ExpectError: acctest.ExpectLiteralError(`Attribute "actions" cannot be specified when "actions_v2" is specified`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions = "[]" + `), + ExpectError: acctest.ExpectLiteralError(`You must add an action for this alert to fire`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [] + `), + ExpectError: acctest.ExpectLiteralError(`You must add an action for this alert to fire`), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [{ }] + `), + ExpectError: acctest.ExpectLiteralError( + `Failed to convert action: [{Exactly one action must be set Exactly one action`, + `must be set}]`, + ), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [{ notify_event = { } }] + + filters_v2 = [{ }] + `), + ExpectError: acctest.ExpectLiteralError( + `Failed to convert filter: [{Exactly one filter must be set Exactly one filter`, + `must be set}]`, + ), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [{ notify_event = { } }] + + conditions_v2 = [{ }] + `), + ExpectError: acctest.ExpectLiteralError( + `Failed to convert condition: [{Exactly one condition must be set Exactly one`, + `condition must be set}]`, + ), + }, + { + Config: testAccIssueAlertConfig(team, project, alert, ` + actions_v2 = [{ notify_event = { } }] + + conditions_v2 = [ + { first_seen_event = {}, regression_event = {} }, + ] + `), + ExpectError: acctest.ExpectLiteralError( + `Attribute "conditions_v2[0].first_seen_event" cannot be specified when`, + `"conditions_v2[0].regression_event" is specified`, + ), + }, + }, + }) +} + +func TestAccIssueAlertResource_basic(t *testing.T) { + rn := "sentry_issue_alert.test" + team := acctest.RandomWithPrefix("tf-team") + project := acctest.RandomWithPrefix("tf-project") + alert := acctest.RandomWithPrefix("tf-issue-alert") + + checks := []statecheck.StateCheck{ + statecheck.ExpectKnownValue(rn, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("organization"), knownvalue.StringExact(acctest.TestOrganization)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("project"), knownvalue.StringExact(project)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("action_match"), knownvalue.StringExact("any")), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filter_match"), knownvalue.StringExact("any")), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("frequency"), knownvalue.Int64Exact(30)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("environment"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("owner"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("conditions"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filters"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("actions"), knownvalue.Null()), + } + + resource.Test(t, resource.TestCase{ + // TODO: Precheck acctest.TestOpsgenieIntegrationKey + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIssueAlertDestroy, + Steps: []resource.TestStep{ + { + Config: testAccIssueAlertConfig(team, project, alert, ` + conditions_v2 = [ + { first_seen_event = {} }, + { regression_event = {} }, + { reappeared_event = {} }, + { new_high_priority_issue = {} }, + { existing_high_priority_issue = {} }, + { event_frequency = { comparison_type = "count", value = 100, interval = "1h" } }, + { event_frequency = { comparison_type = "percent", comparison_interval = "1w", value = 100, interval = "1h" } }, + { event_unique_user_frequency = { comparison_type = "count", value = 100, interval = "1h" } }, + { event_unique_user_frequency = { comparison_type = "percent", comparison_interval = "1w", value = 100, interval = "1h" } }, + { event_frequency_percent = { comparison_type = "count", value = 100, interval = "1h" } }, + { event_frequency_percent = { comparison_type = "percent", comparison_interval = "1w", value = 100, interval = "1h" } }, + ] + + filters_v2 = [ + { age_comparison = { comparison_type = "older", value = 10, time = "minute" } }, + { issue_occurrences = { value = 10 } }, + { assigned_to = { target_type = "Unassigned" } }, + { assigned_to = { target_type = "Team", target_identifier = sentry_team.test.internal_id } }, + { latest_adopted_release = { oldest_or_newest = "oldest", older_or_newer = "older", environment = "test" } }, + { latest_release = {} }, + { issue_category = { value = "Error" } }, + { event_attribute = { attribute = "message", match = "CONTAINS", value = "test" } }, + { event_attribute = { attribute = "message", match = "IS_SET" } }, + { tagged_event = { key = "key", match = "CONTAINS", value = "value" } }, + { tagged_event = { key = "key", match = "NOT_SET" } }, + { level = { match = "EQUAL", level = "error" } }, + ] + + actions_v2 = [ + { notify_email = { target_type = "IssueOwners", fallthrough_type = "ActiveMembers" } }, + { notify_email = { target_type = "Team", target_identifier = sentry_team.test.internal_id } }, + { notify_event = { } }, + { + notify_event_service = { + service = "terraform-provider-sentry-ea4fdd" + } + }, + { + notify_event_sentry_app = { + sentry_app_installation_uuid = "d384d654-0e4c-447d-999c-a298fad579a7" + + settings = { + teamId = "5538c20b-37cf-4efd-b0aa-83c7f2e691f8" + assigneeId = "b7afdd84-58b9-48ab-a682-9bb121d9dfbd" + labelId = "9f918fa3-9641-4522-950e-84dfb5c21099" + projectId = "" + stateId = "23e412bc-5abc-4812-916c-f91b4e21a060" + priority = "0" + } + } + }, + { + opsgenie_notify_team = { + account = sentry_integration_opsgenie.opsgenie.integration_id + team = sentry_integration_opsgenie.opsgenie.id + priority = "P1" + } + }, + { + pagerduty_notify_service = { + account = sentry_integration_pagerduty.pagerduty.integration_id + service = sentry_integration_pagerduty.pagerduty.id + severity = "default" + } + }, + { + slack_notify_service = { + workspace = data.sentry_organization_integration.slack.id + channel = "#general" + notes = "Please for triage information" + } + }, + { + slack_notify_service = { + workspace = data.sentry_organization_integration.slack.id + channel = "#general" + tags = ["environment", "level"] + notes = "Please for triage information" + } + }, + { + discord_notify_service = { + server = data.sentry_organization_integration.discord.id + channel_id = "714123428994482189" + } + }, + { + discord_notify_service = { + server = data.sentry_organization_integration.discord.id + channel_id = "714123428994482189" + tags = ["environment", "level"] + } + }, + { + github_create_ticket = { + integration = data.sentry_organization_integration.github.id + repo = "terraform-provider-sentry" + assignee = "jianyuan" + labels = ["bug", "enhancement"] + } + }, + { + azure_devops_create_ticket = { + integration = data.sentry_organization_integration.vsts.id + project = "123" + work_item_type = "Microsoft.VSTS.WorkItemTypes.Task" + } + } + ] + `) + fmt.Sprintf(` + # Opsgenie + data "sentry_organization_integration" "opsgenie" { + organization = sentry_project.test.organization + provider_key = "opsgenie" + name = "terraform-provider-sentry" + } + + resource "sentry_integration_opsgenie" "opsgenie" { + organization = data.sentry_organization_integration.opsgenie.organization + integration_id = data.sentry_organization_integration.opsgenie.id + team = "issue-alert-team" + integration_key = "%[1]s" + } + + # PagerDuty + data "sentry_organization_integration" "pagerduty" { + organization = sentry_project.test.organization + provider_key = "pagerduty" + name = "terraform-provider-sentry" + } + + resource "sentry_integration_pagerduty" "pagerduty" { + organization = data.sentry_organization_integration.pagerduty.organization + integration_id = data.sentry_organization_integration.pagerduty.id + service = "issue-alert-service" + integration_key = "issue-alert-integration-key" + } + + # Slack + data "sentry_organization_integration" "slack" { + organization = sentry_project.test.organization + provider_key = "slack" + name = "A2 Marketing" # TODO: Use a real integration name + } + + # Discord + data "sentry_organization_integration" "discord" { + organization = sentry_project.test.organization + provider_key = "discord" + name = "jy's server" + } + + # GitHub + data "sentry_organization_integration" "github" { + organization = sentry_project.test.organization + provider_key = "github" + name = "jianyuan" + } + + # Azure DevOps + data "sentry_organization_integration" "vsts" { + organization = sentry_project.test.organization + provider_key = "vsts" + name = "jianyuanlee" + } + `, acctest.TestOpsgenieIntegrationKey), + ConfigStateChecks: append( + checks, + statecheck.ExpectKnownValue(rn, tfjsonpath.New("name"), knownvalue.StringExact(alert)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("conditions_v2"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "first_seen_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "regression_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "reappeared_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "new_high_priority_issue": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "existing_high_priority_issue": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_frequency": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("count"), + "comparison_interval": knownvalue.Null(), + "value": knownvalue.Int64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_frequency": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("percent"), + "comparison_interval": knownvalue.StringExact("1w"), + "value": knownvalue.Int64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_unique_user_frequency": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("count"), + "comparison_interval": knownvalue.Null(), + "value": knownvalue.Int64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_unique_user_frequency": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("percent"), + "comparison_interval": knownvalue.StringExact("1w"), + "value": knownvalue.Int64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_frequency_percent": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("count"), + "comparison_interval": knownvalue.Null(), + "value": knownvalue.Float64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_frequency_percent": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("percent"), + "comparison_interval": knownvalue.StringExact("1w"), + "value": knownvalue.Float64Exact(100), + "interval": knownvalue.StringExact("1h"), + }), + }), + })), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filters_v2"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "age_comparison": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "comparison_type": knownvalue.StringExact("older"), + "value": knownvalue.Int64Exact(10), + "time": knownvalue.StringExact("minute"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "issue_occurrences": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "value": knownvalue.Int64Exact(10), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "assigned_to": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "target_type": knownvalue.StringExact("Unassigned"), + "target_identifier": knownvalue.Null(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "assigned_to": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "target_type": knownvalue.StringExact("Team"), + "target_identifier": knownvalue.StringRegexp(regexp.MustCompile(`^\d+$`)), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "latest_adopted_release": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "oldest_or_newest": knownvalue.StringExact("oldest"), + "older_or_newer": knownvalue.StringExact("older"), + "environment": knownvalue.StringExact("test"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "latest_release": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "issue_category": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "value": knownvalue.StringExact("Error"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_attribute": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "attribute": knownvalue.StringExact("message"), + "match": knownvalue.StringExact("CONTAINS"), + "value": knownvalue.StringExact("test"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "event_attribute": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "attribute": knownvalue.StringExact("message"), + "match": knownvalue.StringExact("IS_SET"), + "value": knownvalue.Null(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "tagged_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "key": knownvalue.StringExact("key"), + "match": knownvalue.StringExact("CONTAINS"), + "value": knownvalue.StringExact("value"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "tagged_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "key": knownvalue.StringExact("key"), + "match": knownvalue.StringExact("NOT_SET"), + "value": knownvalue.Null(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "level": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "match": knownvalue.StringExact("EQUAL"), + "level": knownvalue.StringExact("error"), + }), + }), + })), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("actions_v2"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_email": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "target_type": knownvalue.StringExact("IssueOwners"), + "target_identifier": knownvalue.Null(), + "fallthrough_type": knownvalue.StringExact("ActiveMembers"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_email": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "target_type": knownvalue.StringExact("Team"), + "target_identifier": knownvalue.NotNull(), + "fallthrough_type": knownvalue.Null(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_event_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "service": knownvalue.StringExact("terraform-provider-sentry-ea4fdd"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_event_sentry_app": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "sentry_app_installation_uuid": knownvalue.StringExact("d384d654-0e4c-447d-999c-a298fad579a7"), + "settings": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "teamId": knownvalue.StringExact("5538c20b-37cf-4efd-b0aa-83c7f2e691f8"), + "assigneeId": knownvalue.StringExact("b7afdd84-58b9-48ab-a682-9bb121d9dfbd"), + "labelId": knownvalue.StringExact("9f918fa3-9641-4522-950e-84dfb5c21099"), + "projectId": knownvalue.StringExact(""), + "stateId": knownvalue.StringExact("23e412bc-5abc-4812-916c-f91b4e21a060"), + "priority": knownvalue.StringExact("0"), + }), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "opsgenie_notify_team": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "account": knownvalue.NotNull(), + "team": knownvalue.NotNull(), + "priority": knownvalue.StringExact("P1"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "pagerduty_notify_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "account": knownvalue.NotNull(), + "service": knownvalue.NotNull(), + "severity": knownvalue.StringExact("default"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "slack_notify_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "workspace": knownvalue.NotNull(), + "channel": knownvalue.StringExact("#general"), + "channel_id": knownvalue.NotNull(), + "tags": knownvalue.Null(), + "notes": knownvalue.StringExact("Please for triage information"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "slack_notify_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "workspace": knownvalue.NotNull(), + "channel": knownvalue.StringExact("#general"), + "channel_id": knownvalue.NotNull(), + "tags": knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("environment"), + knownvalue.StringExact("level"), + }), + "notes": knownvalue.StringExact("Please for triage information"), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "discord_notify_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "server": knownvalue.NotNull(), + "channel_id": knownvalue.NotNull(), + "tags": knownvalue.Null(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "discord_notify_service": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "server": knownvalue.NotNull(), + "channel_id": knownvalue.NotNull(), + "tags": knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("environment"), + knownvalue.StringExact("level"), + }), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "github_create_ticket": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "integration": knownvalue.NotNull(), + "repo": knownvalue.StringExact("terraform-provider-sentry"), + "assignee": knownvalue.StringExact("jianyuan"), + "labels": knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("bug"), + knownvalue.StringExact("enhancement"), + }), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "azure_devops_create_ticket": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "integration": knownvalue.NotNull(), + "project": knownvalue.StringExact("123"), + "work_item_type": knownvalue.StringExact("Microsoft.VSTS.WorkItemTypes.Task"), + }), + }), + })), + ), + }, + { + Config: testAccIssueAlertConfig(team, project, alert+"-updated", ` + conditions_v2 = [ + { reappeared_event = {} }, + { new_high_priority_issue = {} }, + { existing_high_priority_issue = {} }, + ] + filters_v2 = [] + actions_v2 = [ + { notify_email = { target_type = "IssueOwners", fallthrough_type = "NoOne" } }, + ] + `), + ConfigStateChecks: append( + checks, + statecheck.ExpectKnownValue(rn, tfjsonpath.New("name"), knownvalue.StringExact(alert+"-updated")), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("conditions_v2"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "reappeared_event": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "new_high_priority_issue": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "existing_high_priority_issue": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }), + })), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filters_v2"), knownvalue.ListExact([]knownvalue.Check{})), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("actions_v2"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "notify_email": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + "target_type": knownvalue.StringExact("IssueOwners"), + "target_identifier": knownvalue.Null(), + "fallthrough_type": knownvalue.StringExact("NoOne"), + }), + }), + })), + ), + }, + { + ResourceName: rn, + ImportState: true, + ImportStateIdFunc: acctest.ThreePartImportStateIdFunc(rn, "organization", "project"), + }, + }, + }) +} + +func TestAccIssueAlertResource_emptyArray(t *testing.T) { + rn := "sentry_issue_alert.test" + team := acctest.RandomWithPrefix("tf-team") + project := acctest.RandomWithPrefix("tf-project") + alert := acctest.RandomWithPrefix("tf-issue-alert") + var alertId string + + check := func(alert string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + testAccCheckIssueAlertExists(rn, &alertId), + resource.TestCheckResourceAttrWith(rn, "id", func(value string) error { + if alertId != value { + return fmt.Errorf("expected %s, got %s", alertId, value) + } + return nil + }), + ) + } + + checks := []statecheck.StateCheck{ + statecheck.ExpectKnownValue(rn, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("organization"), knownvalue.StringExact(acctest.TestOrganization)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("project"), knownvalue.StringExact(project)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("action_match"), knownvalue.StringExact("any")), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filter_match"), knownvalue.StringExact("any")), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("frequency"), knownvalue.Int64Exact(30)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("environment"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("owner"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("conditions"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filters"), knownvalue.Null()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("actions"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("conditions_v2"), knownvalue.ListSizeExact(0)), + statecheck.ExpectKnownValue(rn, tfjsonpath.New("filters_v2"), knownvalue.Null()), + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIssueAlertDestroy, + Steps: []resource.TestStep{ + { + Config: testAccIssueAlertConfig(team, project, alert, ` + conditions_v2 = [] + + actions = < 0 { + parsedItems = append(parsedItems, parsedItem) + } + } + + return strings.Join(parsedItems, ","), diags +} + +func (v StringSet) ValueStringPointer(ctx context.Context) (*string, diag.Diagnostics) { + var diags diag.Diagnostics + + if v.IsNull() || v.IsUnknown() || len(v.Elements()) == 0 { + return nil, diags + } + + var items []types.String + diags.Append(v.ElementsAs(ctx, &items, false)...) + if diags.HasError() { + return nil, diags + } + + var parsedItems []string + for _, item := range items { + if item.IsNull() || item.IsUnknown() { + continue + } + + parsedItem := strings.TrimSpace(item.ValueString()) + if len(parsedItem) > 0 { + parsedItems = append(parsedItems, parsedItem) + } + } + + return ptr.Ptr(strings.Join(parsedItems, ",")), diags +} + +func StringSetNull() StringSet { + return StringSet{SetValue: basetypes.NewSetNull(types.StringType)} +} + +func StringSetUnknown() StringSet { + return StringSet{SetValue: basetypes.NewSetUnknown(types.StringType)} +} + +func StringSetPointerValue(value *string) (StringSet, diag.Diagnostics) { + var diags diag.Diagnostics + if value == nil || strings.TrimSpace(*value) == "" { + return StringSetNull(), diags + } + + items := strings.Split(*value, ",") + elements := sliceutils.Map(func(item string) attr.Value { + return types.StringValue(strings.TrimSpace(item)) + }, items) + + setValue, d := types.SetValue(types.StringType, elements) + diags.Append(d...) + + return StringSet{SetValue: setValue}, diags +} diff --git a/internal/tfutils/enum_string_attribute.go b/internal/tfutils/enum_string_attribute.go new file mode 100644 index 000000000..2903c3175 --- /dev/null +++ b/internal/tfutils/enum_string_attribute.go @@ -0,0 +1,29 @@ +package tfutils + +import ( + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/jianyuan/go-utils/sliceutils" +) + +func WithEnumStringAttribute(base schema.StringAttribute, choices []string) schema.StringAttribute { + // Add a markdown description that lists the valid values + if base.MarkdownDescription != "" { + base.MarkdownDescription += " " + } + validValues := sliceutils.Map(func(v string) string { + return "`" + v + "`" + }, choices) + if len(validValues) > 1 { + base.MarkdownDescription += "Valid values are: " + strings.Join(validValues[:len(validValues)-1], ", ") + ", and " + validValues[len(validValues)-1] + "." + } else { + base.MarkdownDescription += "Valid values are: " + validValues[0] + "." + } + + // Add a validator that checks the value is one of the valid values + base.Validators = append(base.Validators, stringvalidator.OneOf(choices...)) + + return base +} diff --git a/internal/tfutils/merge_diagnostics.go b/internal/tfutils/merge_diagnostics.go new file mode 100644 index 000000000..b14289363 --- /dev/null +++ b/internal/tfutils/merge_diagnostics.go @@ -0,0 +1,12 @@ +package tfutils + +import "github.com/hashicorp/terraform-plugin-framework/diag" + +// MergeDiagnostics is a utility function that merges the given diagnostics into the +// provided diagnostics and returns the original value. +func MergeDiagnostics[T any](v T, diagsOut diag.Diagnostics) func(diags *diag.Diagnostics) T { + return func(diags *diag.Diagnostics) T { + diags.Append(diagsOut...) + return v + } +} diff --git a/internal/tfutils/mutex.go b/internal/tfutils/mutex.go new file mode 100644 index 000000000..0942a9fe8 --- /dev/null +++ b/internal/tfutils/mutex.go @@ -0,0 +1,35 @@ +package tfutils + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func WithMutuallyExclusiveValidator(attributes map[string]schema.SingleNestedAttribute) map[string]schema.Attribute { + var names []string + for name := range attributes { + names = append(names, name) + } + + conditionFor := func(name string) []validator.Object { + var paths []path.Expression + + for _, thisName := range names { + if thisName != name { + paths = append(paths, path.MatchRelative().AtParent().AtName(thisName)) + } + } + + return []validator.Object{objectvalidator.ConflictsWith(paths...)} + } + + result := make(map[string]schema.Attribute, len(attributes)) + for name, attribute := range attributes { + attribute.Validators = append(attribute.Validators, conditionFor(name)...) + result[name] = attribute + } + + return result +}