Skip to content

Add campaign check sync support - #644

Merged
andrewstillv15 merged 7 commits into
mainfrom
andrew/MATTER-86/campaign-check-sync
Aug 27, 2026
Merged

andrewstillv15 merged 7 commits into
mainfrom
andrew/MATTER-86/campaign-check-sync

Conversation

@andrewstillv15

@andrewstillv15 andrewstillv15 commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

MATTER-86

Summary

  • Add Check.Campaign via a new lightweight CampaignId type
  • Return created checks from CopyChecksToCampaign, and add SourceCheck to campaign check nodes
  • Add ChecksCopyToCampaignPayload.CreatedChecks (generated) and Check.SourceCheck (generated)
  • Fix EnableOn being silently dropped when converting check inputs

Test plan

  • go build ./...
  • go test ./...

Had claude write a manual test:

  • Creates a check
  • creates a campaign
  • copies the check to the campaign
  • validates the check was copied & referenced & that the copy is returned in the result
  • deletes the original check
  • validates that the copy no longer references the original check.
func testCopyChecksToCampaign(client *opslevel.Client) {
	teams, err := client.ListTeams(nil)
	if err != nil || len(teams.Nodes) == 0 {
		fmt.Fprintln(os.Stderr, "list teams failed:", err)
		return
	}
	owner := teams.Nodes[0]
	fmt.Printf("owner team: %-40s %s\n", owner.Name, owner.Id)

	categories, err := client.ListCategories(nil)
	if err != nil || len(categories.Nodes) == 0 {
		fmt.Fprintln(os.Stderr, "list categories failed:", err)
		return
	}
	category := categories.Nodes[0]
	fmt.Printf("category: %-40s %s\n", category.Name, category.Id)

	levels, err := client.ListLevels(nil)
	if err != nil || len(levels.Nodes) == 0 {
		fmt.Fprintln(os.Stderr, "list levels failed:", err)
		return
	}
	for _, l := range levels.Nodes {
		fmt.Printf("available level: %-40s %s index=%d\n", l.Name, l.Id, l.Index)
	}
	level := levels.Nodes[len(levels.Nodes)-1]
	fmt.Printf("level: %-40s %s\n", level.Name, level.Id)

	sourceCheck, err := client.CreateCheckServiceProperty(opslevel.CheckServicePropertyCreateInput{
		Name:            "manual-test source check",
		CategoryId:      category.Id,
		LevelId:         level.Id,
		ServiceProperty: opslevel.ServicePropertyTypeEnumLanguage,
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, "create service property check failed:", err)
		return
	}
	fmt.Printf("created source check: %-40s %s\n", sourceCheck.Name, sourceCheck.Id)

	campaign, err := client.CreateCampaign(opslevel.CampaignCreateInput{
		Name:    "manual-test campaign",
		OwnerId: owner.Id,
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, "create campaign failed:", err)
		return
	}
	fmt.Printf("created campaign: %-40s %s\n", campaign.Name, campaign.Id)

	_, created, err := client.CopyChecksToCampaign(opslevel.ChecksCopyToCampaignInput{
		CampaignId: campaign.Id,
		CheckIds:   []opslevel.ID{sourceCheck.Id},
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, "copy checks to campaign failed:", err)
		return
	}
	for _, check := range created {
		fmt.Printf("  created check: %-40s %s\n", check.Name, check.Id)
	}

	fmt.Println("validating sourceCheck before deletion...")
	if err := checkCampaignChecksSourceCheck(client, campaign.Id, sourceCheck.Id); err != nil {
		fmt.Fprintln(os.Stderr, err)
		return
	}

	if err := client.DeleteCheck(sourceCheck.Id); err != nil {
		fmt.Fprintln(os.Stderr, "delete source check failed:", err)
		return
	}
	fmt.Printf("deleted source check: %s\n", sourceCheck.Id)

	fmt.Println("validating sourceCheck after deletion...")
	if err := checkCampaignChecksSourceCheck(client, campaign.Id, ""); err != nil {
		fmt.Fprintln(os.Stderr, err)
		return
	}
}

// checkCampaignChecksSourceCheck lists a campaign's checks and verifies every
// check's SourceCheck.Id matches wantSourceCheckId (use "" to assert null).
func checkCampaignChecksSourceCheck(client *opslevel.Client, campaignId opslevel.ID, wantSourceCheckId opslevel.ID) error {
	campaignChecks, err := client.ListCampaignChecks(campaignId)
	if err != nil {
		return fmt.Errorf("list campaign checks failed: %w", err)
	}
	for _, check := range campaignChecks {
		fmt.Printf("  campaign check: %-40s id=%s sourceCheck.Id=%q sourceCheck.Name=%q\n",
			check.Name, check.Id, check.SourceCheck.Id, check.SourceCheck.Name)
		if check.SourceCheck.Id != wantSourceCheckId {
			return fmt.Errorf("FAIL: campaign check %s has sourceCheck.Id=%q, want %q",
				check.Id, check.SourceCheck.Id, wantSourceCheckId)
		}
		fmt.Println("    OK")
	}
	return nil
}

andrewstillv15 and others added 7 commits August 25, 2026 12:22
Regenerated against the current public GraphQL schema, which now exposes
sourceCheck on Check (jklabsinc/OpsLevel!19828) -- the check a campaign
copy was made from. Uses the existing lightweight CheckId type, same
pattern as other Check-returning-Check-shaped fields, to avoid unbounded
recursive selection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Regenerated against the schema now exposing createdChecks on
checksCopyToCampaign (jklabsinc/OpsLevel!19906) -- the checks that a
given copy call created, as opposed to campaign.checks which is every
check on the campaign.

The Terraform provider needs this to identify the check it just created.
Matching campaign checks on sourceCheck is ambiguous whenever two copies
of the same source check exist on one campaign, which happens without any
concurrency if a copy was previously made via the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ampaign check nodes

CopyChecksToCampaign now returns (*Campaign, []Check, error). The created
checks identify the new copies unambiguously; matching campaign checks on
sourceCheck cannot, because a campaign may already hold another copy of the
same source check -- which happens with no concurrency at all if a copy was
previously made through the UI.

This is a breaking signature change. It follows the client's existing
convention for payloads carrying more than one useful field, as
RunnerGetPendingJob does with (*RunnerJob, ID, error), rather than exposing
the payload type, which no method in this client does.

CampaignCheckNode also gains SourceCheck so ListCampaignChecks can report
where each campaign check was copied from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Terraform provider's opslevel_campaign_check resource needs the campaign
a copied check belongs to, so that importing a campaign check can take a bare
check id rather than a composite campaign_id:check_id string.

Check.Campaign is typed as CampaignId rather than Campaign. The full type
would drag the entire campaign selection -- checkStats, reminder and its nine
subfields, both stat blocks -- into every check read, per check, per page, to
obtain one id. It would also terminate only by accident: Campaign has no
Checks field today, but adding one would close a Check -> Campaign -> Checks
-> Check cycle and blow up every check query. CampaignId cannot participate in
such a cycle.

Campaign now embeds CampaignId rather than declaring its own Id and Name,
following the same pattern as CustomActionsExternalAction and CustomActionsId.
Both fields remain accessible through promotion, so this is not a breaking
change for consumers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CheckCreateInput.EnableOn and CheckUpdateInput.EnableOn both carried
mapstructure:"enabledOn" while their JSON tags were "enableOn". The
type-specific check inputs they are decoded into have no mapstructure tag, so
they match on field name EnableOn. mapstructure compares case-insensitively,
and "enabledon" != "enableon", so NewCheckCreateInputTypeOf and
NewCheckUpdateInputTypeOf silently discarded the value -- no error, no
warning, the field simply never arrived.

The failure mode is the damaging kind: the mutation succeeds without the
field, so a caller sees success while the value never reached the API.

Latent until now, since both helpers were only exercised by tests and no test
asserted on EnableOn. The Terraform provider's forthcoming
opslevel_campaign_check resource is the first real consumer, and enable_on is
part of its editable surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@andrewstillv15
andrewstillv15 merged commit 74f41a9 into main Aug 27, 2026
4 checks passed
@andrewstillv15
andrewstillv15 deleted the andrew/MATTER-86/campaign-check-sync branch August 27, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants