Skip to content

Commit 1423815

Browse files
Add campaign CRUD client methods and check management (#611)
* 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 * 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 * 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 * feat: add CheckIdsToCopy to CampaignCreateInput Made-with: Cursor * 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 * fix: use deletedId instead of deletedCampaignId in CampaignDeletePayload Made-with: Cursor * 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 * 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 * 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 * Apply suggestions from code review Co-authored-by: andrewstillv15 <andrew.still@opslevel.com> --------- Co-authored-by: andrewstillv15 <andrew.still@opslevel.com>
1 parent 940a99e commit 1423815

5 files changed

Lines changed: 576 additions & 2 deletions

File tree

campaign.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package opslevel
22

3+
import (
4+
"fmt"
5+
6+
"github.com/hasura/go-graphql-client"
7+
)
8+
39
type ListCampaignsVariables struct {
410
After *string
511
First *int
@@ -25,6 +31,136 @@ func (v *ListCampaignsVariables) AsPayloadVariables() *PayloadVariables {
2531
return &variables
2632
}
2733

34+
func (client *Client) CreateCampaign(input CampaignCreateInput) (*Campaign, error) {
35+
var m struct {
36+
Payload CampaignCreatePayload `graphql:"campaignCreate(input: $input)"`
37+
}
38+
v := PayloadVariables{
39+
"input": input,
40+
}
41+
err := client.Mutate(&m, v, WithName("CampaignCreate"))
42+
return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors)
43+
}
44+
45+
func (client *Client) GetCampaign(id ID) (*Campaign, error) {
46+
var q struct {
47+
Account struct {
48+
Campaign Campaign `graphql:"campaign(id: $id)"`
49+
}
50+
}
51+
v := PayloadVariables{
52+
"id": id,
53+
}
54+
err := client.Query(&q, v, WithName("CampaignGet"))
55+
if q.Account.Campaign.Id == "" {
56+
err = graphql.Errors{graphql.Error{
57+
Message: fmt.Sprintf("campaign with ID '%s' not found", id),
58+
Path: []any{"account", "campaign"},
59+
}}
60+
}
61+
return &q.Account.Campaign, HandleErrors(err, nil)
62+
}
63+
64+
func (client *Client) UpdateCampaign(input CampaignUpdateInput) (*Campaign, error) {
65+
var m struct {
66+
Payload CampaignUpdatePayload `graphql:"campaignUpdate(input: $input)"`
67+
}
68+
v := PayloadVariables{
69+
"input": input,
70+
}
71+
err := client.Mutate(&m, v, WithName("CampaignUpdate"))
72+
return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors)
73+
}
74+
75+
func (client *Client) DeleteCampaign(id ID) error {
76+
var m struct {
77+
Payload CampaignDeletePayload `graphql:"campaignDelete(input: $input)"`
78+
}
79+
v := PayloadVariables{
80+
"input": DeleteInput{Id: id},
81+
}
82+
err := client.Mutate(&m, v, WithName("CampaignDelete"))
83+
return HandleErrors(err, m.Payload.Errors)
84+
}
85+
86+
func (client *Client) ScheduleCampaign(input CampaignScheduleUpdateInput) (*Campaign, error) {
87+
var m struct {
88+
Payload CampaignUpdatePayload `graphql:"campaignScheduleUpdate(input: $input)"`
89+
}
90+
v := PayloadVariables{
91+
"input": input,
92+
}
93+
err := client.Mutate(&m, v, WithName("CampaignScheduleUpdate"))
94+
return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors)
95+
}
96+
97+
func (client *Client) UnscheduleCampaign(id ID) (*Campaign, error) {
98+
var m struct {
99+
Payload CampaignUnschedulePayload `graphql:"campaignUnschedule(input: $input)"`
100+
}
101+
v := PayloadVariables{
102+
"input": CampaignUnscheduleInput{Id: id},
103+
}
104+
err := client.Mutate(&m, v, WithName("CampaignUnschedule"))
105+
return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors)
106+
}
107+
108+
// CampaignCheckNode is a lightweight representation of a check belonging to a campaign,
109+
// used when listing campaign checks without needing the full Check interface fragments.
110+
type CampaignCheckNode struct {
111+
Id ID `graphql:"id"`
112+
Name string `graphql:"name"`
113+
}
114+
115+
type campaignCheckConnection struct {
116+
Nodes []CampaignCheckNode `graphql:"nodes"`
117+
PageInfo PageInfo `graphql:"pageInfo"`
118+
}
119+
120+
func (client *Client) ListCampaignChecks(campaignId ID, variables ...*PayloadVariables) ([]CampaignCheckNode, error) {
121+
var q struct {
122+
Account struct {
123+
Campaign struct {
124+
Checks campaignCheckConnection `graphql:"checks(first: $first, after: $after)"`
125+
} `graphql:"campaign(id: $id)"`
126+
}
127+
}
128+
129+
var pages *PayloadVariables
130+
if len(variables) > 0 && variables[0] != nil {
131+
pages = variables[0]
132+
} else {
133+
pages = client.InitialPageVariablesPointer()
134+
(*pages)["id"] = campaignId
135+
}
136+
137+
if err := client.Query(&q, *pages, WithName("CampaignChecksList")); err != nil {
138+
return nil, err
139+
}
140+
141+
allChecks := q.Account.Campaign.Checks.Nodes
142+
if q.Account.Campaign.Checks.PageInfo.HasNextPage {
143+
(*pages)["after"] = q.Account.Campaign.Checks.PageInfo.End
144+
resp, err := client.ListCampaignChecks(campaignId, pages)
145+
if err != nil {
146+
return nil, err
147+
}
148+
allChecks = append(allChecks, resp...)
149+
}
150+
return allChecks, nil
151+
}
152+
153+
func (client *Client) CopyChecksToCampaign(input ChecksCopyToCampaignInput) (*Campaign, error) {
154+
var m struct {
155+
Payload ChecksCopyToCampaignPayload `graphql:"checksCopyToCampaign(input: $input)"`
156+
}
157+
v := PayloadVariables{
158+
"input": input,
159+
}
160+
err := client.Mutate(&m, v, WithName("ChecksCopyToCampaign"))
161+
return &m.Payload.Campaign, HandleErrors(err, m.Payload.Errors)
162+
}
163+
28164
func (client *Client) ListCampaigns(campaignVariables *ListCampaignsVariables) (*CampaignConnection, error) {
29165
if campaignVariables == nil {
30166
campaignVariables = &ListCampaignsVariables{}

campaign_test.go

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,203 @@ import (
44
"testing"
55

66
ol "github.com/opslevel/opslevel-go/v2026"
7+
"github.com/relvacode/iso8601"
78
"github.com/rocktavious/autopilot/v2023"
89
)
910

11+
func TestCreateCampaign(t *testing.T) {
12+
// Arrange
13+
testRequest := autopilot.NewTestRequest(
14+
`{{ template "campaign_create_request" }}`,
15+
`{{ template "campaign_create_request_vars" }}`,
16+
`{{ template "campaign_create_response" }}`,
17+
)
18+
client := BestTestClient(t, "campaign/create", testRequest)
19+
20+
brief := "A test campaign"
21+
// Act
22+
campaign, err := client.CreateCampaign(ol.CampaignCreateInput{
23+
Name: "New Campaign",
24+
OwnerId: id1,
25+
FilterId: ol.RefOf(id2),
26+
ProjectBrief: &brief,
27+
})
28+
29+
// Assert
30+
autopilot.Ok(t, err)
31+
autopilot.Equals(t, "New Campaign", campaign.Name)
32+
autopilot.Equals(t, ol.CampaignStatusEnumDraft, campaign.Status)
33+
autopilot.Equals(t, id1, campaign.Owner.Id)
34+
autopilot.Equals(t, id2, campaign.Filter.Id)
35+
autopilot.Equals(t, "A test campaign", campaign.RawProjectBrief)
36+
}
37+
38+
func TestGetCampaign(t *testing.T) {
39+
// Arrange
40+
testRequest := autopilot.NewTestRequest(
41+
`{{ template "campaign_get_request" }}`,
42+
`{{ template "campaign_get_request_vars" }}`,
43+
`{{ template "campaign_get_response" }}`,
44+
)
45+
client := BestTestClient(t, "campaign/get", testRequest)
46+
47+
// Act
48+
campaign, err := client.GetCampaign(id1)
49+
50+
// Assert
51+
autopilot.Ok(t, err)
52+
autopilot.Equals(t, id1, campaign.Id)
53+
autopilot.Equals(t, "Fetched Campaign", campaign.Name)
54+
autopilot.Equals(t, ol.CampaignStatusEnumScheduled, campaign.Status)
55+
autopilot.Equals(t, "2026-05-01 00:00:00 +0000 UTC", campaign.StartDate.String())
56+
autopilot.Equals(t, "2026-06-30 00:00:00 +0000 UTC", campaign.TargetDate.String())
57+
}
58+
59+
func TestUpdateCampaign(t *testing.T) {
60+
// Arrange
61+
testRequest := autopilot.NewTestRequest(
62+
`{{ template "campaign_update_request" }}`,
63+
`{{ template "campaign_update_request_vars" }}`,
64+
`{{ template "campaign_update_response" }}`,
65+
)
66+
client := BestTestClient(t, "campaign/update", testRequest)
67+
68+
name := "Updated Campaign"
69+
// Act
70+
campaign, err := client.UpdateCampaign(ol.CampaignUpdateInput{
71+
Id: id1,
72+
Name: &name,
73+
OwnerId: ol.RefOf(id2),
74+
})
75+
76+
// Assert
77+
autopilot.Ok(t, err)
78+
autopilot.Equals(t, id1, campaign.Id)
79+
autopilot.Equals(t, "Updated Campaign", campaign.Name)
80+
autopilot.Equals(t, id2, campaign.Owner.Id)
81+
}
82+
83+
func TestDeleteCampaign(t *testing.T) {
84+
// Arrange
85+
testRequest := autopilot.NewTestRequest(
86+
`{{ template "campaign_delete_request" }}`,
87+
`{{ template "campaign_delete_request_vars" }}`,
88+
`{{ template "campaign_delete_response" }}`,
89+
)
90+
client := BestTestClient(t, "campaign/delete", testRequest)
91+
92+
// Act
93+
err := client.DeleteCampaign(id1)
94+
95+
// Assert
96+
autopilot.Ok(t, err)
97+
}
98+
99+
func TestScheduleCampaign(t *testing.T) {
100+
// Arrange
101+
testRequest := autopilot.NewTestRequest(
102+
`{{ template "campaign_schedule_request" }}`,
103+
`{{ template "campaign_schedule_request_vars" }}`,
104+
`{{ template "campaign_schedule_response" }}`,
105+
)
106+
client := BestTestClient(t, "campaign/schedule", testRequest)
107+
108+
startDate, _ := iso8601.ParseString("2026-05-01T00:00:00Z")
109+
targetDate, _ := iso8601.ParseString("2026-06-30T00:00:00Z")
110+
111+
// Act
112+
campaign, err := client.ScheduleCampaign(ol.CampaignScheduleUpdateInput{
113+
Id: id1,
114+
StartDate: iso8601.Time{Time: startDate},
115+
TargetDate: iso8601.Time{Time: targetDate},
116+
})
117+
118+
// Assert
119+
autopilot.Ok(t, err)
120+
autopilot.Equals(t, id1, campaign.Id)
121+
autopilot.Equals(t, ol.CampaignStatusEnumScheduled, campaign.Status)
122+
autopilot.Equals(t, "2026-05-01 00:00:00 +0000 UTC", campaign.StartDate.String())
123+
autopilot.Equals(t, "2026-06-30 00:00:00 +0000 UTC", campaign.TargetDate.String())
124+
}
125+
126+
func TestUnscheduleCampaign(t *testing.T) {
127+
// Arrange
128+
testRequest := autopilot.NewTestRequest(
129+
`{{ template "campaign_unschedule_request" }}`,
130+
`{{ template "campaign_unschedule_request_vars" }}`,
131+
`{{ template "campaign_unschedule_response" }}`,
132+
)
133+
client := BestTestClient(t, "campaign/unschedule", testRequest)
134+
135+
// Act
136+
campaign, err := client.UnscheduleCampaign(id1)
137+
138+
// Assert
139+
autopilot.Ok(t, err)
140+
autopilot.Equals(t, id1, campaign.Id)
141+
autopilot.Equals(t, ol.CampaignStatusEnumDraft, campaign.Status)
142+
autopilot.Equals(t, true, campaign.StartDate.IsZero())
143+
}
144+
145+
func TestCopyChecksToCampaign(t *testing.T) {
146+
// Arrange
147+
testRequest := autopilot.NewTestRequest(
148+
`{{ template "campaign_copy_checks_request" }}`,
149+
`{{ template "campaign_copy_checks_request_vars" }}`,
150+
`{{ template "campaign_copy_checks_response" }}`,
151+
)
152+
client := BestTestClient(t, "campaign/copy_checks", testRequest)
153+
154+
// Act
155+
campaign, err := client.CopyChecksToCampaign(ol.ChecksCopyToCampaignInput{
156+
CampaignId: id1,
157+
CheckIds: []ol.ID{id2, id3},
158+
})
159+
160+
// Assert
161+
autopilot.Ok(t, err)
162+
autopilot.Equals(t, id1, campaign.Id)
163+
autopilot.Equals(t, 2, campaign.CheckStats.Total)
164+
}
165+
166+
func TestListCampaignChecks(t *testing.T) {
167+
// Arrange
168+
testRequest := autopilot.NewTestRequest(
169+
`{{ template "campaign_list_checks_request" }}`,
170+
`{{ template "campaign_list_checks_request_vars" }}`,
171+
`{{ template "campaign_list_checks_response" }}`,
172+
)
173+
client := BestTestClient(t, "campaign/list_checks", testRequest)
174+
175+
// Act
176+
checks, err := client.ListCampaignChecks(id1)
177+
178+
// Assert
179+
autopilot.Ok(t, err)
180+
autopilot.Equals(t, 2, len(checks))
181+
autopilot.Equals(t, id2, checks[0].Id)
182+
autopilot.Equals(t, "Secret Rotation", checks[0].Name)
183+
autopilot.Equals(t, id3, checks[1].Id)
184+
autopilot.Equals(t, "Dependency Scanning", checks[1].Name)
185+
}
186+
187+
func TestListCampaignChecksEmpty(t *testing.T) {
188+
// Arrange
189+
testRequest := autopilot.NewTestRequest(
190+
`{{ template "campaign_list_checks_request" }}`,
191+
`{{ template "campaign_list_checks_request_vars" }}`,
192+
`{{ template "campaign_list_checks_empty_response" }}`,
193+
)
194+
client := BestTestClient(t, "campaign/list_checks_empty", testRequest)
195+
196+
// Act
197+
checks, err := client.ListCampaignChecks(id1)
198+
199+
// Assert
200+
autopilot.Ok(t, err)
201+
autopilot.Equals(t, 0, len(checks))
202+
}
203+
10204
func TestListCampaigns(t *testing.T) {
11205
// Arrange
12206
testRequestOne := autopilot.NewTestRequest(
@@ -40,7 +234,6 @@ func TestListCampaigns(t *testing.T) {
40234
autopilot.Equals(t, "2024-01-01 00:00:00 +0000 UTC", result[0].StartDate.String())
41235
}
42236

43-
// TestListCampaignsVariables_AsPayloadVariables verifies that ListCampaignsVariables produces the correct payload map.
44237
func TestListCampaignsVariables_AsPayloadVariables(t *testing.T) {
45238
after := "cursor"
46239
first := 5
@@ -61,7 +254,6 @@ func TestListCampaignsVariables_AsPayloadVariables(t *testing.T) {
61254
autopilot.Equals(t, expected, *variables)
62255
}
63256

64-
// TestListCampaignsWithCustomVariables verifies that custom ListCampaignsVariables values are sent in the GraphQL request.
65257
func TestListCampaignsWithCustomVariables(t *testing.T) {
66258
after := "cursor"
67259
first := 5

0 commit comments

Comments
 (0)