diff --git a/pkg/cmd/cmdutil/factory.go b/pkg/cmd/cmdutil/factory.go index 24fdb1a8d..663c6e93d 100644 --- a/pkg/cmd/cmdutil/factory.go +++ b/pkg/cmd/cmdutil/factory.go @@ -21,6 +21,6 @@ import ( type Factory struct { IOStreams *iostreams.IOStreams - Version string - Config func() (*config.Config, error) + Version string + Config func() (*config.Config, error) } diff --git a/pkg/cmd/finding/create/create.go b/pkg/cmd/finding/create/create.go new file mode 100644 index 000000000..4dee7a399 --- /dev/null +++ b/pkg/cmd/finding/create/create.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package create + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateFindingInput!) { + createFinding(input: $input) { + findingEdge { + node { + id + referenceId + kind + status + priority + } + } + } +} +` + +type createResponse struct { + CreateFinding struct { + FindingEdge struct { + Node struct { + ID string `json:"id"` + ReferenceID string `json:"referenceId"` + Kind string `json:"kind"` + Status string `json:"status"` + Priority string `json:"priority"` + } `json:"node"` + } `json:"findingEdge"` + } `json:"createFinding"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrganization string + flagKind string + flagDescription string + flagSource string + flagIdentifiedOn string + flagRootCause string + flagCorrectiveAction string + flagOwnerID string + flagDueDate string + flagStatus string + flagPriority string + flagRiskID string + flagEffectivenessChk string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new finding", + Example: ` # Create a finding + prb finding create --organization ORG_ID --kind NONCONFORMITY --owner-id OWNER_ID --status OPEN --priority HIGH`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateEnum("kind", flagKind, []string{"NONCONFORMITY", "OBSERVATION", "EXCEPTION"}); err != nil { + return err + } + if err := cmdutil.ValidateEnum("status", flagStatus, []string{"OPEN", "IN_PROGRESS", "CLOSED", "RISK_ACCEPTED", "MITIGATED", "FALSE_POSITIVE"}); err != nil { + return err + } + if err := cmdutil.ValidateEnum("priority", flagPriority, []string{"LOW", "MEDIUM", "HIGH"}); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "organizationId": flagOrganization, + "kind": flagKind, + "status": flagStatus, + "priority": flagPriority, + } + + if flagOwnerID != "" { + input["ownerId"] = flagOwnerID + } + if flagDescription != "" { + input["description"] = flagDescription + } + if flagSource != "" { + input["source"] = flagSource + } + if flagIdentifiedOn != "" { + input["identifiedOn"] = flagIdentifiedOn + } + if flagRootCause != "" { + input["rootCause"] = flagRootCause + } + if flagCorrectiveAction != "" { + input["correctiveAction"] = flagCorrectiveAction + } + if flagDueDate != "" { + input["dueDate"] = flagDueDate + } + if flagRiskID != "" { + input["riskId"] = flagRiskID + } + if flagEffectivenessChk != "" { + input["effectivenessCheck"] = flagEffectivenessChk + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + n := resp.CreateFinding.FindingEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created finding %s (%s)\n", + n.ID, + n.ReferenceID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrganization, "organization", "", "Organization ID (required)") + cmd.Flags().StringVar(&flagKind, "kind", "", "Finding kind: NONCONFORMITY, OBSERVATION, EXCEPTION (required)") + cmd.Flags().StringVar(&flagDescription, "description", "", "Finding description") + cmd.Flags().StringVar(&flagSource, "source", "", "Finding source") + cmd.Flags().StringVar(&flagIdentifiedOn, "identified-on", "", "Date identified (RFC3339)") + cmd.Flags().StringVar(&flagRootCause, "root-cause", "", "Root cause") + cmd.Flags().StringVar(&flagCorrectiveAction, "corrective-action", "", "Corrective action") + cmd.Flags().StringVar(&flagOwnerID, "owner-id", "", "Owner profile ID") + cmd.Flags().StringVar(&flagDueDate, "due-date", "", "Due date (RFC3339)") + cmd.Flags().StringVar(&flagStatus, "status", "", "Status: OPEN, IN_PROGRESS, CLOSED, RISK_ACCEPTED, MITIGATED, FALSE_POSITIVE (required)") + cmd.Flags().StringVar(&flagPriority, "priority", "", "Priority: LOW, MEDIUM, HIGH (required)") + cmd.Flags().StringVar(&flagRiskID, "risk-id", "", "Associated risk ID") + cmd.Flags().StringVar(&flagEffectivenessChk, "effectiveness-check", "", "Effectiveness check") + + _ = cmd.MarkFlagRequired("organization") + _ = cmd.MarkFlagRequired("kind") + _ = cmd.MarkFlagRequired("status") + _ = cmd.MarkFlagRequired("priority") + + return cmd +} diff --git a/pkg/cmd/finding/delete/delete.go b/pkg/cmd/finding/delete/delete.go new file mode 100644 index 000000000..5bb6706a7 --- /dev/null +++ b/pkg/cmd/finding/delete/delete.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteFindingInput!) { + deleteFinding(input: $input) { + deletedFindingId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a finding", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete finding: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete finding %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "findingId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted finding %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/finding/finding.go b/pkg/cmd/finding/finding.go new file mode 100644 index 000000000..9a638ad16 --- /dev/null +++ b/pkg/cmd/finding/finding.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package finding + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/finding/create" + "go.probo.inc/probo/pkg/cmd/finding/delete" + "go.probo.inc/probo/pkg/cmd/finding/list" + "go.probo.inc/probo/pkg/cmd/finding/update" + "go.probo.inc/probo/pkg/cmd/finding/view" +) + +func NewCmdFinding(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "finding ", + Short: "Manage findings", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + + return cmd +} diff --git a/pkg/cmd/finding/list/list.go b/pkg/cmd/finding/list/list.go new file mode 100644 index 000000000..9e08e6b93 --- /dev/null +++ b/pkg/cmd/finding/list/list.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: FindingOrder, $filter: FindingFilter) { + node(id: $id) { + __typename + ... on Organization { + findings(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { + totalCount + edges { + node { + id + referenceId + kind + status + priority + dueDate + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type finding struct { + ID string `json:"id"` + ReferenceID string `json:"referenceId"` + Kind string `json:"kind"` + Status string `json:"status"` + Priority string `json:"priority"` + DueDate *string `json:"dueDate"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrganization string + flagLimit int + flagOrderBy string + flagOrderDir string + flagKind string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List findings in an organization", + Aliases: []string{"ls"}, + Example: ` # List findings in an organization + prb finding list --organization + + # Filter by kind and output as JSON + prb finding ls --organization --kind NONCONFORMITY --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + variables := map[string]any{ + "id": flagOrganization, + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "REFERENCE_ID", "IDENTIFIED_ON", "DUE_DATE", "STATUS", "PRIORITY", "KIND"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + filter := map[string]any{} + if flagKind != "" { + if err := cmdutil.ValidateEnum("kind", flagKind, []string{"NONCONFORMITY", "OBSERVATION", "EXCEPTION"}); err != nil { + return err + } + filter["kind"] = flagKind + } + if len(filter) > 0 { + variables["filter"] = filter + } + + findings, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[finding], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Findings api.Connection[finding] `json:"findings"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrganization) + } + if resp.Node.Typename != "Organization" { + return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) + } + return &resp.Node.Findings, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, findings) + } + + if len(findings) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No findings found.") + return nil + } + + rows := make([][]string, 0, len(findings)) + for _, fi := range findings { + dueDate := "-" + if fi.DueDate != nil { + dueDate = cmdutil.FormatTime(*fi.DueDate) + } + rows = append(rows, []string{ + fi.ID, + fi.ReferenceID, + fi.Kind, + fi.Status, + fi.Priority, + dueDate, + }) + } + + t := cmdutil.NewTable("ID", "REFERENCE", "KIND", "STATUS", "PRIORITY", "DUE DATE").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(findings) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d findings\n", + len(findings), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrganization, "organization", "", "Organization ID (required)") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of findings to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, REFERENCE_ID, IDENTIFIED_ON, DUE_DATE, STATUS, PRIORITY, KIND)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().StringVar(&flagKind, "kind", "", "Filter by kind (NONCONFORMITY, OBSERVATION, EXCEPTION)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + _ = cmd.MarkFlagRequired("organization") + + return cmd +} diff --git a/pkg/cmd/finding/update/update.go b/pkg/cmd/finding/update/update.go new file mode 100644 index 000000000..a907fb9fa --- /dev/null +++ b/pkg/cmd/finding/update/update.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateFindingInput!) { + updateFinding(input: $input) { + finding { + id + referenceId + kind + status + priority + } + } +} +` + +type updateResponse struct { + UpdateFinding struct { + Finding struct { + ID string `json:"id"` + ReferenceID string `json:"referenceId"` + Kind string `json:"kind"` + Status string `json:"status"` + Priority string `json:"priority"` + } `json:"finding"` + } `json:"updateFinding"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagDescription string + flagSource string + flagIdentifiedOn string + flagRootCause string + flagCorrectiveAction string + flagOwnerID string + flagDueDate string + flagStatus string + flagPriority string + flagRiskID string + flagEffectivenessChk string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a finding", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "id": args[0], + } + + if cmd.Flags().Changed("description") { + if flagDescription == "" { + input["description"] = nil + } else { + input["description"] = flagDescription + } + } + if cmd.Flags().Changed("source") { + if flagSource == "" { + input["source"] = nil + } else { + input["source"] = flagSource + } + } + if cmd.Flags().Changed("identified-on") { + if flagIdentifiedOn == "" { + input["identifiedOn"] = nil + } else { + input["identifiedOn"] = flagIdentifiedOn + } + } + if cmd.Flags().Changed("root-cause") { + if flagRootCause == "" { + input["rootCause"] = nil + } else { + input["rootCause"] = flagRootCause + } + } + if cmd.Flags().Changed("corrective-action") { + if flagCorrectiveAction == "" { + input["correctiveAction"] = nil + } else { + input["correctiveAction"] = flagCorrectiveAction + } + } + if cmd.Flags().Changed("owner-id") { + input["ownerId"] = flagOwnerID + } + if cmd.Flags().Changed("due-date") { + if flagDueDate == "" { + input["dueDate"] = nil + } else { + input["dueDate"] = flagDueDate + } + } + if cmd.Flags().Changed("status") { + if err := cmdutil.ValidateEnum("status", flagStatus, []string{"OPEN", "IN_PROGRESS", "CLOSED", "RISK_ACCEPTED", "MITIGATED", "FALSE_POSITIVE"}); err != nil { + return err + } + input["status"] = flagStatus + } + if cmd.Flags().Changed("priority") { + if err := cmdutil.ValidateEnum("priority", flagPriority, []string{"LOW", "MEDIUM", "HIGH"}); err != nil { + return err + } + input["priority"] = flagPriority + } + if cmd.Flags().Changed("risk-id") { + if flagRiskID == "" { + input["riskId"] = nil + } else { + input["riskId"] = flagRiskID + } + } + if cmd.Flags().Changed("effectiveness-check") { + if flagEffectivenessChk == "" { + input["effectivenessCheck"] = nil + } else { + input["effectivenessCheck"] = flagEffectivenessChk + } + } + + if len(input) == 1 { + return fmt.Errorf("at least one field must be specified for update") + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + fi := resp.UpdateFinding.Finding + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated finding %s (%s)\n", + fi.ID, + fi.ReferenceID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagDescription, "description", "", "Finding description") + cmd.Flags().StringVar(&flagSource, "source", "", "Finding source") + cmd.Flags().StringVar(&flagIdentifiedOn, "identified-on", "", "Date identified (RFC3339)") + cmd.Flags().StringVar(&flagRootCause, "root-cause", "", "Root cause") + cmd.Flags().StringVar(&flagCorrectiveAction, "corrective-action", "", "Corrective action") + cmd.Flags().StringVar(&flagOwnerID, "owner-id", "", "Owner profile ID") + cmd.Flags().StringVar(&flagDueDate, "due-date", "", "Due date (RFC3339)") + cmd.Flags().StringVar(&flagStatus, "status", "", "Status: OPEN, IN_PROGRESS, CLOSED, RISK_ACCEPTED, MITIGATED, FALSE_POSITIVE") + cmd.Flags().StringVar(&flagPriority, "priority", "", "Priority: LOW, MEDIUM, HIGH") + cmd.Flags().StringVar(&flagRiskID, "risk-id", "", "Associated risk ID") + cmd.Flags().StringVar(&flagEffectivenessChk, "effectiveness-check", "", "Effectiveness check") + + return cmd +} diff --git a/pkg/cmd/finding/view/view.go b/pkg/cmd/finding/view/view.go new file mode 100644 index 000000000..764496d83 --- /dev/null +++ b/pkg/cmd/finding/view/view.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package view + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const viewQuery = ` +query($id: ID!) { + node(id: $id) { + __typename + ... on Finding { + id + referenceId + kind + description + source + identifiedOn + rootCause + correctiveAction + owner { + id + fullName + } + dueDate + status + priority + risk { + id + name + } + effectivenessCheck + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + ReferenceID string `json:"referenceId"` + Kind string `json:"kind"` + Description *string `json:"description"` + Source *string `json:"source"` + IdentifiedOn *string `json:"identifiedOn"` + RootCause *string `json:"rootCause"` + CorrectiveAction *string `json:"correctiveAction"` + Owner struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + DueDate *string `json:"dueDate"` + Status string `json:"status"` + Priority string `json:"priority"` + Risk *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"risk"` + EffectivenessCheck *string `json:"effectivenessCheck"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` +} + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var flagOutput *string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View a finding", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + viewQuery, + map[string]any{"id": args[0]}, + ) + if err != nil { + return err + } + + var resp viewResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if resp.Node == nil { + return fmt.Errorf("finding %s not found", args[0]) + } + + if resp.Node.Typename != "Finding" { + return fmt.Errorf("expected Finding node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + n := resp.Node + out := f.IOStreams.Out + + bold := lipgloss.NewStyle().Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(n.ReferenceID)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), n.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Kind:"), n.Kind) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Status:"), n.Status) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Priority:"), n.Priority) + _, _ = fmt.Fprintf(out, "%s%s (%s)\n", label.Render("Owner:"), n.Owner.FullName, n.Owner.ID) + + if n.Description != nil && *n.Description != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *n.Description) + } + if n.Source != nil && *n.Source != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Source:"), *n.Source) + } + if n.IdentifiedOn != nil && *n.IdentifiedOn != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Identified On:"), cmdutil.FormatTime(*n.IdentifiedOn)) + } + if n.DueDate != nil && *n.DueDate != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Due Date:"), cmdutil.FormatTime(*n.DueDate)) + } + if n.RootCause != nil && *n.RootCause != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Root Cause:"), *n.RootCause) + } + if n.CorrectiveAction != nil && *n.CorrectiveAction != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Corrective Action:"), *n.CorrectiveAction) + } + if n.Risk != nil { + _, _ = fmt.Fprintf(out, "%s%s (%s)\n", label.Render("Risk:"), n.Risk.Name, n.Risk.ID) + } + if n.EffectivenessCheck != nil && *n.EffectivenessCheck != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Effectiveness Check:"), *n.EffectivenessCheck) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(n.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(n.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 08ced0d5e..5b2fcf5c8 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -23,6 +23,7 @@ import ( "go.probo.inc/probo/pkg/cmd/completion" cmdconfig "go.probo.inc/probo/pkg/cmd/config" "go.probo.inc/probo/pkg/cmd/control" + "go.probo.inc/probo/pkg/cmd/finding" "go.probo.inc/probo/pkg/cmd/framework" "go.probo.inc/probo/pkg/cmd/org" "go.probo.inc/probo/pkg/cmd/risk" @@ -68,6 +69,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(completion.NewCmdCompletion(f)) cmd.AddCommand(cmdconfig.NewCmdConfig(f)) cmd.AddCommand(control.NewCmdControl(f)) + cmd.AddCommand(finding.NewCmdFinding(f)) cmd.AddCommand(framework.NewCmdFramework(f)) cmd.AddCommand(org.NewCmdOrg(f)) cmd.AddCommand(risk.NewCmdRisk(f))