From 2fc76ed86ceb405a4d8514cf9bc133ebf6e7609d Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 11:08:26 -0500 Subject: [PATCH 1/9] feat: add Campaign CRUD methods and input/payload types Adds GetCampaign, CreateCampaign, UpdateCampaign, DeleteCampaign, ScheduleCampaign, and UnscheduleCampaign client methods along with their corresponding input and payload types. These are needed by the terraform-provider-opslevel campaign resource. Made-with: Cursor --- campaign.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ input.go | 29 ++++++++++++++++++++++ payload.go | 30 +++++++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/campaign.go b/campaign.go index 0b276e63..ebb1dc15 100644 --- a/campaign.go +++ b/campaign.go @@ -25,6 +25,76 @@ func (v *ListCampaignsVariables) AsPayloadVariables() *PayloadVariables { return &variables } +func (client *Client) CreateCampaign(input CampaignCreateInput) (*Campaign, error) { + var m struct { + Payload CampaignCreatePayload `graphql:"campaignCreate(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("CampaignCreate")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + +func (client *Client) GetCampaign(id ID) (*Campaign, error) { + var q struct { + Account struct { + Campaign Campaign `graphql:"campaign(id: $id)"` + } + } + v := PayloadVariables{ + "id": id, + } + err := client.Query(&q, v, WithName("CampaignGet")) + return &q.Account.Campaign, HandleErrors(err, nil) +} + +func (client *Client) UpdateCampaign(input CampaignUpdateInput) (*Campaign, error) { + var m struct { + Payload CampaignUpdatePayload `graphql:"campaignUpdate(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("CampaignUpdate")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + +func (client *Client) DeleteCampaign(id ID) error { + var m struct { + Payload CampaignDeletePayload `graphql:"campaignDelete(input: $input)"` + } + v := PayloadVariables{ + "input": CampaignDeleteInput{Id: id}, + } + err := client.Mutate(&m, v, WithName("CampaignDelete")) + return HandleErrors(err, m.Payload.Errors) +} + +func (client *Client) ScheduleCampaign(input CampaignScheduleUpdateInput) (*Campaign, error) { + var m struct { + Payload CampaignSchedulePayload `graphql:"campaignSchedule(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("CampaignSchedule")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + +func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) { + var m struct { + Payload CampaignUnschedulePayload `graphql:"campaignUnschedule(input: $input)"` + } + v := PayloadVariables{ + "input": struct { + Id ID `json:"id"` + }{Id: id}, + } + err := client.Mutate(&m, v, WithName("CampaignUnschedule")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) ListCampaigns(campaignVariables *ListCampaignsVariables) (*CampaignConnection, error) { if campaignVariables == nil { campaignVariables = &ListCampaignsVariables{} diff --git a/input.go b/input.go index 2170c22e..dbb6190d 100644 --- a/input.go +++ b/input.go @@ -93,6 +93,35 @@ type CategoryUpdateInput struct { Name *Nullable[string] `json:"name,omitempty" yaml:"name,omitempty" example:"example_value"` // The display name of the category (Optional) } +// CampaignCreateInput Specifies the input fields used to create a campaign +type CampaignCreateInput struct { + Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) + OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) +} + +// CampaignDeleteInput Specifies the input fields used to delete a campaign +type CampaignDeleteInput struct { + Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be deleted (Required) +} + +// CampaignScheduleUpdateInput Specifies the input fields used to schedule a campaign +type CampaignScheduleUpdateInput struct { + Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be scheduled (Required) + StartDate iso8601.Time `json:"startDate" yaml:"startDate" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Required) + TargetDate iso8601.Time `json:"targetDate" yaml:"targetDate" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Required) +} + +// CampaignUpdateInput Specifies the input fields used to update a campaign +type CampaignUpdateInput struct { + Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be updated (Required) + Name *string `json:"name,omitempty" yaml:"name,omitempty" example:"example_value"` // The name of the campaign (Optional) + OwnerId *Nullable[ID] `json:"ownerId,omitempty" yaml:"ownerId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Optional) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) +} + // CheckAlertSourceUsageCreateInput Specifies the input fields used to create an alert source usage check type CheckAlertSourceUsageCreateInput struct { AlertSourceNamePredicate *PredicateInput `json:"alertSourceNamePredicate,omitempty" yaml:"alertSourceNamePredicate,omitempty"` // The condition that the alert source name should satisfy to be evaluated (Optional) diff --git a/payload.go b/payload.go index e74b8408..2e95f27b 100644 --- a/payload.go +++ b/payload.go @@ -30,6 +30,36 @@ type CategoryUpdatePayload struct { BasePayload } +// CampaignCreatePayload The return type of the `campaignCreate` mutation +type CampaignCreatePayload struct { + Campaign Campaign // The created campaign (Optional) + BasePayload +} + +// CampaignUpdatePayload The return type of the `campaignUpdate` mutation +type CampaignUpdatePayload struct { + Campaign Campaign // The updated campaign (Optional) + BasePayload +} + +// CampaignSchedulePayload The return type of the `campaignSchedule` mutation +type CampaignSchedulePayload struct { + Campaign Campaign // The scheduled campaign (Optional) + BasePayload +} + +// CampaignUnschedulePayload The return type of the `campaignUnschedule` mutation +type CampaignUnschedulePayload struct { + Campaign Campaign // The unscheduled campaign (Optional) + BasePayload +} + +// CampaignDeletePayload The return type of the `campaignDelete` mutation +type CampaignDeletePayload struct { + Id ID `graphql:"deletedCampaignId"` // The id of the deleted campaign + BasePayload +} + // CheckCopyPayload The result of a check copying operation type CheckCopyPayload struct { TargetCategory Category // The category to which the checks have been copied (Optional) From 8d4a8d7dc2990082a38670ce29d64ac9996a9dbb Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 11:52:58 -0500 Subject: [PATCH 2/9] fix: pass dates in create/update inputs, remove campaignSchedule The OpsLevel API doesn't have a campaignSchedule mutation. Scheduling is done by passing startDate/targetDate in campaignCreate/campaignUpdate. Removes ScheduleCampaign method and CampaignScheduleUpdateInput. Made-with: Cursor --- campaign.go | 11 ----------- input.go | 29 +++++++++++++---------------- payload.go | 6 ------ 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/campaign.go b/campaign.go index ebb1dc15..fed23bfb 100644 --- a/campaign.go +++ b/campaign.go @@ -71,17 +71,6 @@ func (client *Client) DeleteCampaign(id ID) error { return HandleErrors(err, m.Payload.Errors) } -func (client *Client) ScheduleCampaign(input CampaignScheduleUpdateInput) (*Campaign, error) { - var m struct { - Payload CampaignSchedulePayload `graphql:"campaignSchedule(input: $input)"` - } - v := PayloadVariables{ - "input": input, - } - err := client.Mutate(&m, v, WithName("CampaignSchedule")) - return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) -} - func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) { var m struct { Payload CampaignUnschedulePayload `graphql:"campaignUnschedule(input: $input)"` diff --git a/input.go b/input.go index dbb6190d..92901bd9 100644 --- a/input.go +++ b/input.go @@ -95,10 +95,12 @@ type CategoryUpdateInput struct { // CampaignCreateInput Specifies the input fields used to create a campaign type CampaignCreateInput struct { - Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) - OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) - FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) - ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) + OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + StartDate *iso8601.Time `json:"startDate,omitempty" yaml:"startDate,omitempty" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Optional) + TargetDate *iso8601.Time `json:"targetDate,omitempty" yaml:"targetDate,omitempty" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Optional) } // CampaignDeleteInput Specifies the input fields used to delete a campaign @@ -106,20 +108,15 @@ type CampaignDeleteInput struct { Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be deleted (Required) } -// CampaignScheduleUpdateInput Specifies the input fields used to schedule a campaign -type CampaignScheduleUpdateInput struct { - Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be scheduled (Required) - StartDate iso8601.Time `json:"startDate" yaml:"startDate" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Required) - TargetDate iso8601.Time `json:"targetDate" yaml:"targetDate" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Required) -} - // CampaignUpdateInput Specifies the input fields used to update a campaign type CampaignUpdateInput struct { - Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be updated (Required) - Name *string `json:"name,omitempty" yaml:"name,omitempty" example:"example_value"` // The name of the campaign (Optional) - OwnerId *Nullable[ID] `json:"ownerId,omitempty" yaml:"ownerId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Optional) - FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) - ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be updated (Required) + Name *string `json:"name,omitempty" yaml:"name,omitempty" example:"example_value"` // The name of the campaign (Optional) + OwnerId *Nullable[ID] `json:"ownerId,omitempty" yaml:"ownerId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Optional) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + StartDate *iso8601.Time `json:"startDate,omitempty" yaml:"startDate,omitempty" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Optional) + TargetDate *iso8601.Time `json:"targetDate,omitempty" yaml:"targetDate,omitempty" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Optional) } // CheckAlertSourceUsageCreateInput Specifies the input fields used to create an alert source usage check diff --git a/payload.go b/payload.go index 2e95f27b..792ad35a 100644 --- a/payload.go +++ b/payload.go @@ -42,12 +42,6 @@ type CampaignUpdatePayload struct { BasePayload } -// CampaignSchedulePayload The return type of the `campaignSchedule` mutation -type CampaignSchedulePayload struct { - Campaign Campaign // The scheduled campaign (Optional) - BasePayload -} - // CampaignUnschedulePayload The return type of the `campaignUnschedule` mutation type CampaignUnschedulePayload struct { Campaign Campaign // The unscheduled campaign (Optional) From b0b40ea8eabbfc88594bee799d6c095f79162ee6 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 12:05:17 -0500 Subject: [PATCH 3/9] fix: use campaignScheduleUpdate mutation for scheduling The OpsLevel API uses a separate campaignScheduleUpdate mutation (not campaignSchedule, and not fields on create/update inputs). Also uses generic DeleteInput for campaignDelete/campaignUnschedule. Made-with: Cursor --- campaign.go | 17 +++++++++++++---- input.go | 20 +++++++++----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/campaign.go b/campaign.go index fed23bfb..0d6340df 100644 --- a/campaign.go +++ b/campaign.go @@ -65,20 +65,29 @@ func (client *Client) DeleteCampaign(id ID) error { Payload CampaignDeletePayload `graphql:"campaignDelete(input: $input)"` } v := PayloadVariables{ - "input": CampaignDeleteInput{Id: id}, + "input": DeleteInput{Id: id}, } err := client.Mutate(&m, v, WithName("CampaignDelete")) return HandleErrors(err, m.Payload.Errors) } +func (client *Client) ScheduleCampaign(input CampaignScheduleUpdateInput) (*Campaign, error) { + var m struct { + Payload CampaignUpdatePayload `graphql:"campaignScheduleUpdate(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("CampaignScheduleUpdate")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) { var m struct { Payload CampaignUnschedulePayload `graphql:"campaignUnschedule(input: $input)"` } v := PayloadVariables{ - "input": struct { - Id ID `json:"id"` - }{Id: id}, + "input": DeleteInput{Id: id}, } err := client.Mutate(&m, v, WithName("CampaignUnschedule")) return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) diff --git a/input.go b/input.go index 92901bd9..9c9e02e1 100644 --- a/input.go +++ b/input.go @@ -95,17 +95,17 @@ type CategoryUpdateInput struct { // CampaignCreateInput Specifies the input fields used to create a campaign type CampaignCreateInput struct { - Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) - OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) - FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) - ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) - StartDate *iso8601.Time `json:"startDate,omitempty" yaml:"startDate,omitempty" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Optional) - TargetDate *iso8601.Time `json:"targetDate,omitempty" yaml:"targetDate,omitempty" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Optional) + Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) + OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) } -// CampaignDeleteInput Specifies the input fields used to delete a campaign -type CampaignDeleteInput struct { - Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to be deleted (Required) +// CampaignScheduleUpdateInput Specifies the input fields used to schedule a campaign +type CampaignScheduleUpdateInput struct { + Id ID `json:"id" yaml:"id" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to schedule (Required) + StartDate iso8601.Time `json:"startDate" yaml:"startDate" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Required) + TargetDate iso8601.Time `json:"targetDate" yaml:"targetDate" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Required) } // CampaignUpdateInput Specifies the input fields used to update a campaign @@ -115,8 +115,6 @@ type CampaignUpdateInput struct { OwnerId *Nullable[ID] `json:"ownerId,omitempty" yaml:"ownerId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Optional) FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) - StartDate *iso8601.Time `json:"startDate,omitempty" yaml:"startDate,omitempty" example:"2025-01-01T00:00:00Z"` // The start date of the campaign (Optional) - TargetDate *iso8601.Time `json:"targetDate,omitempty" yaml:"targetDate,omitempty" example:"2025-06-01T00:00:00Z"` // The target end date of the campaign (Optional) } // CheckAlertSourceUsageCreateInput Specifies the input fields used to create an alert source usage check From 52da43765cd192dfca7a6f10bfdc91841bd1eb25 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 12:10:16 -0500 Subject: [PATCH 4/9] feat: add CheckIdsToCopy to CampaignCreateInput Made-with: Cursor --- input.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/input.go b/input.go index 9c9e02e1..b7de58d7 100644 --- a/input.go +++ b/input.go @@ -95,10 +95,11 @@ type CategoryUpdateInput struct { // CampaignCreateInput Specifies the input fields used to create a campaign type CampaignCreateInput struct { - Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) - OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) - FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) - ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) + OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) + CheckIdsToCopy []ID `json:"checkIdsToCopy,omitempty" yaml:"checkIdsToCopy,omitempty"` // Check IDs to copy to this campaign (Optional) } // CampaignScheduleUpdateInput Specifies the input fields used to schedule a campaign From 70fd5379db8e25e6d3e9934ce81bd843119be361 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 12:39:05 -0500 Subject: [PATCH 5/9] feat: add CopyChecksToCampaign via checksCopyToCampaign mutation Replace CheckIdsToCopy on CampaignCreateInput with a dedicated CopyChecksToCampaign method that calls the checksCopyToCampaign GraphQL mutation. This is the correct API for associating rubric checks with a campaign after creation. Changes: - Add ChecksCopyToCampaignInput and ChecksCopyToCampaignPayload types - Add Client.CopyChecksToCampaign method - Remove CheckIdsToCopy from CampaignCreateInput (not a real API field) - Add comprehensive tests for all campaign CRUD operations (Create, Get, Update, Delete, Schedule, Unschedule, CopyChecks) - Add test fixtures in campaigns.tpl for all operations Made-with: Cursor --- campaign.go | 11 +++ campaign_test.go | 158 +++++++++++++++++++++++++++++- input.go | 15 ++- payload.go | 6 ++ testdata/templates/campaigns.tpl | 162 +++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 7 deletions(-) diff --git a/campaign.go b/campaign.go index 0d6340df..91e771a8 100644 --- a/campaign.go +++ b/campaign.go @@ -93,6 +93,17 @@ func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) { return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) } +func (client *Client) CopyChecksToCampaign(input ChecksCopyToCampaignInput) (*Campaign, error) { + var m struct { + Payload ChecksCopyToCampaignPayload `graphql:"checksCopyToCampaign(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("ChecksCopyToCampaign")) + return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) ListCampaigns(campaignVariables *ListCampaignsVariables) (*CampaignConnection, error) { if campaignVariables == nil { campaignVariables = &ListCampaignsVariables{} diff --git a/campaign_test.go b/campaign_test.go index 3a2196e2..59dc58cd 100644 --- a/campaign_test.go +++ b/campaign_test.go @@ -4,9 +4,165 @@ import ( "testing" ol "github.com/opslevel/opslevel-go/v2026" + "github.com/relvacode/iso8601" "github.com/rocktavious/autopilot/v2023" ) +func TestCreateCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_create_request" }}`, + `{{ template "campaign_create_request_vars" }}`, + `{{ template "campaign_create_response" }}`, + ) + client := BestTestClient(t, "campaign/create", testRequest) + + brief := "A test campaign" + // Act + campaign, err := client.CreateCampaign(ol.CampaignCreateInput{ + Name: "New Campaign", + OwnerId: id1, + FilterId: ol.RefOf(id2), + ProjectBrief: &brief, + }) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, "New Campaign", campaign.Name) + autopilot.Equals(t, ol.CampaignStatusEnumDraft, campaign.Status) + autopilot.Equals(t, id1, campaign.Owner.Id) + autopilot.Equals(t, id2, campaign.Filter.Id) + autopilot.Equals(t, "A test campaign", campaign.RawProjectBrief) +} + +func TestGetCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_get_request" }}`, + `{{ template "campaign_get_request_vars" }}`, + `{{ template "campaign_get_response" }}`, + ) + client := BestTestClient(t, "campaign/get", testRequest) + + // Act + campaign, err := client.GetCampaign(id1) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, id1, campaign.Id) + autopilot.Equals(t, "Fetched Campaign", campaign.Name) + autopilot.Equals(t, ol.CampaignStatusEnumScheduled, campaign.Status) + autopilot.Equals(t, "2026-05-01 00:00:00 +0000 UTC", campaign.StartDate.String()) + autopilot.Equals(t, "2026-06-30 00:00:00 +0000 UTC", campaign.TargetDate.String()) +} + +func TestUpdateCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_update_request" }}`, + `{{ template "campaign_update_request_vars" }}`, + `{{ template "campaign_update_response" }}`, + ) + client := BestTestClient(t, "campaign/update", testRequest) + + name := "Updated Campaign" + // Act + campaign, err := client.UpdateCampaign(ol.CampaignUpdateInput{ + Id: id1, + Name: &name, + OwnerId: ol.RefOf(id2), + }) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, id1, campaign.Id) + autopilot.Equals(t, "Updated Campaign", campaign.Name) + autopilot.Equals(t, id2, campaign.Owner.Id) +} + +func TestDeleteCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_delete_request" }}`, + `{{ template "campaign_delete_request_vars" }}`, + `{{ template "campaign_delete_response" }}`, + ) + client := BestTestClient(t, "campaign/delete", testRequest) + + // Act + err := client.DeleteCampaign(id1) + + // Assert + autopilot.Ok(t, err) +} + +func TestScheduleCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_schedule_request" }}`, + `{{ template "campaign_schedule_request_vars" }}`, + `{{ template "campaign_schedule_response" }}`, + ) + client := BestTestClient(t, "campaign/schedule", testRequest) + + startDate, _ := iso8601.ParseString("2026-05-01T00:00:00Z") + targetDate, _ := iso8601.ParseString("2026-06-30T00:00:00Z") + + // Act + campaign, err := client.ScheduleCampaign(ol.CampaignScheduleUpdateInput{ + Id: id1, + StartDate: iso8601.Time{Time: startDate}, + TargetDate: iso8601.Time{Time: targetDate}, + }) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, id1, campaign.Id) + autopilot.Equals(t, ol.CampaignStatusEnumScheduled, campaign.Status) + autopilot.Equals(t, "2026-05-01 00:00:00 +0000 UTC", campaign.StartDate.String()) + autopilot.Equals(t, "2026-06-30 00:00:00 +0000 UTC", campaign.TargetDate.String()) +} + +func TestUnscheduleCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_unschedule_request" }}`, + `{{ template "campaign_unschedule_request_vars" }}`, + `{{ template "campaign_unschedule_response" }}`, + ) + client := BestTestClient(t, "campaign/unschedule", testRequest) + + // Act + campaign, err := client.UnscheduleCampaign(id1) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, id1, campaign.Id) + autopilot.Equals(t, ol.CampaignStatusEnumDraft, campaign.Status) + autopilot.Equals(t, true, campaign.StartDate.IsZero()) +} + +func TestCopyChecksToCampaign(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_copy_checks_request" }}`, + `{{ template "campaign_copy_checks_request_vars" }}`, + `{{ template "campaign_copy_checks_response" }}`, + ) + client := BestTestClient(t, "campaign/copy_checks", testRequest) + + // Act + campaign, err := client.CopyChecksToCampaign(ol.ChecksCopyToCampaignInput{ + CampaignId: id1, + CheckIds: []ol.ID{id2, id3}, + }) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, id1, campaign.Id) + autopilot.Equals(t, 2, campaign.CheckStats.Total) +} + func TestListCampaigns(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( @@ -40,7 +196,6 @@ func TestListCampaigns(t *testing.T) { autopilot.Equals(t, "2024-01-01 00:00:00 +0000 UTC", result[0].StartDate.String()) } -// TestListCampaignsVariables_AsPayloadVariables verifies that ListCampaignsVariables produces the correct payload map. func TestListCampaignsVariables_AsPayloadVariables(t *testing.T) { after := "cursor" first := 5 @@ -61,7 +216,6 @@ func TestListCampaignsVariables_AsPayloadVariables(t *testing.T) { autopilot.Equals(t, expected, *variables) } -// TestListCampaignsWithCustomVariables verifies that custom ListCampaignsVariables values are sent in the GraphQL request. func TestListCampaignsWithCustomVariables(t *testing.T) { after := "cursor" first := 5 diff --git a/input.go b/input.go index b7de58d7..220c026b 100644 --- a/input.go +++ b/input.go @@ -95,11 +95,16 @@ type CategoryUpdateInput struct { // CampaignCreateInput Specifies the input fields used to create a campaign type CampaignCreateInput struct { - Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) - OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) - FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) - ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) - CheckIdsToCopy []ID `json:"checkIdsToCopy,omitempty" yaml:"checkIdsToCopy,omitempty"` // Check IDs to copy to this campaign (Optional) + Name string `json:"name" yaml:"name" example:"example_value"` // The name of the campaign (Required) + OwnerId ID `json:"ownerId" yaml:"ownerId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the team that owns the campaign (Required) + FilterId *Nullable[ID] `json:"filterId,omitempty" yaml:"filterId,omitempty" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the filter applied to the campaign (Optional) + ProjectBrief *string `json:"projectBrief,omitempty" yaml:"projectBrief,omitempty" example:"example_value"` // The project brief of the campaign in Markdown (Optional) +} + +// ChecksCopyToCampaignInput Specifies the input fields for copying checks to a campaign +type ChecksCopyToCampaignInput struct { + CampaignId ID `json:"campaignId" yaml:"campaignId" example:"Z2lkOi8vc2VydmljZS8xMjM0NTY3ODk"` // The id of the campaign to copy checks to (Required) + CheckIds []ID `json:"checkIds" yaml:"checkIds"` // The ids of the checks to copy (Required) } // CampaignScheduleUpdateInput Specifies the input fields used to schedule a campaign diff --git a/payload.go b/payload.go index 792ad35a..e483900b 100644 --- a/payload.go +++ b/payload.go @@ -60,6 +60,12 @@ type CheckCopyPayload struct { BasePayload } +// ChecksCopyToCampaignPayload Return type for the `checksCopyToCampaign` mutation +type ChecksCopyToCampaignPayload struct { + Campaign Campaign // The campaign that checks were copied to (Optional) + BasePayload +} + // CheckResponsePayload The return type of a `checkCreate` mutation and `checkUpdate` mutation type CheckResponsePayload struct { Check Check // The newly created check (Optional) diff --git a/testdata/templates/campaigns.tpl b/testdata/templates/campaigns.tpl index 54adad47..c4fd732e 100644 --- a/testdata/templates/campaigns.tpl +++ b/testdata/templates/campaigns.tpl @@ -88,3 +88,165 @@ "reminder": null } {{end}} + +{{- define "campaign_create_request" }} +mutation CampaignCreate($input:CampaignCreateInput!){campaignCreate(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} +{{ end }} + +{{- define "campaign_create_request_vars" }} +{"input":{"name":"New Campaign","ownerId":"{{ template "id1_string" }}","filterId":"{{ template "id2_string" }}","projectBrief":"A test campaign"}} +{{ end }} + +{{- define "campaign_create_response" }}{ + "data":{"campaignCreate":{"campaign":{ + {{ template "id1" }}, + "name":"New Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/new-campaign", + "status":"draft", + "checkStats":{"total":0,"totalSuccessful":0}, + "serviceStats":{"total":0,"totalSuccessful":0}, + "owner":{ {{ template "id1" }}, "alias":"platform" }, + "projectBrief":"A test campaign", + "rawProjectBrief":"A test campaign", + "filter":{ "id":"{{ template "id2_string" }}", "name":"Tier 1" }, + "reminder":null + },"errors":[]}} +}{{ end }} + +{{- define "campaign_get_request" }} +query CampaignGet($id:ID!){account{campaign(id: $id){checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate}}} +{{ end }} + +{{- define "campaign_get_request_vars" }} +{"id":"{{ template "id1_string" }}"} +{{ end }} + +{{- define "campaign_get_response" }}{ + "data":{"account":{"campaign":{ + {{ template "id1" }}, + "name":"Fetched Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/fetched", + "status":"scheduled", + "checkStats":{"total":3,"totalSuccessful":1}, + "serviceStats":{"total":10,"totalSuccessful":5}, + "owner":{ {{ template "id1" }}, "alias":"platform" }, + "startDate":"2026-05-01T00:00:00Z", + "targetDate":"2026-06-30T00:00:00Z", + "projectBrief":"Fetched campaign brief", + "rawProjectBrief":"Fetched campaign brief", + "filter":null, + "reminder":null + }}} +}{{ end }} + +{{- define "campaign_update_request" }} +mutation CampaignUpdate($input:CampaignUpdateInput!){campaignUpdate(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} +{{ end }} + +{{- define "campaign_update_request_vars" }} +{"input":{"id":"{{ template "id1_string" }}","name":"Updated Campaign","ownerId":"{{ template "id2_string" }}"}} +{{ end }} + +{{- define "campaign_update_response" }}{ + "data":{"campaignUpdate":{"campaign":{ + {{ template "id1" }}, + "name":"Updated Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/updated", + "status":"draft", + "checkStats":{"total":0,"totalSuccessful":0}, + "serviceStats":{"total":0,"totalSuccessful":0}, + "owner":{ {{ template "id2" }}, "alias":"staff" }, + "projectBrief":"A test campaign", + "rawProjectBrief":"A test campaign", + "filter":null, + "reminder":null + },"errors":[]}} +}{{ end }} + +{{- define "campaign_delete_request" }} +mutation CampaignDelete($input:DeleteInput!){campaignDelete(input: $input){deletedCampaignId,errors{message,path}}} +{{ end }} + +{{- define "campaign_delete_request_vars" }} +{"input":{"id":"{{ template "id1_string" }}"}} +{{ end }} + +{{- define "campaign_delete_response" }}{ + "data":{"campaignDelete":{"deletedCampaignId":"{{ template "id1_string" }}","errors":[]}} +}{{ end }} + +{{- define "campaign_schedule_request" }} +mutation CampaignScheduleUpdate($input:CampaignScheduleUpdateInput!){campaignScheduleUpdate(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} +{{ end }} + +{{- define "campaign_schedule_request_vars" }} +{"input":{"id":"{{ template "id1_string" }}","startDate":"2026-05-01T00:00:00Z","targetDate":"2026-06-30T00:00:00Z"}} +{{ end }} + +{{- define "campaign_schedule_response" }}{ + "data":{"campaignScheduleUpdate":{"campaign":{ + {{ template "id1" }}, + "name":"New Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/new-campaign", + "status":"scheduled", + "checkStats":{"total":0,"totalSuccessful":0}, + "serviceStats":{"total":0,"totalSuccessful":0}, + "owner":{ {{ template "id1" }}, "alias":"platform" }, + "startDate":"2026-05-01T00:00:00Z", + "targetDate":"2026-06-30T00:00:00Z", + "projectBrief":"A test campaign", + "rawProjectBrief":"A test campaign", + "filter":null, + "reminder":null + },"errors":[]}} +}{{ end }} + +{{- define "campaign_unschedule_request" }} +mutation CampaignUnschedule($input:DeleteInput!){campaignUnschedule(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} +{{ end }} + +{{- define "campaign_unschedule_request_vars" }} +{"input":{"id":"{{ template "id1_string" }}"}} +{{ end }} + +{{- define "campaign_unschedule_response" }}{ + "data":{"campaignUnschedule":{"campaign":{ + {{ template "id1" }}, + "name":"New Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/new-campaign", + "status":"draft", + "checkStats":{"total":0,"totalSuccessful":0}, + "serviceStats":{"total":0,"totalSuccessful":0}, + "owner":{ {{ template "id1" }}, "alias":"platform" }, + "projectBrief":"A test campaign", + "rawProjectBrief":"A test campaign", + "filter":null, + "reminder":null + },"errors":[]}} +}{{ end }} + +{{- define "campaign_copy_checks_request" }} +mutation ChecksCopyToCampaign($input:ChecksCopyToCampaignInput!){checksCopyToCampaign(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} +{{ end }} + +{{- define "campaign_copy_checks_request_vars" }} +{"input":{"campaignId":"{{ template "id1_string" }}","checkIds":["{{ template "id2_string" }}","{{ template "id3_string" }}"]}} +{{ end }} + +{{- define "campaign_copy_checks_response" }}{ + "data":{"checksCopyToCampaign":{"campaign":{ + {{ template "id1" }}, + "name":"New Campaign", + "htmlUrl":"https://app.opslevel.com/campaigns/new-campaign", + "status":"scheduled", + "checkStats":{"total":2,"totalSuccessful":0}, + "serviceStats":{"total":10,"totalSuccessful":0}, + "owner":{ {{ template "id1" }}, "alias":"platform" }, + "startDate":"2026-05-01T00:00:00Z", + "targetDate":"2026-06-30T00:00:00Z", + "projectBrief":"A test campaign", + "rawProjectBrief":"A test campaign", + "filter":null, + "reminder":null + },"errors":[]}} +}{{ end }} From e8c2c9fdf610e32130949fd1b44785d9583c798c Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 12:54:47 -0500 Subject: [PATCH 6/9] fix: use deletedId instead of deletedCampaignId in CampaignDeletePayload Made-with: Cursor --- payload.go | 2 +- testdata/templates/campaigns.tpl | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/payload.go b/payload.go index e483900b..15f42132 100644 --- a/payload.go +++ b/payload.go @@ -50,7 +50,7 @@ type CampaignUnschedulePayload struct { // CampaignDeletePayload The return type of the `campaignDelete` mutation type CampaignDeletePayload struct { - Id ID `graphql:"deletedCampaignId"` // The id of the deleted campaign + Id ID `graphql:"deletedId"` // The id of the deleted campaign BasePayload } diff --git a/testdata/templates/campaigns.tpl b/testdata/templates/campaigns.tpl index c4fd732e..e80b3273 100644 --- a/testdata/templates/campaigns.tpl +++ b/testdata/templates/campaigns.tpl @@ -164,7 +164,7 @@ mutation CampaignUpdate($input:CampaignUpdateInput!){campaignUpdate(input: $inpu }{{ end }} {{- define "campaign_delete_request" }} -mutation CampaignDelete($input:DeleteInput!){campaignDelete(input: $input){deletedCampaignId,errors{message,path}}} +mutation CampaignDelete($input:DeleteInput!){campaignDelete(input: $input){deletedId,errors{message,path}}} {{ end }} {{- define "campaign_delete_request_vars" }} @@ -172,7 +172,7 @@ mutation CampaignDelete($input:DeleteInput!){campaignDelete(input: $input){delet {{ end }} {{- define "campaign_delete_response" }}{ - "data":{"campaignDelete":{"deletedCampaignId":"{{ template "id1_string" }}","errors":[]}} + "data":{"campaignDelete":{"deletedId":"{{ template "id1_string" }}","errors":[]}} }{{ end }} {{- define "campaign_schedule_request" }} From 16a73937f546b1832932deb14d8d54fbc7dce507 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 13:59:42 -0500 Subject: [PATCH 7/9] feat: add ListCampaignChecks to support check removal from campaigns Adds a lightweight query for fetching a campaign's checks (id + name only) to enable matching and deleting campaign checks when rubric check IDs are removed from the Terraform resource's check_ids list. Made-with: Cursor --- campaign.go | 39 ++++++++++++++++++++++++++++++++ campaign_test.go | 21 +++++++++++++++++ testdata/templates/campaigns.tpl | 15 ++++++++++++ 3 files changed, 75 insertions(+) diff --git a/campaign.go b/campaign.go index 91e771a8..1f7df1d6 100644 --- a/campaign.go +++ b/campaign.go @@ -93,6 +93,45 @@ func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) { return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors) } +// CampaignCheckNode is a lightweight representation of a check belonging to a campaign, +// used when listing campaign checks without needing the full Check interface fragments. +type CampaignCheckNode struct { + Id ID `graphql:"id"` + Name string `graphql:"name"` +} + +type campaignCheckConnection struct { + Nodes []CampaignCheckNode `graphql:"nodes"` + PageInfo PageInfo `graphql:"pageInfo"` +} + +func (client *Client) ListCampaignChecks(campaignId ID) ([]CampaignCheckNode, error) { + var q struct { + Account struct { + Campaign struct { + Checks campaignCheckConnection `graphql:"checks(first: $first, after: $after)"` + } `graphql:"campaign(id: $id)"` + } + } + + pages := client.InitialPageVariablesPointer() + (*pages)["id"] = campaignId + + if err := client.Query(&q, *pages, WithName("CampaignChecksList")); err != nil { + return nil, err + } + + allChecks := q.Account.Campaign.Checks.Nodes + for q.Account.Campaign.Checks.PageInfo.HasNextPage { + (*pages)["after"] = q.Account.Campaign.Checks.PageInfo.End + if err := client.Query(&q, *pages, WithName("CampaignChecksList")); err != nil { + return nil, err + } + allChecks = append(allChecks, q.Account.Campaign.Checks.Nodes...) + } + return allChecks, nil +} + func (client *Client) CopyChecksToCampaign(input ChecksCopyToCampaignInput) (*Campaign, error) { var m struct { Payload ChecksCopyToCampaignPayload `graphql:"checksCopyToCampaign(input: $input)"` diff --git a/campaign_test.go b/campaign_test.go index 59dc58cd..af2ec56a 100644 --- a/campaign_test.go +++ b/campaign_test.go @@ -163,6 +163,27 @@ func TestCopyChecksToCampaign(t *testing.T) { autopilot.Equals(t, 2, campaign.CheckStats.Total) } +func TestListCampaignChecks(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_list_checks_request" }}`, + `{{ template "campaign_list_checks_request_vars" }}`, + `{{ template "campaign_list_checks_response" }}`, + ) + client := BestTestClient(t, "campaign/list_checks", testRequest) + + // Act + checks, err := client.ListCampaignChecks(id1) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 2, len(checks)) + autopilot.Equals(t, id2, checks[0].Id) + autopilot.Equals(t, "Secret Rotation", checks[0].Name) + autopilot.Equals(t, id3, checks[1].Id) + autopilot.Equals(t, "Dependency Scanning", checks[1].Name) +} + func TestListCampaigns(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( diff --git a/testdata/templates/campaigns.tpl b/testdata/templates/campaigns.tpl index e80b3273..fc6429e4 100644 --- a/testdata/templates/campaigns.tpl +++ b/testdata/templates/campaigns.tpl @@ -225,6 +225,21 @@ mutation CampaignUnschedule($input:DeleteInput!){campaignUnschedule(input: $inpu },"errors":[]}} }{{ end }} +{{- define "campaign_list_checks_request" }} +query CampaignChecksList($after:String!$first:Int!$id:ID!){account{campaign(id: $id){checks(first: $first, after: $after){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}} +{{ end }} + +{{- define "campaign_list_checks_request_vars" }} +{"after":"","first":500,"id":"{{ template "id1_string" }}"} +{{ end }} + +{{- define "campaign_list_checks_response" }}{ + "data":{"account":{"campaign":{"checks":{"nodes":[ + {"id":"{{ template "id2_string" }}","name":"Secret Rotation"}, + {"id":"{{ template "id3_string" }}","name":"Dependency Scanning"} + ],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":null,"endCursor":null}}}}} +}{{ end }} + {{- define "campaign_copy_checks_request" }} mutation ChecksCopyToCampaign($input:ChecksCopyToCampaignInput!){checksCopyToCampaign(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} {{ end }} From 36178f7a736cc44dac2e1d6425605bd145230123 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 15:20:53 -0500 Subject: [PATCH 8/9] add TestListCampaignChecksEmpty for edge case coverage Adds a test verifying ListCampaignChecks returns an empty slice when a campaign has no checks, plus the supporting template. Made-with: Cursor --- campaign_test.go | 17 +++++++++++++++++ testdata/templates/campaigns.tpl | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/campaign_test.go b/campaign_test.go index af2ec56a..78eb1623 100644 --- a/campaign_test.go +++ b/campaign_test.go @@ -184,6 +184,23 @@ func TestListCampaignChecks(t *testing.T) { autopilot.Equals(t, "Dependency Scanning", checks[1].Name) } +func TestListCampaignChecksEmpty(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `{{ template "campaign_list_checks_request" }}`, + `{{ template "campaign_list_checks_request_vars" }}`, + `{{ template "campaign_list_checks_empty_response" }}`, + ) + client := BestTestClient(t, "campaign/list_checks_empty", testRequest) + + // Act + checks, err := client.ListCampaignChecks(id1) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 0, len(checks)) +} + func TestListCampaigns(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( diff --git a/testdata/templates/campaigns.tpl b/testdata/templates/campaigns.tpl index fc6429e4..c0f99885 100644 --- a/testdata/templates/campaigns.tpl +++ b/testdata/templates/campaigns.tpl @@ -240,6 +240,10 @@ query CampaignChecksList($after:String!$first:Int!$id:ID!){account{campaign(id: ],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":null,"endCursor":null}}}}} }{{ end }} +{{- define "campaign_list_checks_empty_response" }}{ + "data":{"account":{"campaign":{"checks":{"nodes":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":null,"endCursor":null}}}}} +}{{ end }} + {{- define "campaign_copy_checks_request" }} mutation ChecksCopyToCampaign($input:ChecksCopyToCampaignInput!){checksCopyToCampaign(input: $input){campaign{checkStats{total,totalSuccessful},endedDate,filter{id,name},htmlUrl,id,name,owner{alias,id},projectBrief,rawProjectBrief,reminder{channels,daysOfWeek,defaultSlackChannel,frequency,frequencyUnit,message,nextOccurrence,timeOfDay,timezone},serviceStats{total,totalSuccessful},startDate,status,targetDate},errors{message,path}}} {{ end }} From 57952879dd2750cea7eabefdada16eb30d608ac6 Mon Sep 17 00:00:00 2001 From: James Carr Date: Mon, 13 Apr 2026 15:46:32 -0500 Subject: [PATCH 9/9] add not-found guard to GetCampaign, refactor ListCampaignChecks to recursive pagination Aligns GetCampaign with the empty-ID convention used by GetCategory, GetScorecard, etc. Converts ListCampaignChecks from iterative to recursive pagination to match the rest of the SDK. Made-with: Cursor --- campaign.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/campaign.go b/campaign.go index 1f7df1d6..7a1a944f 100644 --- a/campaign.go +++ b/campaign.go @@ -1,5 +1,11 @@ package opslevel +import ( + "fmt" + + "github.com/hasura/go-graphql-client" +) + type ListCampaignsVariables struct { After *string First *int @@ -46,6 +52,12 @@ func (client *Client) GetCampaign(id ID) (*Campaign, error) { "id": id, } err := client.Query(&q, v, WithName("CampaignGet")) + if q.Account.Campaign.Id == "" { + err = graphql.Errors{graphql.Error{ + Message: fmt.Sprintf("campaign with ID '%s' not found", id), + Path: []any{"account", "campaign"}, + }} + } return &q.Account.Campaign, HandleErrors(err, nil) } @@ -105,7 +117,7 @@ type campaignCheckConnection struct { PageInfo PageInfo `graphql:"pageInfo"` } -func (client *Client) ListCampaignChecks(campaignId ID) ([]CampaignCheckNode, error) { +func (client *Client) ListCampaignChecks(campaignId ID, variables ...*PayloadVariables) ([]CampaignCheckNode, error) { var q struct { Account struct { Campaign struct { @@ -114,20 +126,26 @@ func (client *Client) ListCampaignChecks(campaignId ID) ([]CampaignCheckNode, er } } - pages := client.InitialPageVariablesPointer() - (*pages)["id"] = campaignId + var pages *PayloadVariables + if len(variables) > 0 && variables[0] != nil { + pages = variables[0] + } else { + pages = client.InitialPageVariablesPointer() + (*pages)["id"] = campaignId + } if err := client.Query(&q, *pages, WithName("CampaignChecksList")); err != nil { return nil, err } allChecks := q.Account.Campaign.Checks.Nodes - for q.Account.Campaign.Checks.PageInfo.HasNextPage { + if q.Account.Campaign.Checks.PageInfo.HasNextPage { (*pages)["after"] = q.Account.Campaign.Checks.PageInfo.End - if err := client.Query(&q, *pages, WithName("CampaignChecksList")); err != nil { + resp, err := client.ListCampaignChecks(campaignId, pages) + if err != nil { return nil, err } - allChecks = append(allChecks, q.Account.Campaign.Checks.Nodes...) + allChecks = append(allChecks, resp...) } return allChecks, nil }