diff --git a/cmd/cli/app/repo/repo_register.go b/cmd/cli/app/repo/repo_register.go index 932e2fe60b..57cbefd0fb 100644 --- a/cmd/cli/app/repo/repo_register.go +++ b/cmd/cli/app/repo/repo_register.go @@ -34,10 +34,6 @@ var repoRegisterCmd = &cobra.Command{ return fmt.Errorf("cannot use --name and --all together") } - if len(inputRepoList) == 0 && !registerAll { - return fmt.Errorf("must provide either --name or --all") - } - return nil }, diff --git a/cmd/cli/app/ruletype/ruletype_delete.go b/cmd/cli/app/ruletype/ruletype_delete.go index 303d3cc411..e77aafebce 100644 --- a/cmd/cli/app/ruletype/ruletype_delete.go +++ b/cmd/cli/app/ruletype/ruletype_delete.go @@ -34,6 +34,7 @@ func deleteCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn *gr project := viper.GetString("project") id := viper.GetString("id") + name := viper.GetString("name") deleteAll := viper.GetBool("all") yesFlag := viper.GetBool("yes") @@ -55,17 +56,31 @@ func deleteCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn *gr // List of rule types to delete var rulesToDelete []*minderv1.RuleType + if !deleteAll { - // Fetch the rule type from the DB, so we can get its name - rtype, err := client.GetRuleTypeById(ctx, &minderv1.GetRuleTypeByIdRequest{ - Context: &minderv1.Context{Project: &project}, - Id: id, - }) - if err != nil { - return cli.MessageAndError("Error getting rule type", err) + // Fetch the rule type from the DB by either ID or Name + if id != "" { + rtype, err := client.GetRuleTypeById(ctx, &minderv1.GetRuleTypeByIdRequest{ + Context: &minderv1.Context{Project: &project}, + Id: id, + }) + if err != nil { + return cli.MessageAndError("Error getting rule type by id", err) + } + rulesToDelete = append(rulesToDelete, rtype.RuleType) } - // Add the rule type for deletion - rulesToDelete = append(rulesToDelete, rtype.RuleType) + + if name != "" { + rtype, err := client.GetRuleTypeByName(ctx, &minderv1.GetRuleTypeByNameRequest{ + Context: &minderv1.Context{Project: &project}, + Name: name, + }) + if err != nil { + return cli.MessageAndError("Error getting rule type by name", err) + } + rulesToDelete = append(rulesToDelete, rtype.RuleType) + } + } else { // List all rule types resp, err := client.ListRuleTypes(ctx, &minderv1.ListRuleTypesRequest{ @@ -174,11 +189,10 @@ func init() { ruleTypeCmd.AddCommand(deleteCmd) // Flags deleteCmd.Flags().StringP("id", "i", "", "ID of rule type to delete") + deleteCmd.Flags().StringP("name", "n", "", "Name of rule type to delete") deleteCmd.Flags().BoolP("all", "a", false, "Warning: Deletes all rule types") deleteCmd.Flags().BoolP("yes", "y", false, "Bypass yes/no prompt when deleting all rule types") - // TODO: add a flag for the rule type name // Exclusive - deleteCmd.MarkFlagsOneRequired("id", "all") - deleteCmd.MarkFlagsMutuallyExclusive("id", "all") - + deleteCmd.MarkFlagsOneRequired("id", "name", "all") + deleteCmd.MarkFlagsMutuallyExclusive("id", "name", "all") } diff --git a/docs/docs/ref/cli/minder_ruletype_delete.md b/docs/docs/ref/cli/minder_ruletype_delete.md index 76d38ac501..b6d79b99fe 100644 --- a/docs/docs/ref/cli/minder_ruletype_delete.md +++ b/docs/docs/ref/cli/minder_ruletype_delete.md @@ -16,10 +16,11 @@ minder ruletype delete [flags] ### Options ``` - -a, --all Warning: Deletes all rule types - -h, --help help for delete - -i, --id string ID of rule type to delete - -y, --yes Bypass yes/no prompt when deleting all rule types + -a, --all Warning: Deletes all rule types + -h, --help help for delete + -i, --id string ID of rule type to delete + -n, --name string Name of rule type to delete + -y, --yes Bypass yes/no prompt when deleting all rule types ``` ### Options inherited from parent commands diff --git a/internal/controlplane/handlers_profile_test.go b/internal/controlplane/handlers_profile_test.go index e6eec5abfb..5ab053ec1d 100644 --- a/internal/controlplane/handlers_profile_test.go +++ b/internal/controlplane/handlers_profile_test.go @@ -1051,10 +1051,6 @@ func TestPatchProfile(t *testing.T) { Name: tc.baseProfile.GetProfile().GetName(), ProjectID: dbproj.ID, }) - if err != nil { - t.Fatalf("Error getting profile: %v", err) - } - if err != nil { t.Fatalf("Error getting profile: %v", err) } diff --git a/internal/controlplane/handlers_repositories_test.go b/internal/controlplane/handlers_repositories_test.go index 26abe50a8b..ce7f31e8ae 100644 --- a/internal/controlplane/handlers_repositories_test.go +++ b/internal/controlplane/handlers_repositories_test.go @@ -531,7 +531,6 @@ const ( remoteRepoId int64 = 123456 repoName2 = "another-repo" remoteRepoId2 int64 = 234567 - accessToken = "TOKEN" ) var ( diff --git a/internal/db/domain.go b/internal/db/domain.go index 8a54e89ee5..c4685a1933 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -6,9 +6,10 @@ package db import ( "slices" - "strings" "github.com/sqlc-dev/pqtype" + + "github.com/mindersec/minder/internal/labels" ) // This file contains domain-level methods for db structs @@ -48,27 +49,7 @@ func (r ListProfilesByProjectIDAndLabelRow) GetSelectors() []ProfileSelector { // LabelsFromFilter parses the filter string and populates the IncludeLabels and ExcludeLabels fields func (lp *ListProfilesByProjectIDAndLabelParams) LabelsFromFilter(filter string) { - // If s does not contain sep and sep is not empty, Split returns a - // slice of length 1 whose only element is s. Work around that by - // returning early if filter is empty. - if filter == "" { - return - } - - var starMatched bool - for _, label := range strings.Split(filter, ",") { - switch { - case label == "*": - starMatched = true - case strings.HasPrefix(label, "!"): - // if the label starts with a "!", it is a negative filter, add it to the negative list - lp.ExcludeLabels = append(lp.ExcludeLabels, label[1:]) - default: - lp.IncludeLabels = append(lp.IncludeLabels, label) - } - } - - if starMatched { - lp.IncludeLabels = []string{"*"} - } + inc, exc := labels.ParseLabelFilter(filter) + lp.IncludeLabels = inc + lp.ExcludeLabels = exc } diff --git a/internal/engine/actions/actions_test.go b/internal/engine/actions/actions_test.go index 7d9dc8c90d..17ed1d7283 100644 --- a/internal/engine/actions/actions_test.go +++ b/internal/engine/actions/actions_test.go @@ -11,8 +11,8 @@ import ( "github.com/mindersec/minder/internal/db" "github.com/mindersec/minder/internal/engine/actions/remediate/pull_request" - enginerr "github.com/mindersec/minder/internal/engine/errors" engif "github.com/mindersec/minder/internal/engine/interfaces" + enginerr "github.com/mindersec/minder/pkg/engine/errors" ) func TestShouldRemediate(t *testing.T) { diff --git a/internal/events/nats/natschannel.go b/internal/events/nats/natschannel.go index 57460699e2..cba0c45a25 100644 --- a/internal/events/nats/natschannel.go +++ b/internal/events/nats/natschannel.go @@ -206,7 +206,15 @@ func sendEvent( event.SetID(msg.UUID) event.SetType(eventType) event.SetSource("minder") // The system which generated the event. The Minder URL would be nice here. - event.SetSubject("TODO") // This *should* represent the entity, but we don't have a standard field for it yet. + subject := "" + + if val, ok := msg.Metadata["entity_id"]; ok && val != "" { + subject = val + } else { + subject = "minder" + } + + event.SetSubject(subject) // All our current payloads are encoded JSON; we need to unmarshal payload := map[string]any{} diff --git a/internal/history/models.go b/internal/history/models.go index 9638bada61..099a00bddd 100644 --- a/internal/history/models.go +++ b/internal/history/models.go @@ -16,6 +16,7 @@ import ( "github.com/mindersec/minder/internal/db" em "github.com/mindersec/minder/internal/entities/models" + "github.com/mindersec/minder/internal/labels" ) var ( @@ -375,22 +376,21 @@ func (filter *listEvaluationFilter) ExcludedProfileNames() []string { } func (filter *listEvaluationFilter) AddLabel(label string) error { - if label == "!*" { - return fmt.Errorf("%w: label", ErrInvalidIdentifier) - } - if label == "*" && len(filter.includedLabels) != 0 { - return fmt.Errorf("%w: label", ErrInvalidIdentifier) + inc, exc := labels.ParseLabel(label) + + if inc != "" { + filter.includedLabels = append(filter.includedLabels, inc) } - if strings.HasPrefix(label, "!") { - label = strings.Split(label, "!")[1] // guaranteed to exist - filter.excludedLabels = append(filter.excludedLabels, label) - } else { - filter.includedLabels = append(filter.includedLabels, label) + if exc != "" { + filter.excludedLabels = append(filter.excludedLabels, exc) } return nil } func (filter *listEvaluationFilter) IncludedLabels() []string { + if slices.Contains(filter.includedLabels, "*") { + return []string{"*"} + } return filter.includedLabels } func (filter *listEvaluationFilter) ExcludedLabels() []string { diff --git a/internal/history/service.go b/internal/history/service.go index b18f426c0d..652ab3a0c5 100644 --- a/internal/history/service.go +++ b/internal/history/service.go @@ -398,7 +398,9 @@ func paramsFromLabelFilter( if len(filter.IncludedLabels()) != 0 { params.Labels = filter.IncludedLabels() } - // We do not exclude based on labels + if len(filter.ExcludedLabels()) != 0 { + params.Notlabels = filter.ExcludedLabels() + } return nil } diff --git a/internal/labels/labels.go b/internal/labels/labels.go new file mode 100644 index 0000000000..b25f02b542 --- /dev/null +++ b/internal/labels/labels.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package labels contains logic for parsing label filters. +package labels + +import ( + "strings" +) + +// ParseLabelFilter parses a comma-separated label filter string into lists of +// labels to include and exclude. It resolves wildcards so that if any inclusion +// rule is `*`, the included labels list evaluates simply to `["*"]`. +func ParseLabelFilter(filter string) (include []string, exclude []string) { + if filter == "" { + return nil, nil + } + + var starMatched bool + for _, label := range strings.Split(filter, ",") { + label = strings.TrimSpace(label) + if label == "" { + continue + } + + inc, exc := ParseLabel(label) + + if inc != "" { + if inc == "*" { + starMatched = true + } else { + include = append(include, inc) + } + } + if exc != "" { + exclude = append(exclude, exc) + } + } + + if starMatched { + include = []string{"*"} + } + + return include, exclude +} + +// ParseLabel parses a single label (without commas) into an include or exclude string. +// Returns the include label (if any) and the exclude label (if any). +func ParseLabel(label string) (include string, exclude string) { + if strings.HasPrefix(label, "!") { + return "", strings.TrimPrefix(label, "!") + } + return label, "" +} diff --git a/internal/labels/labels_test.go b/internal/labels/labels_test.go new file mode 100644 index 0000000000..0fd03c0bde --- /dev/null +++ b/internal/labels/labels_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package labels + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseLabelFilter(t *testing.T) { + t.Parallel() + tests := []struct { + name string + filter string + expectedInc []string + expectedExc []string + }{ + { + name: "empty", + filter: "", + }, + { + name: "single include", + filter: "foo", + expectedInc: []string{"foo"}, + }, + { + name: "single exclude", + filter: "!foo", + expectedExc: []string{"foo"}, + }, + { + name: "star include", + filter: "*", + expectedInc: []string{"*"}, + }, + { + name: "star exclude", + filter: "!*", + expectedExc: []string{"*"}, + }, + { + name: "multiple includes", + filter: "foo,bar", + expectedInc: []string{"foo", "bar"}, + }, + { + name: "includes and excludes", + filter: "foo,!bar,baz,!qux", + expectedInc: []string{"foo", "baz"}, + expectedExc: []string{"bar", "qux"}, + }, + { + name: "star mixed with includes", + filter: "foo,*", + expectedInc: []string{"*"}, + }, + { + name: "includes mixed with star", + filter: "*,foo", + expectedInc: []string{"*"}, + }, + { + name: "star and excludes", + filter: "*,!foo", + expectedInc: []string{"*"}, + expectedExc: []string{"foo"}, + }, + { + name: "whitespace handling", + filter: " foo , !bar ", + expectedInc: []string{"foo"}, + expectedExc: []string{"bar"}, + }, + { + name: "trailing commas", + filter: "foo,,!bar,", + expectedInc: []string{"foo"}, + expectedExc: []string{"bar"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + inc, exc := ParseLabelFilter(tt.filter) + require.Equal(t, tt.expectedInc, inc) + require.Equal(t, tt.expectedExc, exc) + }) + } +} diff --git a/internal/util/cli/multi_select.go b/internal/util/cli/multi_select.go index d56d75149e..d27e8b70d6 100644 --- a/internal/util/cli/multi_select.go +++ b/internal/util/cli/multi_select.go @@ -122,9 +122,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c", "enter": return m, tea.Quit case " ": - idx := m.list.Index() oldItem := m.list.SelectedItem().(item) - cmd := m.list.SetItem(idx, item{ + + // find absolute index in the underlying list + var absoluteIdx int + for i, listItem := range m.list.Items() { + if listItem.(item).title == oldItem.title { + absoluteIdx = i + break + } + } + + // use absolute index to update item + cmd := m.list.SetItem(absoluteIdx, item{ title: oldItem.title, checked: !oldItem.checked, })