From f8dcb6c014f24d03931f70da13aaf81e9d3df2cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 12:48:12 +0000 Subject: [PATCH] Add errorx.AnyOf for sentinel error checks Centralize errors.Is OR chains in access review campaign resolvers and expose CampaignStatusErrors for the four non-draft status sentinels. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- pkg/accessreview/errors.go | 8 ++ pkg/errorx/anyof.go | 34 ++++++++ pkg/errorx/anyof_test.go | 84 +++++++++++++++++++ .../v1/access_review_campaign_resolvers.go | 18 ++-- 4 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 pkg/errorx/anyof.go create mode 100644 pkg/errorx/anyof_test.go diff --git a/pkg/accessreview/errors.go b/pkg/accessreview/errors.go index d6259a090..b50f9f87d 100644 --- a/pkg/accessreview/errors.go +++ b/pkg/accessreview/errors.go @@ -34,6 +34,14 @@ var ( ErrCampaignCancelled = errors.New("campaign is cancelled") ) +// CampaignStatusErrors are client-facing invalid-state errors for non-draft campaigns. +var CampaignStatusErrors = []error{ + ErrCampaignInProgress, + ErrCampaignPendingActions, + ErrCampaignCompleted, + ErrCampaignCancelled, +} + func CampaignStatusError(status coredata.AccessReviewCampaignStatus) error { switch status { case coredata.AccessReviewCampaignStatusInProgress: diff --git a/pkg/errorx/anyof.go b/pkg/errorx/anyof.go new file mode 100644 index 000000000..eebc8b111 --- /dev/null +++ b/pkg/errorx/anyof.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package errorx + +import "errors" + +// AnyOf reports whether err matches any of targets via errors.Is. +func AnyOf(err error, targets ...error) bool { + for _, target := range targets { + if errors.Is(err, target) { + return true + } + } + + return false +} diff --git a/pkg/errorx/anyof_test.go b/pkg/errorx/anyof_test.go new file mode 100644 index 000000000..7f5fbd265 --- /dev/null +++ b/pkg/errorx/anyof_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package errorx_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/errorx" +) + +func TestAnyOf(t *testing.T) { + t.Parallel() + + sentinelA := errors.New("a") + sentinelB := errors.New("b") + sentinelC := errors.New("c") + + tests := []struct { + name string + err error + targets []error + want bool + }{ + { + name: "nil error", + err: nil, + targets: []error{sentinelA}, + want: false, + }, + { + name: "no targets", + err: sentinelA, + targets: nil, + want: false, + }, + { + name: "direct match", + err: sentinelA, + targets: []error{sentinelA}, + want: true, + }, + { + name: "wrapped match", + err: fmt.Errorf("outer: %w", sentinelB), + targets: []error{sentinelA, sentinelB}, + want: true, + }, + { + name: "no match", + err: fmt.Errorf("outer: %w", sentinelC), + targets: []error{sentinelA, sentinelB}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, errorx.AnyOf(tt.err, tt.targets...)) + }) + } +} diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 4c04723cb..b594bdab5 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -14,6 +14,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/errorx" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" @@ -760,10 +761,7 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input return nil, gqlutils.NotFound(ctx, err) } - if errors.Is(err, accessreview.ErrCampaignInProgress) || - errors.Is(err, accessreview.ErrCampaignPendingActions) || - errors.Is(err, accessreview.ErrCampaignCompleted) || - errors.Is(err, accessreview.ErrCampaignCancelled) { + if errorx.AnyOf(err, accessreview.CampaignStatusErrors...) { return nil, gqlutils.Invalid(ctx, err) } @@ -808,11 +806,8 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID) if err != nil { - if errors.Is(err, accessreview.ErrCampaignMissingSources) || - errors.Is(err, accessreview.ErrCampaignInProgress) || - errors.Is(err, accessreview.ErrCampaignPendingActions) || - errors.Is(err, accessreview.ErrCampaignCompleted) || - errors.Is(err, accessreview.ErrCampaignCancelled) { + if errorx.AnyOf(err, accessreview.ErrCampaignMissingSources) || + errorx.AnyOf(err, accessreview.CampaignStatusErrors...) { return nil, gqlutils.Invalid(ctx, err) } @@ -884,10 +879,7 @@ func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, in return nil, gqlutils.NotFound(ctx, err) } - if errors.Is(err, accessreview.ErrCampaignInProgress) || - errors.Is(err, accessreview.ErrCampaignPendingActions) || - errors.Is(err, accessreview.ErrCampaignCompleted) || - errors.Is(err, accessreview.ErrCampaignCancelled) { + if errorx.AnyOf(err, accessreview.CampaignStatusErrors...) { return nil, gqlutils.Invalid(ctx, err) }