From 5a230278b267a1c1bc781a5aa01ddbb861a4dba5 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sat, 14 Mar 2026 23:34:56 +0100 Subject: [PATCH] Add proboctl risk commands Add create, list, view, update, and delete commands for managing risks. Signed-off-by: Bryan Frimin --- pkg/cmd/risk/create/create.go | 230 ++++++++++++++++++++++++++++++++++ pkg/cmd/risk/delete/delete.go | 102 +++++++++++++++ pkg/cmd/risk/list/list.go | 201 +++++++++++++++++++++++++++++ pkg/cmd/risk/risk.go | 40 ++++++ pkg/cmd/risk/update/update.go | 170 +++++++++++++++++++++++++ pkg/cmd/risk/view/view.go | 168 +++++++++++++++++++++++++ 6 files changed, 911 insertions(+) create mode 100644 pkg/cmd/risk/create/create.go create mode 100644 pkg/cmd/risk/delete/delete.go create mode 100644 pkg/cmd/risk/list/list.go create mode 100644 pkg/cmd/risk/risk.go create mode 100644 pkg/cmd/risk/update/update.go create mode 100644 pkg/cmd/risk/view/view.go diff --git a/pkg/cmd/risk/create/create.go b/pkg/cmd/risk/create/create.go new file mode 100644 index 000000000..5f1dddb56 --- /dev/null +++ b/pkg/cmd/risk/create/create.go @@ -0,0 +1,230 @@ +// 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/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateRiskInput!) { + createRisk(input: $input) { + riskEdge { + node { + id + name + category + treatment + inherentLikelihood + inherentImpact + inherentRiskScore + residualLikelihood + residualImpact + residualRiskScore + } + } + } +} +` + +type createResponse struct { + CreateRisk struct { + RiskEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Treatment string `json:"treatment"` + InherentLikelihood int `json:"inherentLikelihood"` + InherentImpact int `json:"inherentImpact"` + InherentRiskScore int `json:"inherentRiskScore"` + ResidualLikelihood int `json:"residualLikelihood"` + ResidualImpact int `json:"residualImpact"` + ResidualRiskScore int `json:"residualRiskScore"` + } `json:"node"` + } `json:"riskEdge"` + } `json:"createRisk"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagName string + flagCategory string + flagTreatment string + flagInherentLikelihood int + flagInherentImpact int + flagResidualLikelihood int + flagResidualImpact int + flagDescription string + flagNote string + flagOwner string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new risk", + Example: ` # Create a risk interactively + proboctl risk create --inherent-likelihood 3 --inherent-impact 4 + + # Create a risk non-interactively + proboctl risk create --name "Data breach" --category "Security" --treatment MITIGATED --inherent-likelihood 3 --inherent-impact 4`, + 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(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'proboctl auth login'") + } + + if f.IOStreams.IsInteractive() { + if flagName == "" { + err := huh.NewInput(). + Title("Risk name"). + Value(&flagName). + Run() + if err != nil { + return err + } + } + + if flagCategory == "" { + err := huh.NewInput(). + Title("Risk category"). + Value(&flagCategory). + Run() + if err != nil { + return err + } + } + + if flagTreatment == "" { + err := huh.NewSelect[string](). + Title("Risk treatment"). + Options( + huh.NewOption("Mitigated", "MITIGATED"), + huh.NewOption("Accepted", "ACCEPTED"), + huh.NewOption("Avoided", "AVOIDED"), + huh.NewOption("Transferred", "TRANSFERRED"), + ). + Value(&flagTreatment). + Run() + if err != nil { + return err + } + } + } + + if flagName == "" { + return fmt.Errorf("name is required; pass --name or run interactively") + } + if flagCategory == "" { + return fmt.Errorf("category is required; pass --category or run interactively") + } + if flagTreatment == "" { + return fmt.Errorf("treatment is required; pass --treatment or run interactively") + } + + input := map[string]any{ + "organizationId": flagOrg, + "name": flagName, + "category": flagCategory, + "treatment": flagTreatment, + "inherentLikelihood": flagInherentLikelihood, + "inherentImpact": flagInherentImpact, + } + + if flagDescription != "" { + input["description"] = flagDescription + } + if flagNote != "" { + input["note"] = flagNote + } + if flagOwner != "" { + input["ownerId"] = flagOwner + } + if cmd.Flags().Changed("residual-likelihood") { + input["residualLikelihood"] = flagResidualLikelihood + } + if cmd.Flags().Changed("residual-impact") { + input["residualImpact"] = flagResidualImpact + } + + 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) + } + + r := resp.CreateRisk.RiskEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created risk %s (%s)\n", + r.ID, + r.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagName, "name", "", "Risk name (required)") + cmd.Flags().StringVar(&flagCategory, "category", "", "Risk category (required)") + cmd.Flags().StringVar(&flagTreatment, "treatment", "", "Risk treatment: MITIGATED, ACCEPTED, AVOIDED, TRANSFERRED (required)") + cmd.Flags().IntVar(&flagInherentLikelihood, "inherent-likelihood", 0, "Inherent likelihood 1-5 (required)") + cmd.Flags().IntVar(&flagInherentImpact, "inherent-impact", 0, "Inherent impact 1-5 (required)") + cmd.Flags().IntVar(&flagResidualLikelihood, "residual-likelihood", 0, "Residual likelihood 1-5") + cmd.Flags().IntVar(&flagResidualImpact, "residual-impact", 0, "Residual impact 1-5") + cmd.Flags().StringVar(&flagDescription, "description", "", "Risk description") + cmd.Flags().StringVar(&flagNote, "note", "", "Risk note") + cmd.Flags().StringVar(&flagOwner, "owner", "", "Owner profile ID") + + _ = cmd.MarkFlagRequired("inherent-likelihood") + _ = cmd.MarkFlagRequired("inherent-impact") + + return cmd +} diff --git a/pkg/cmd/risk/delete/delete.go b/pkg/cmd/risk/delete/delete.go new file mode 100644 index 000000000..53c97e06e --- /dev/null +++ b/pkg/cmd/risk/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: DeleteRiskInput!) { + deleteRisk(input: $input) { + deletedRiskId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a risk", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete risk: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete risk %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{ + "riskId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted risk %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/risk/list/list.go b/pkg/cmd/risk/list/list.go new file mode 100644 index 000000000..c692ce97c --- /dev/null +++ b/pkg/cmd/risk/list/list.go @@ -0,0 +1,201 @@ +// 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: RiskOrder, $filter: RiskFilter) { + node(id: $id) { + ... on Organization { + risks(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { + totalCount + edges { + node { + id + name + category + treatment + inherentRiskScore + residualRiskScore + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type risk struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Treatment string `json:"treatment"` + InherentRiskScore int `json:"inherentRiskScore"` + ResidualRiskScore int `json:"residualRiskScore"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagFilter string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List risks in an organization", + Aliases: []string{"ls"}, + Example: ` # List risks in the default organization + proboctl risk list + + # Filter risks by name + proboctl risk list --filter "data breach" + + # List risks sorted by inherent score + proboctl risk ls --order-by INHERENT_RISK_SCORE --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(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'proboctl auth login'") + } + + variables := map[string]any{ + "id": flagOrg, + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "NAME", "CATEGORY", "TREATMENT", "INHERENT_RISK_SCORE", "RESIDUAL_RISK_SCORE"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + if flagFilter != "" { + variables["filter"] = map[string]any{ + "query": flagFilter, + } + } + + risks, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[risk], error) { + var resp struct { + Node struct { + Risks api.Connection[risk] `json:"risks"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp.Node.Risks, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, risks) + } + + if len(risks) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No risks found.") + return nil + } + + rows := make([][]string, 0, len(risks)) + for _, r := range risks { + rows = append(rows, []string{ + r.ID, + r.Name, + r.Category, + r.Treatment, + fmt.Sprintf("%d", r.InherentRiskScore), + fmt.Sprintf("%d", r.ResidualRiskScore), + }) + } + + t := cmdutil.NewTable("ID", "NAME", "CATEGORY", "TREATMENT", "INHERENT", "RESIDUAL").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(risks) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d risks\n", + len(risks), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of risks to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, NAME, CATEGORY, TREATMENT, INHERENT_RISK_SCORE, RESIDUAL_RISK_SCORE)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().StringVarP(&flagFilter, "filter", "q", "", "Filter risks by search query") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/risk/risk.go b/pkg/cmd/risk/risk.go new file mode 100644 index 000000000..4c3a84046 --- /dev/null +++ b/pkg/cmd/risk/risk.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 risk + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/risk/create" + "go.probo.inc/probo/pkg/cmd/risk/delete" + "go.probo.inc/probo/pkg/cmd/risk/list" + "go.probo.inc/probo/pkg/cmd/risk/update" + "go.probo.inc/probo/pkg/cmd/risk/view" +) + +func NewCmdRisk(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "risk ", + Short: "Manage risks", + } + + 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/risk/update/update.go b/pkg/cmd/risk/update/update.go new file mode 100644 index 000000000..2a050ca33 --- /dev/null +++ b/pkg/cmd/risk/update/update.go @@ -0,0 +1,170 @@ +// 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: UpdateRiskInput!) { + updateRisk(input: $input) { + risk { + id + name + category + treatment + inherentRiskScore + residualRiskScore + } + } +} +` + +type updateResponse struct { + UpdateRisk struct { + Risk struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Treatment string `json:"treatment"` + InherentRiskScore int `json:"inherentRiskScore"` + ResidualRiskScore int `json:"residualRiskScore"` + } `json:"risk"` + } `json:"updateRisk"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagCategory string + flagTreatment string + flagInherentLikelihood int + flagInherentImpact int + flagResidualLikelihood int + flagResidualImpact int + flagDescription string + flagNote string + flagOwner string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a risk", + 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("name") { + input["name"] = flagName + } + if cmd.Flags().Changed("category") { + input["category"] = flagCategory + } + if cmd.Flags().Changed("treatment") { + input["treatment"] = flagTreatment + } + if cmd.Flags().Changed("inherent-likelihood") { + input["inherentLikelihood"] = flagInherentLikelihood + } + if cmd.Flags().Changed("inherent-impact") { + input["inherentImpact"] = flagInherentImpact + } + if cmd.Flags().Changed("residual-likelihood") { + input["residualLikelihood"] = flagResidualLikelihood + } + if cmd.Flags().Changed("residual-impact") { + input["residualImpact"] = flagResidualImpact + } + if cmd.Flags().Changed("description") { + input["description"] = flagDescription + } + if cmd.Flags().Changed("note") { + input["note"] = flagNote + } + if cmd.Flags().Changed("owner") { + if flagOwner == "" { + input["ownerId"] = nil + } else { + input["ownerId"] = flagOwner + } + } + + 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) + } + + r := resp.UpdateRisk.Risk + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated risk %s (%s)\n", + r.ID, + r.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Risk name") + cmd.Flags().StringVar(&flagCategory, "category", "", "Risk category") + cmd.Flags().StringVar(&flagTreatment, "treatment", "", "Risk treatment: MITIGATED, ACCEPTED, AVOIDED, TRANSFERRED") + cmd.Flags().IntVar(&flagInherentLikelihood, "inherent-likelihood", 0, "Inherent likelihood 1-5") + cmd.Flags().IntVar(&flagInherentImpact, "inherent-impact", 0, "Inherent impact 1-5") + cmd.Flags().IntVar(&flagResidualLikelihood, "residual-likelihood", 0, "Residual likelihood 1-5") + cmd.Flags().IntVar(&flagResidualImpact, "residual-impact", 0, "Residual impact 1-5") + cmd.Flags().StringVar(&flagDescription, "description", "", "Risk description") + cmd.Flags().StringVar(&flagNote, "note", "", "Risk note") + cmd.Flags().StringVar(&flagOwner, "owner", "", "Owner profile ID") + + return cmd +} diff --git a/pkg/cmd/risk/view/view.go b/pkg/cmd/risk/view/view.go new file mode 100644 index 000000000..a94d25244 --- /dev/null +++ b/pkg/cmd/risk/view/view.go @@ -0,0 +1,168 @@ +// 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) { + ... on Risk { + id + name + description + category + treatment + note + inherentLikelihood + inherentImpact + inherentRiskScore + residualLikelihood + residualImpact + residualRiskScore + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Category string `json:"category"` + Treatment string `json:"treatment"` + Note string `json:"note"` + InherentLikelihood int `json:"inherentLikelihood"` + InherentImpact int `json:"inherentImpact"` + InherentRiskScore int `json:"inherentRiskScore"` + ResidualLikelihood int `json:"residualLikelihood"` + ResidualImpact int `json:"residualImpact"` + ResidualRiskScore int `json:"residualRiskScore"` + 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 risk", + 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("risk %s not found", args[0]) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + r := 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(r.Name)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Category:"), r.Category) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Treatment:"), r.Treatment) + + if r.Description != nil && *r.Description != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *r.Description) + } + + if r.Note != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Note:"), r.Note) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf( + out, + "%s%d (likelihood: %d, impact: %d)\n", + label.Render("Inherent Risk Score:"), + r.InherentRiskScore, + r.InherentLikelihood, + r.InherentImpact, + ) + _, _ = fmt.Fprintf( + out, + "%s%d (likelihood: %d, impact: %d)\n", + label.Render("Residual Risk Score:"), + r.ResidualRiskScore, + r.ResidualLikelihood, + r.ResidualImpact, + ) + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(r.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +}