From b836daa5e4871ee0c6dbfad6acd02f932e605c90 Mon Sep 17 00:00:00 2001 From: TomRomeo Date: Mon, 4 May 2026 11:46:55 +0200 Subject: [PATCH] feat: add odgcli Signed-off-by: TomRomeo --- odgcli/.gitignore | 8 + odgcli/README.md | 58 ++ odgcli/Taskfile.yml | 52 ++ odgcli/cmd/auth.go | 98 +++ odgcli/cmd/root.go | 68 +++ odgcli/go.mod | 56 ++ odgcli/go.sum | 184 ++++++ odgcli/internal/config/config.go | 130 ++++ odgcli/internal/tui/app.go | 559 ++++++++++++++++++ odgcli/internal/views/detailspane.go | 174 ++++++ odgcli/internal/views/loadingmodal.go | 119 ++++ odgcli/internal/views/statusbar.go | 78 +++ odgcli/internal/views/util.go | 21 + odgcli/main.go | 7 + odgcli/pkg/github/client.go | 109 ++++ odgcli/pkg/odg/client.go | 408 +++++++++++++ odgcli/pkg/odg/client_test.go | 319 ++++++++++ .../pkg/odg/testdata/compliance_summary.json | 365 ++++++++++++ odgcli/pkg/odg/testdata/metadata_query.json | 94 +++ .../odg/testdata/metadata_query_page2.json | 46 ++ odgcli/pkg/odg/testdata/rescorings.json | 143 +++++ odgcli/pkg/odg/testdata/responsibles.json | 29 + odgcli/pkg/odg/types.go | 278 +++++++++ 23 files changed, 3403 insertions(+) create mode 100644 odgcli/.gitignore create mode 100644 odgcli/README.md create mode 100644 odgcli/Taskfile.yml create mode 100644 odgcli/cmd/auth.go create mode 100644 odgcli/cmd/root.go create mode 100644 odgcli/go.mod create mode 100644 odgcli/go.sum create mode 100644 odgcli/internal/config/config.go create mode 100644 odgcli/internal/tui/app.go create mode 100644 odgcli/internal/views/detailspane.go create mode 100644 odgcli/internal/views/loadingmodal.go create mode 100644 odgcli/internal/views/statusbar.go create mode 100644 odgcli/internal/views/util.go create mode 100644 odgcli/main.go create mode 100644 odgcli/pkg/github/client.go create mode 100644 odgcli/pkg/odg/client.go create mode 100644 odgcli/pkg/odg/client_test.go create mode 100644 odgcli/pkg/odg/testdata/compliance_summary.json create mode 100644 odgcli/pkg/odg/testdata/metadata_query.json create mode 100644 odgcli/pkg/odg/testdata/metadata_query_page2.json create mode 100644 odgcli/pkg/odg/testdata/rescorings.json create mode 100644 odgcli/pkg/odg/testdata/responsibles.json create mode 100644 odgcli/pkg/odg/types.go diff --git a/odgcli/.gitignore b/odgcli/.gitignore new file mode 100644 index 0000000..411bade --- /dev/null +++ b/odgcli/.gitignore @@ -0,0 +1,8 @@ +# IDE configuration folders +.idea + +# secrets +.env + +.task +bin/ diff --git a/odgcli/README.md b/odgcli/README.md new file mode 100644 index 0000000..6bc0ddc --- /dev/null +++ b/odgcli/README.md @@ -0,0 +1,58 @@ +# odgcli + +CVE compliance tooling for Open Delivery Gear. + +## Installation + +```bash +go install github.com/open-component-model/community/odgcli@latest +``` + +## Configuration + +odgcli uses a YAML config file at `~/.config/odgcli/config.yaml`. Values can be overridden by environment variables or CLI flags. + +```yaml +base_url: "https://delivery-service.demo.ci.gardener.cloud" +github_url: "https://api.github.com" +root_component: ocm.software/ocmcli +``` + +| Key | Env Variable | Flag | Description | +|------------------|----------------|----------|------------------------------| +| `base_url` | `BASE_URL` | — | Delivery Service base URL | +| `github_url` | `GITHUB_URL` | — | GitHub API base URL | +| `access_token` | `ACCESS_TOKEN` | — | GitHub personal access token | +| `root_component` | `ODG_ROOT` | `--root` | Root component to browse | + +## Authentication + +The access token can be provided via environment variable, config file, or the system keychain. The recommended approach is the system keychain: + +```bash +# Store token in system keychain (interactive prompt) +odgcli auth login +``` + +## Usage + +Start the interactive TUI (default when no subcommand is given): + +```bash +odgcli + +# Override the root component +odgcli --root ocm.software/ocmcli +``` + +## Development + +This project uses [Task](https://taskfile.dev) as a task runner. + +```bash +task # Build the binary +task test # Run tests +task lint # Run golangci-lint +task check # Run all checks (fmt, vet, lint, test) +task clean # Remove build artifacts +``` diff --git a/odgcli/Taskfile.yml b/odgcli/Taskfile.yml new file mode 100644 index 0000000..47dd759 --- /dev/null +++ b/odgcli/Taskfile.yml @@ -0,0 +1,52 @@ +version: '3' + +vars: + BINARY_NAME: odgcli + BUILD_DIR: bin + +tasks: + default: + desc: Build the binary + aliases: [build] + cmds: + - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} . + sources: + - '**/*.go' + - go.mod + - go.sum + - exclude: '**/*_test.go' + generates: + - '{{.BUILD_DIR}}/{{.BINARY_NAME}}' + + test: + desc: Run tests + cmds: + - go test ./... + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + + fmt: + desc: Format code + cmds: + - gofmt -w . + + vet: + desc: Run go vet + cmds: + - go vet ./... + + clean: + desc: Remove build artifacts + cmds: + - rm -rf {{.BUILD_DIR}} + + check: + desc: Run all checks (fmt, vet, lint, test) + cmds: + - task: fmt + - task: vet + - task: lint + - task: test diff --git a/odgcli/cmd/auth.go b/odgcli/cmd/auth.go new file mode 100644 index 0000000..5046c31 --- /dev/null +++ b/odgcli/cmd/auth.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/zalando/go-keyring" + "golang.org/x/term" + + "github.com/open-component-model/community/odgcli/internal/config" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", + Long: "Store, remove, or check the status of your access token in the system keychain.", + // Override root's PersistentPreRunE so auth commands work without a valid config. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Store an access token in the system keychain", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Print("Paste your access token: ") + tokenBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // newline after hidden input + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + token := strings.TrimSpace(string(tokenBytes)) + + if token == "" { + return fmt.Errorf("token cannot be empty") + } + + if err := config.StoreAccessToken(token); err != nil { + return fmt.Errorf("failed to store token in keychain: %w", err) + } + + fmt.Println("Token stored in system keychain.") + return nil + }, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove the access token from the system keychain", + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.DeleteAccessToken(); err != nil { + return fmt.Errorf("failed to remove token from keychain: %w", err) + } + + fmt.Println("Token removed from system keychain.") + return nil + }, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show where the access token is sourced from", + Run: func(cmd *cobra.Command, args []string) { + // Check env var. + if os.Getenv("ACCESS_TOKEN") != "" { + fmt.Println("Token source: ACCESS_TOKEN environment variable") + return + } + + // Check keychain. + if token, err := keyring.Get(config.KeyringService, config.KeyringAccessTokenKey); err == nil && token != "" { + fmt.Println("Token source: system keychain") + return + } + + // Check config file. + config.SetConfigFile(configPath) + loadedCfg, err := config.Load() + if err == nil && loadedCfg.AccessToken != "" { + fmt.Println("Token source: config file (" + config.DefaultConfigPath() + ")") + return + } + + fmt.Println("No access token found.") + fmt.Println("Run 'odgcli auth login' to store a token in the system keychain,") + fmt.Println("or set the ACCESS_TOKEN environment variable.") + }, +} + +func init() { + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authStatusCmd) + rootCmd.AddCommand(authCmd) +} diff --git a/odgcli/cmd/root.go b/odgcli/cmd/root.go new file mode 100644 index 0000000..35391c7 --- /dev/null +++ b/odgcli/cmd/root.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/open-component-model/community/odgcli/internal/config" + "github.com/open-component-model/community/odgcli/internal/tui" + "github.com/open-component-model/community/odgcli/pkg/github" + "github.com/open-component-model/community/odgcli/pkg/odg" +) + +// cfg holds shared configuration populated in PersistentPreRunE and +// available to all subcommands. +var cfg *config.Config + +// configPath allows overriding the config file location via --config flag. +var configPath string + +var rootCmd = &cobra.Command{ + Use: "odgcli", + Short: "CVE compliance tooling for Open Delivery Gear", + Long: "odgcli provides a TUI for browsing CVE compliance data from Open Delivery Gear.", + // When invoked without a subcommand, start the interactive TUI. + RunE: func(cmd *cobra.Command, args []string) error { + ghClient := github.NewClient(cfg.GithubURL, cfg.AccessToken) + odgClient, err := odg.NewClient(context.Background(), cfg.BaseURL, ghClient) + if err != nil { + return fmt.Errorf("failed to create ODG client: %w", err) + } + return tui.Run(odgClient, ghClient, cfg.RootComponent) + }, + // Silence cobra's default usage/error printing so the TUI stays clean. + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + // Define flags. + rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file path (default: ~/.config/odgcli/config.yaml)") + rootCmd.PersistentFlags().String("root", "", "Root component name (overrides config file and ODG_ROOT env var)") + + // Bind flags and env vars to viper keys. + config.BindFlags(rootCmd) + + // Load config before each command that needs it. + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + config.SetConfigFile(configPath) + + var err error + cfg, err = config.Load() + if err != nil { + return err + } + return nil + } +} + +// Execute is the main entry point called from main.go. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/odgcli/go.mod b/odgcli/go.mod new file mode 100644 index 0000000..ae59dc4 --- /dev/null +++ b/odgcli/go.mod @@ -0,0 +1,56 @@ +module github.com/open-component-model/community/odgcli + +go 1.25.0 + +require ( + github.com/allegro/bigcache/v3 v3.1.0 + github.com/eko/gocache/lib/v4 v4.2.3 + github.com/eko/gocache/store/bigcache/v4 v4.2.4 + github.com/gdamore/tcell/v2 v2.8.1 + github.com/rivo/tview v0.42.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/term v0.28.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/odgcli/go.sum b/odgcli/go.sum new file mode 100644 index 0000000..c71e199 --- /dev/null +++ b/odgcli/go.sum @@ -0,0 +1,184 @@ +github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= +github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eko/gocache/lib/v4 v4.2.3 h1:s78TFqEGAH3SbzP4N40D755JYT/aaGFKEPrsUtC1chU= +github.com/eko/gocache/lib/v4 v4.2.3/go.mod h1:Zus8mwmaPu1VYOzfomb+Dvx2wV7fT5jDRbHYtQM6MEY= +github.com/eko/gocache/store/bigcache/v4 v4.2.4 h1:Ak2qZYh7ioRoSts6xUObdnbNhKIZQxrVGO1kb21U8Ak= +github.com/eko/gocache/store/bigcache/v4 v4.2.4/go.mod h1:ty85W3DT2S5ZO3VG30UkwMQ1FmH8p6O0Y6FrQz7alUo= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/odgcli/internal/config/config.go b/odgcli/internal/config/config.go new file mode 100644 index 0000000..5969fca --- /dev/null +++ b/odgcli/internal/config/config.go @@ -0,0 +1,130 @@ +// Package config handles application configuration via config file, environment +// variables, and system keychain for secrets. +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/zalando/go-keyring" +) + +const ( + // KeyringService is the service name used for storing secrets in the system keychain. + KeyringService = "odgcli" + // KeyringAccessTokenKey is the keychain key under which the access token is stored. + KeyringAccessTokenKey = "access_token" +) + +// Config holds all application configuration. +type Config struct { + BaseURL string `mapstructure:"base_url"` + GithubURL string `mapstructure:"github_url"` + AccessToken string `mapstructure:"access_token"` + RootComponent string `mapstructure:"root_component"` +} + +// DefaultConfigDir returns the default configuration directory path. +func DefaultConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "odgcli") +} + +// DefaultConfigPath returns the default configuration file path. +func DefaultConfigPath() string { + return filepath.Join(DefaultConfigDir(), "config.yaml") +} + +// BindFlags binds cobra flags and environment variables to viper keys. +func BindFlags(cmd *cobra.Command) { + viper.BindPFlag("root_component", cmd.PersistentFlags().Lookup("root")) //nolint:errcheck + viper.BindEnv("base_url", "BASE_URL") //nolint:errcheck + viper.BindEnv("github_url", "GITHUB_URL") //nolint:errcheck + viper.BindEnv("access_token", "ACCESS_TOKEN") //nolint:errcheck + viper.BindEnv("root_component", "ODG_ROOT") //nolint:errcheck +} + +// SetConfigFile configures viper to read from the given config file path. +// If configPath is empty, the default path (~/.config/odgcli/config.yaml) is used. +func SetConfigFile(configPath string) { + if configPath != "" { + viper.SetConfigFile(configPath) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(DefaultConfigDir()) + } +} + +// Load reads the config file and resolves all configuration values. +// BindFlags and SetConfigFile should be called before this. +// +// Resolution order per value: +// 1. Cobra flag (--root) +// 2. Environment variable (ACCESS_TOKEN, BASE_URL, GITHUB_URL, ODG_ROOT) +// 3. System keychain (access token only) +// 4. Config file +func Load() (*Config, error) { + // Read config file (not an error if it doesn't exist). + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + // Only error if the file exists but can't be read/parsed. + if _, statErr := os.Stat(viper.ConfigFileUsed()); statErr == nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + } + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + // Resolve access token: flag/env > keyring > config file. + // Flag, env, and config file are already handled by viper above. + // Check keyring if still empty. + if cfg.AccessToken == "" { + if token, err := keyring.Get(KeyringService, KeyringAccessTokenKey); err == nil && token != "" { + cfg.AccessToken = token + } + } + + // Validate required fields. + if cfg.BaseURL == "" { + return nil, fmt.Errorf("base_url must be set (via config file, or BASE_URL env var)") + } + if cfg.GithubURL == "" { + return nil, fmt.Errorf("github_url must be set (via config file, or GITHUB_URL env var)") + } + if cfg.AccessToken == "" { + return nil, fmt.Errorf("access_token must be set (via ACCESS_TOKEN env var, system keychain, or config file)") + } + if cfg.RootComponent == "" { + return nil, fmt.Errorf("root_component must be set (via --root flag, ODG_ROOT env var, or config file)") + } + + return &cfg, nil +} + +// StoreAccessToken stores the access token in the system keychain. +func StoreAccessToken(token string) error { + return keyring.Set(KeyringService, KeyringAccessTokenKey, token) +} + +// DeleteAccessToken removes the access token from the system keychain. +func DeleteAccessToken() error { + return keyring.Delete(KeyringService, KeyringAccessTokenKey) +} + +// EnsureConfigDir creates the config directory with appropriate permissions if +// it doesn't exist. +func EnsureConfigDir() error { + dir := DefaultConfigDir() + return os.MkdirAll(dir, 0700) +} diff --git a/odgcli/internal/tui/app.go b/odgcli/internal/tui/app.go new file mode 100644 index 0000000..cae4aa9 --- /dev/null +++ b/odgcli/internal/tui/app.go @@ -0,0 +1,559 @@ +package tui + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "slices" + "sort" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/open-component-model/community/odgcli/internal/views" + "github.com/open-component-model/community/odgcli/pkg/github" + "github.com/open-component-model/community/odgcli/pkg/odg" +) + +// Application holds all state for the tview TUI. +type Application struct { + TV *tview.Application + Pages *tview.Pages + MainTreeView *tview.TreeView + Clients ApplicationClients + LoadingModal *views.LoadingModal + StatusBar *views.StatusBar + DetailsPane *views.DetailsPane + filterForUser bool + rootComponent string + ctx context.Context + cancel context.CancelFunc +} + +// ApplicationClients bundles the API clients used by the TUI. +type ApplicationClients struct { + GHClient *github.Client + ODGClient *odg.Client +} + +// Run creates and starts the interactive TUI application. +func Run(odgClient *odg.Client, ghClient *github.Client, rootComponent string) error { + // Suppress log output from libraries that write to stderr, + // which would bypass tcell's alternate screen buffer and shift the TUI. + log.SetOutput(io.Discard) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tv := tview.NewApplication() + pages := tview.NewPages() + + app := &Application{ + TV: tv, + Pages: pages, + Clients: ApplicationClients{ + GHClient: ghClient, + ODGClient: odgClient, + }, + LoadingModal: views.NewLoadingModal(tv, pages), + StatusBar: views.NewStatusBar(), + filterForUser: true, + rootComponent: rootComponent, + ctx: ctx, + cancel: cancel, + } + + app.createMainUI() + return nil +} + +func (a *Application) loadArtifactDetails(artifact odg.ArtefactEntry) { + var findings []odg.Finding + + err := a.doWithModal(func() error { + var err error + findings, err = a.Clients.ODGClient.GetRescorings(a.ctx, artifact.Artefact) + return err + }, fmt.Sprintf("Loading CVEs for %s in %s@%s...", artifact.Artefact.Info.Name, artifact.Artefact.ComponentName, + artifact.Artefact.ComponentVersion), 50*time.Millisecond) + + if err != nil { + go a.showErrorModal(err) + return + } + + // filter findings + var filteredFindings []odg.Finding + for _, finding := range findings { + if finding.Finding.Severity == "NONE" { + continue + } + if len(finding.ApplicableRescorings) != 0 { + continue + } + filteredFindings = append(filteredFindings, finding) + } + + go func() { + a.TV.QueueUpdateDraw(func() { + if len(filteredFindings) == 0 { + a.DetailsPane.SetNoFindings(artifact.Artefact.Info.Name) + } else { + currentArtifact := a.MainTreeView.GetCurrentNode() + currentArtifact.ClearChildren() + currentArtifact.Expand() + for _, finding := range filteredFindings { + findingNode := views.CreateTreeNode(fmt.Sprintf("%s (%s)", finding.Finding.CVE, finding.Finding.Severity), false).SetReference(views.FindingContext{ + Finding: finding, + Artefact: artifact.Artefact, + }) + currentArtifact.AddChild(findingNode) + } + a.MainTreeView.SetCurrentNode(currentArtifact.GetChildren()[0]) + } + }) + }() +} + +func (a *Application) showErrorModal(err error) { + errorModal := tview.NewModal(). + SetText("An error occurred:" + err.Error()). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + a.Pages.RemovePage("error") + a.TV.SetFocus(a.MainTreeView) + }) + a.TV.QueueUpdateDraw(func() { + a.Pages.AddPage("error", errorModal, true, true) + }) +} + +func (a *Application) showFindingDetails(findingCtx views.FindingContext) { + comments, commentsErr := a.getCommentsForCVE(a.ctx, findingCtx.Finding.Finding.CVE) + + if commentsErr != nil { + go a.showErrorModal(commentsErr) + } + + // Resolve author display names. + var resolved []views.ResolvedComment + if commentsErr == nil { + for _, comment := range comments { + author, err := a.Clients.GHClient.ResolveUsername(a.ctx, comment.Author) + if err != nil { + author = comment.Author + } + resolved = append(resolved, views.ResolvedComment{ + Comment: comment, + DisplayAuthor: author, + }) + } + } + + a.TV.QueueUpdateDraw(func() { + a.DetailsPane.ShowFinding(views.FindingContext{ + Finding: findingCtx.Finding, + Artefact: findingCtx.Artefact, + }, resolved) + }) +} + +// getCommentsForCVE queries rescoring entries for the given CVE and transforms +// them into Comment structs for display. +func (a *Application) getCommentsForCVE(ctx context.Context, cve string) ([]odg.Comment, error) { + var comments []odg.Comment + for item, err := range a.Clients.ODGClient.QueryMetadataBySearchExpression(ctx, []odg.MetadataQueryCriterion{ + {Type: "artefact-metadata", Attr: "type", Op: "eq", Value: "rescorings"}, + {Type: "artefact-metadata", Attr: "data.finding.cve", Op: "eq", Value: cve}, + }, 50, []odg.MetadataQuerySort{ + {Field: "meta.creation_date", Order: "desc"}, + {Field: "id", Order: "desc"}, + }) { + if err != nil { + return nil, err + } + if item.Data.Comment == "" { + continue + } + + createdAt, err := time.Parse(time.RFC3339, item.Meta.CreationDate) + if err != nil { + return nil, fmt.Errorf("failed to parse creation date %q: %w", item.Meta.CreationDate, err) + } + + componentVersion := "" + if item.Artefact.ComponentVersion != nil { + componentVersion = *item.Artefact.ComponentVersion + } + + comments = append(comments, odg.Comment{ + Author: item.Data.User.Username, + Content: item.Data.Comment, + CreatedAt: createdAt, + ComponentName: item.Artefact.ComponentName, + ComponentVersion: componentVersion, + ArtefactName: item.Artefact.Info.Name, + ArtefactVersion: item.Artefact.Info.Version, + Severity: item.Data.Severity, + }) + } + + return comments, nil +} + +// openInEditor writes the finding details to a temporary file and opens it in $EDITOR. +func (a *Application) openInEditor(findingCtx views.FindingContext) { + finding := findingCtx.Finding + artefact := findingCtx.Artefact + + matchingRules := finding.MatchingRules + matchingRules = slices.DeleteFunc(matchingRules, func(rule string) bool { + return rule == "original-severity" + }) + + dueDate := "N/A" + if finding.DueDate != nil { + dueDate = *finding.DueDate + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("CVE: %s\n", finding.Finding.CVE)) + sb.WriteString(fmt.Sprintf("Package: %s\n", finding.Finding.PackageName)) + sb.WriteString(fmt.Sprintf("Component: %s (%s)\n", artefact.ComponentName, artefact.ComponentVersion)) + sb.WriteString(fmt.Sprintf("Artefact: %s (%s)\n", artefact.Info.Name, artefact.Info.Version)) + sb.WriteString(fmt.Sprintf("Original Severity: %s\n", finding.Finding.Severity)) + sb.WriteString(fmt.Sprintf("Suggested Severity: %s\n", finding.Severity)) + sb.WriteString(fmt.Sprintf("Discovery Date: %s\n", finding.DiscoveryDate)) + sb.WriteString(fmt.Sprintf("Due Date: %s\n", dueDate)) + sb.WriteString(fmt.Sprintf("Matching Rules: %s\n", matchingRules)) + sb.WriteString("\n") + sb.WriteString("Description:\n") + sb.WriteString(finding.Finding.Summary) + sb.WriteString("\n\n") + + comments, err := a.getCommentsForCVE(a.ctx, finding.Finding.CVE) + if err != nil { + sb.WriteString(fmt.Sprintf("Error loading comments: %s\n", err.Error())) + } else if len(comments) == 0 { + sb.WriteString("No rescoring comments for this CVE\n") + } else { + sb.WriteString(fmt.Sprintf("Rescorings for %s in other components (%d total):\n\n", finding.Finding.CVE, len(comments))) + for _, comment := range comments { + author, err := a.Clients.GHClient.ResolveUsername(a.ctx, comment.Author) + if err != nil { + author = comment.Author + } + sb.WriteString(fmt.Sprintf("- Author: %s\n", author)) + sb.WriteString(fmt.Sprintf(" Created at: %s\n", comment.CreatedAt.Format(time.RFC1123))) + sb.WriteString(fmt.Sprintf(" Component: %s (%s)\n", comment.ComponentName, comment.ComponentVersion)) + sb.WriteString(fmt.Sprintf(" Artefact: %s@%s\n", comment.ArtefactName, comment.ArtefactVersion)) + sb.WriteString(fmt.Sprintf(" Severity: %s\n", comment.Severity)) + sb.WriteString(fmt.Sprintf(" Comment:\n %s\n\n", comment.Content)) + } + } + + tmpFile, err := os.CreateTemp("", "cve-details-*.txt") + if err != nil { + go a.showErrorModal(fmt.Errorf("failed to create temp file: %w", err)) + return + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(sb.String()); err != nil { + tmpFile.Close() + go a.showErrorModal(fmt.Errorf("failed to write temp file: %w", err)) + return + } + tmpFile.Close() + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + var editorErr error + a.TV.Suspend(func() { + cmd := exec.Command(editor, tmpFile.Name()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + editorErr = cmd.Run() + }) + a.TV.ForceDraw() + if editorErr != nil { + go a.showErrorModal(fmt.Errorf("editor exited with error: %w", editorErr)) + } +} + +func (a *Application) updateFilterStatus() { + text := "filtering for user: " + if a.filterForUser { + username, err := a.Clients.GHClient.LoggedInUsername(a.ctx) + if err != nil { + text += "[red]error[white] (press 'f' to toggle)" + } else { + text += fmt.Sprintf("[green]%s[white] (press 'f' to toggle)", username) + } + } else { + text += "[red]OFF[white] (press 'f' to toggle)" + } + a.StatusBar.SetMessage("filter", text) +} + +func (a *Application) createMainUI() { + cveTreeView := tview.NewTreeView() + views.WrapWithFocusBorders(cveTreeView.Box) + cveTreeView.SetBorderPadding(1, 1, 2, 2) + cveTreeView.SetBorder(true).SetTitle("CVEs") + + // Clear details pane when scrolling + var detailsTimer *time.Timer + cveTreeView.SetChangedFunc(func(node *tview.TreeNode) { + a.DetailsPane.Clear() + + // load new details data after debounce + if detailsTimer != nil { + detailsTimer.Stop() + } + detailsTimer = time.AfterFunc(150*time.Millisecond, func() { + if findingCtx, ok := node.GetReference().(views.FindingContext); ok { + a.showFindingDetails(findingCtx) + } + }) + }) + cveTreeView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'l': + cveTreeView.SetCurrentNode(cveTreeView.GetCurrentNode().SetExpanded(true)) + case 'h': + if cveTreeView.GetCurrentNode().IsExpanded() { + cveTreeView.SetCurrentNode(cveTreeView.GetCurrentNode().SetExpanded(false)) + return nil + } + // if current node is already collapsed, move to parent + cveTreeView.GetRoot().Walk(func(node, parent *tview.TreeNode) bool { + if node == cveTreeView.GetCurrentNode() && parent != nil { + parent.SetExpanded(false) + return false // stop walking + } + return true // continue walking + }) + case 'f': + a.filterForUser = !a.filterForUser + a.updateFilterStatus() + a.loadCVEs(a.filterForUser) + return event + case 'e': + node := cveTreeView.GetCurrentNode() + if node == nil { + return nil + } + if findingCtx, ok := node.GetReference().(views.FindingContext); ok { + go a.openInEditor(findingCtx) + } + return nil + default: + return event + + } + default: + return event + } + + return event + }) + cveTreeView.SetSelectedFunc(func(node *tview.TreeNode) { + // do nothing if node has no reference (e.g. it's the root node) or component node + if node.GetReference() == nil { + return + } + + artifact, ok := node.GetReference().(odg.ArtefactEntry) + if !ok { + return + } + + go a.loadArtifactDetails(artifact) + + }) + a.LoadingModal.SetOnHideFocus(cveTreeView) + a.MainTreeView = cveTreeView + a.updateFilterStatus() + a.StatusBar.SetMessage("keybinds", "up/down or j/k: Navigate | l: Expand | h: Collapse | Enter: Load CVEs | e: Open in $EDITOR") + + a.DetailsPane = views.NewDetailsPane() + + flex := tview.NewFlex().SetDirection(tview.FlexColumnCSS). + AddItem(tview.NewFlex().SetDirection(tview.FlexRowCSS). + AddItem(cveTreeView, 0, 1, false). + AddItem(a.DetailsPane.Primitive(), 0, 3, false), 0, 1, false). + AddItem(a.StatusBar.Primitive(), 3, 1, false) + + a.Pages.AddPage("root", flex, true, true) + + a.loadCVEs(a.filterForUser) + + if err := a.TV.SetRoot(a.Pages, true).SetFocus(cveTreeView).Run(); err != nil { + panic(err) + } +} + +// filterSummary removes components and artefacts that have no vulnerability findings. +// Only artefacts with at least one finding/vulnerability entry that is not CLEAN or UNKNOWN are kept. +// Components with no remaining artefacts are removed entirely. +// TODO: inquire if there is a better way to filter the compliance summary (e.g. server-side filtering) +func filterSummary(summary *odg.ComplianceSummaryResponse) *odg.ComplianceSummaryResponse { + filtered := &odg.ComplianceSummaryResponse{ + ComplianceSummary: []odg.ComplianceSummaryItem{}, + } + for _, comp := range summary.ComplianceSummary { + if !slices.ContainsFunc(comp.Entries, func(entry odg.Entry) bool { + return entry.Type == "finding/vulnerability" && entry.Value > 0 && entry.Categorisation != "CLEAN" && entry.Categorisation != "UNKNOWN" + }) { + continue + } + + filteredComp := odg.ComplianceSummaryItem{ + ComponentID: comp.ComponentID, + Entries: []odg.Entry{}, + Artefacts: []odg.ArtefactEntry{}, + } + for _, artifact := range comp.Artefacts { + if !slices.ContainsFunc(artifact.Entries, func(entry odg.Entry) bool { + return entry.Type == "finding/vulnerability" && entry.Value > 0 && entry.Categorisation != "CLEAN" && entry.Categorisation != "UNKNOWN" + }) { + continue + } + filteredComp.Artefacts = append(filteredComp.Artefacts, artifact) + } + + if len(filteredComp.Artefacts) == 0 { + continue + } + + filtered.ComplianceSummary = append(filtered.ComplianceSummary, filteredComp) + + } + + return filtered +} + +func (a *Application) loadCVEs(filterByUser bool) { + go a.LoadingModal.Show("Loading Compliance Summary...") + go func() { + odgClient := a.Clients.ODGClient + summary, err := odgClient.GetComplianceSummary(a.ctx, a.rootComponent, "greatest") + if err != nil { + a.TV.QueueUpdateDraw(func() { + a.Pages.RemovePage("loading") + a.Pages.RemovePage("loadingBG") + errorModal := tview.NewModal(). + SetText("Error loading data: " + err.Error()). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + a.Pages.RemovePage("error") + }) + a.Pages.AddPage("error", errorModal, true, true) + }) + return + } + filteredSummary := filterSummary(summary) + a.TV.QueueUpdateDraw(func() { + a.loadCVEsIntoTree(filteredSummary, filterByUser) + }) + go a.LoadingModal.Hide() + }() +} + +// artefactLabel builds a display label for an artefact node. +// If the artefact has a non-empty extra ID, it is appended as key=value pairs. +func artefactLabel(info odg.ArtefactInfo) string { + label := info.Name + "@" + info.Version + if len(info.ExtraID) > 0 { + // Sort keys for stable output + keys := make([]string, 0, len(info.ExtraID)) + for k := range info.ExtraID { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, info.ExtraID[k])) + } + label += " (" + strings.Join(parts, ", ") + ")" + } + return label +} + +func (a *Application) loadCVEsIntoTree(summary *odg.ComplianceSummaryResponse, filterByUser bool) { + rootNode := views.CreateTreeNode(a.rootComponent, true) + rootNode.ClearChildren() + + username, err := a.Clients.GHClient.LoggedInUsername(a.ctx) + if err != nil { + panic("Could not get logged in username: " + err.Error()) + } + for _, comp := range summary.ComplianceSummary { + responsibles, err := a.Clients.ODGClient.GetResponsibles(a.ctx, comp.ComponentID.Name, nil) + if err != nil { + // TODO: handle error properly, e.g. show in UI + continue + } + + // Only show components for which the user is responsible + if filterByUser && !slices.ContainsFunc(responsibles, func(r odg.Responsible) bool { return r.Username == username }) { + continue + } + + // If this item is the root component itself, add its artefacts + // directly to the root node instead of creating a redundant child. + if comp.ComponentID.Name == a.rootComponent { + for _, artefact := range comp.Artefacts { + artefactNode := views.CreateTreeNode(artefactLabel(artefact.Artefact.Info), false).SetReference(artefact) + rootNode.AddChild(artefactNode) + } + continue + } + + compNode := views.CreateTreeNode(comp.ComponentID.Name+"@"+comp.ComponentID.Version, false) + for _, artefact := range comp.Artefacts { + artefactNode := views.CreateTreeNode(artefactLabel(artefact.Artefact.Info), false).SetReference(artefact) + compNode.AddChild(artefactNode) + } + rootNode.AddChild(compNode) + } + cves := a.Pages.GetPage("root").(*tview.Flex).GetItem(0).(*tview.Flex).GetItem(0).(*tview.TreeView) + cves.SetRoot(rootNode).SetCurrentNode(rootNode) +} + +func (a *Application) doWithModal(action func() error, loadingText string, after time.Duration) error { + done := make(chan struct{}) + var actionErr error + go func() { + actionErr = action() + close(done) + }() + + // Wait for either completion or timeout + for { + select { + case <-done: + // Completed within desired interval, no loading modal needed + return actionErr + case <-time.After(after): + // Taking longer than desired interval, show loading modal + a.LoadingModal.Show(loadingText) + <-done // Wait for completion + a.LoadingModal.Hide() + return actionErr + } + } +} diff --git a/odgcli/internal/views/detailspane.go b/odgcli/internal/views/detailspane.go new file mode 100644 index 0000000..470dd69 --- /dev/null +++ b/odgcli/internal/views/detailspane.go @@ -0,0 +1,174 @@ +package views + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/open-component-model/community/odgcli/pkg/odg" +) + +// FindingContext pairs a Finding with its parent artefact for display purposes. +type FindingContext struct { + Finding odg.Finding + Artefact odg.Artefact +} + +// ResolvedComment is a Comment with the author's display name already resolved. +type ResolvedComment struct { + odg.Comment + DisplayAuthor string +} + +// DetailsPane displays metadata and descriptive text for a selected finding. +type DetailsPane struct { + flex *tview.Flex + table *tview.Table + separator *tview.TextView + text *tview.TextView +} + +// NewDetailsPane creates a new details pane with its internal layout. +func NewDetailsPane() *DetailsPane { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(false, false) + table.SetBorderPadding(0, 0, 1, 1) + + separator := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(false) + separator.SetBorderPadding(1, 1, 1, 1) + + text := tview.NewTextView(). + SetRegions(true). + SetDynamicColors(true). + SetScrollable(true). + SetWrap(true). + SetWordWrap(true). + SetMaxLines(500) + text.SetBorderPadding(0, 0, 1, 1) + + flex := tview.NewFlex().SetDirection(tview.FlexColumnCSS). + AddItem(table, 5, 0, false). + AddItem(separator, 3, 0, false). + AddItem(text, 0, 1, false) + flex.SetTitle("Details").SetBorder(true).SetBorderPadding(1, 1, 1, 1) + + return &DetailsPane{ + flex: flex, + table: table, + separator: separator, + text: text, + } +} + +// Primitive returns the top-level tview primitive for embedding in layouts. +func (d *DetailsPane) Primitive() tview.Primitive { + return d.flex +} + +// Clear resets the details pane to an empty state. +func (d *DetailsPane) Clear() { + d.flex.SetTitle("Details") + d.flex.SetBorderColor(tcell.ColorWhite) + d.table.Clear() + d.separator.SetText("") + d.text.SetText("") +} + +// ShowFinding renders a finding with its resolved comments. +func (d *DetailsPane) ShowFinding(ctx FindingContext, comments []ResolvedComment) { + finding := ctx.Finding + artefact := ctx.Artefact + + matchingRules := make([]string, 0, len(finding.MatchingRules)) + for _, rule := range finding.MatchingRules { + if rule != "original-severity" { + matchingRules = append(matchingRules, rule) + } + } + + dueDate := "N/A" + if finding.DueDate != nil { + dueDate = *finding.DueDate + } + + tableData := [][]string{ + {"CVE:", finding.Finding.CVE, "Package:", finding.Finding.PackageName}, + {"Component:", fmt.Sprintf("%s (%s)", artefact.ComponentName, artefact.ComponentVersion), "Artefact:", fmt.Sprintf("%s (%s)", artefact.Info.Name, artefact.Info.Version)}, + {"Original Severity:", finding.Finding.Severity, "Suggested Severity:", finding.Severity}, + {"Discovery Date:", finding.DiscoveryDate, "Due Date:", dueDate}, + {"Matching Rules:", fmt.Sprintf("%s", matchingRules), "", ""}, + } + + d.table.Clear() + for row, cols := range tableData { + for col, text := range cols { + cell := tview.NewTableCell(text) + if col%2 == 0 { + cell.SetTextColor(tcell.ColorGreen) + cell.SetExpansion(0) + } else { + cell.SetText(" " + text) + cell.SetExpansion(1) + } + d.table.SetCell(row, col, cell) + } + } + + details := fmt.Sprintf("[green::b]Description:[white::-]\n%s\n\n\n", finding.Finding.Summary) + details += d.formatComments(finding.Finding.CVE, comments) + + d.flex.SetTitle("Details for " + finding.Finding.CVE) + d.flex.SetBorderColor(tcell.ColorWhite) + d.separator.SetText("[gray]" + strings.Repeat("─", 200)) + d.text.SetText(details) + d.text.ScrollToBeginning() +} + +const maxComments = 25 + +func (d *DetailsPane) formatComments(cve string, comments []ResolvedComment) string { + if len(comments) == 0 { + return fmt.Sprintf("[green::b]Rescorings for %s in other components:[white::-]\n[green::d]No rescoring comments for this CVE", cve) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("[green::b]Rescorings for %s in other components (%d total):[white::-]\n", cve, len(comments))) + + displayed := len(comments) + if displayed > maxComments { + displayed = maxComments + } + + for _, comment := range comments[:displayed] { + sb.WriteString(fmt.Sprintf(` +[green]- Author:[white] %s +[green] Created at:[white] %s +[green] Component:[white] %s (%s) +[green] Artefact:[white] %s@%s +[green] Severity:[white] %s +[green] Comment:[white] + %s +`, comment.DisplayAuthor, comment.CreatedAt.Format(time.RFC1123), comment.ComponentName, comment.ComponentVersion, comment.ArtefactName, comment.ArtefactVersion, comment.Severity, comment.Content)) + } + + if len(comments) > maxComments { + sb.WriteString(fmt.Sprintf("\n[yellow::d]... and %d more comments not shown\n", len(comments)-maxComments)) + } + + return sb.String() +} + +// SetNoFindings displays a message indicating no open findings for an artefact. +func (d *DetailsPane) SetNoFindings(artefactName string) { + d.table.Clear() + d.separator.SetText("") + d.flex.SetTitle("Findings for " + artefactName) + d.flex.SetBorderColor(tcell.ColorGreen) + d.text.SetText("[green::b]No open findings.[white::-]\n\nAll findings for this artefact have either been rescored or have severity NONE.") +} diff --git a/odgcli/internal/views/loadingmodal.go b/odgcli/internal/views/loadingmodal.go new file mode 100644 index 0000000..83e9233 --- /dev/null +++ b/odgcli/internal/views/loadingmodal.go @@ -0,0 +1,119 @@ +package views + +import ( + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// LoadingModal is a modal overlay with an animated loading spinner. +type LoadingModal struct { + app *tview.Application + pages *tview.Pages + modal *tview.Modal + background *tview.TextView + text string + stopChan chan struct{} + isVisible bool + onHideFocus tview.Primitive +} + +// NewLoadingModal creates a new LoadingModal. +func NewLoadingModal(app *tview.Application, pages *tview.Pages) *LoadingModal { + return &LoadingModal{ + app: app, + pages: pages, + } +} + +// SetOnHideFocus sets the primitive to focus when the modal is hidden. +func (l *LoadingModal) SetOnHideFocus(p tview.Primitive) *LoadingModal { + l.onHideFocus = p + return l +} + +// Show displays the loading modal and starts the spinner animation. +func (l *LoadingModal) Show(text string) { + if l.isVisible { + return + } + + l.text = text + l.isVisible = true + l.stopChan = make(chan struct{}) + + l.background = tview.NewTextView(). + SetTextColor(tcell.ColorBlue). + SetText(strings.Repeat(". ", 100000)) + + modal := tview.NewModal(). + SetBackgroundColor(tcell.ColorDefault). + SetText(text + "\n\n⠋"). + AddButtons([]string{}) + modal.SetBorderStyle(tcell.StyleDefault) + modal.SetBorder(true) + modal.SetBorderColor(tcell.ColorBlue) + l.modal = modal + + l.app.QueueUpdateDraw(func() { + l.pages.AddPage("loadingBG", l.background, true, true) + l.pages.AddPage("loading", l.modal, true, true) + }) + + // Start spinner animation in a goroutine + go l.animateSpinner() +} + +// Hide hides the loading modal and stops the spinner. +func (l *LoadingModal) Hide() { + if !l.isVisible { + return + } + + if l.stopChan != nil { + close(l.stopChan) + l.stopChan = nil + } + + l.isVisible = false + l.modal = nil + l.background = nil + + l.app.QueueUpdateDraw(func() { + l.pages.RemovePage("loading") + l.pages.RemovePage("loadingBG") + if l.onHideFocus != nil { + l.app.SetFocus(l.onHideFocus) + } + }) +} + +// IsVisible returns whether the modal is currently visible. +func (l *LoadingModal) IsVisible() bool { + return l.isVisible +} + +// animateSpinner runs the spinner animation loop +func (l *LoadingModal) animateSpinner() { + spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + frameIndex := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-l.stopChan: + return + case <-ticker.C: + frame := spinnerFrames[frameIndex] + frameIndex = (frameIndex + 1) % len(spinnerFrames) + l.app.QueueUpdateDraw(func() { + if l.modal != nil { + l.modal.SetText(l.text + "\n\n" + frame) + } + }) + } + } +} diff --git a/odgcli/internal/views/statusbar.go b/odgcli/internal/views/statusbar.go new file mode 100644 index 0000000..6a7fe8b --- /dev/null +++ b/odgcli/internal/views/statusbar.go @@ -0,0 +1,78 @@ +package views + +import ( + "strings" + "sync" + + "github.com/rivo/tview" +) + +// StatusBar is a tview.TextView wrapper that manages multiple named message +// segments separated by " | ". Any part of the application that holds a +// reference to the StatusBar can update individual segments independently, +// and the displayed text is re-rendered automatically. +type StatusBar struct { + tv *tview.TextView + + mu sync.Mutex + keys []string // insertion-ordered segment keys + segments map[string]string // key -> display text +} + +// NewStatusBar creates a StatusBar with a bordered TextView titled "Key Bindings". +func NewStatusBar() *StatusBar { + tv := tview.NewTextView().SetDynamicColors(true) + tv.SetBorder(true).SetTitle("Key Bindings").SetBorderPadding(0, 0, 1, 1) + + return &StatusBar{ + tv: tv, + segments: make(map[string]string), + } +} + +// SetMessage sets or updates the segment identified by key. +func (s *StatusBar) SetMessage(key, text string) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.segments[key]; !exists { + s.keys = append(s.keys, key) + } + s.segments[key] = text + s.render() +} + +// RemoveMessage removes a segment by key. +func (s *StatusBar) RemoveMessage(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.segments[key]; !exists { + return + } + delete(s.segments, key) + filtered := s.keys[:0] + for _, k := range s.keys { + if k != key { + filtered = append(filtered, k) + } + } + s.keys = filtered + s.render() +} + +// Primitive returns the top-level tview primitive for embedding in layouts. +func (s *StatusBar) Primitive() tview.Primitive { + return s.tv +} + +// render rebuilds the displayed text. Must be called with s.mu held. +func (s *StatusBar) render() { + parts := make([]string, 0, len(s.keys)) + for _, k := range s.keys { + if text := s.segments[k]; text != "" { + parts = append(parts, text) + } + } + s.tv.SetText(strings.Join(parts, " | ")) +} diff --git a/odgcli/internal/views/util.go b/odgcli/internal/views/util.go new file mode 100644 index 0000000..382f181 --- /dev/null +++ b/odgcli/internal/views/util.go @@ -0,0 +1,21 @@ +package views + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func WrapWithFocusBorders(p *tview.Box) { + p.SetFocusFunc(func() { + p.SetBorderColor(tcell.ColorBlue) + }) + p.SetBlurFunc(func() { + p.SetBorderColor(tcell.ColorWhite) + }) +} + +func CreateTreeNode(text string, expanded bool) *tview.TreeNode { + node := tview.NewTreeNode(text).SetExpanded(expanded) + node.SetSelectedTextStyle(tcell.Style{}.Foreground(tcell.ColorDarkCyan)) + return node +} diff --git a/odgcli/main.go b/odgcli/main.go new file mode 100644 index 0000000..55dd6fb --- /dev/null +++ b/odgcli/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/open-component-model/community/odgcli/cmd" + +func main() { + cmd.Execute() +} diff --git a/odgcli/pkg/github/client.go b/odgcli/pkg/github/client.go new file mode 100644 index 0000000..bb23de5 --- /dev/null +++ b/odgcli/pkg/github/client.go @@ -0,0 +1,109 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type Client struct { + httpClient *http.Client + apiUrl string + token string + username string + usernameCache map[string]string +} + +func NewClient(apiUrl, token string) *Client { + return &Client{ + apiUrl: apiUrl, + token: token, + usernameCache: make(map[string]string), + httpClient: &http.Client{}, + } +} + +func (c *Client) makeAuthenticatedRequest(ctx context.Context, method, path string) (*http.Response, error) { + reqURL, err := url.JoinPath(c.apiUrl, path) + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return resp, nil +} + +func (c *Client) LoggedInUsername(ctx context.Context) (string, error) { + if c.username != "" { + return c.username, nil + } + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "user") + if err != nil { + return "", err + } + + defer resp.Body.Close() + + var result struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.Login, nil +} + +// ResolveUsername resolves a GitHub username to the user's real name using the GitHub API +func (c *Client) ResolveUsername(ctx context.Context, username string) (string, error) { + // Check cache first + if realName, found := c.usernameCache[username]; found { + return realName, nil + } + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "users/"+username) + if err != nil { + return "", fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + var result struct { + Name string `json:"name"` + Login string `json:"login"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + // Return the real name if available, otherwise return the username + if result.Name != "" { + c.usernameCache[username] = result.Name + return result.Name, nil + } + return result.Login, nil +} + +func (c *Client) GetURL() string { + return c.apiUrl +} + +func (c *Client) GetToken() string { + return c.token +} diff --git a/odgcli/pkg/odg/client.go b/odgcli/pkg/odg/client.go new file mode 100644 index 0000000..b6c7830 --- /dev/null +++ b/odgcli/pkg/odg/client.go @@ -0,0 +1,408 @@ +package odg + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "iter" + "net/http" + "net/url" + "time" + + "github.com/allegro/bigcache/v3" + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/marshaler" + bigcachestore "github.com/eko/gocache/store/bigcache/v4" + + "github.com/open-component-model/community/odgcli/pkg/github" +) + +// Client provides access to the Delivery Service API. +type Client struct { + baseURL string + token string + httpClient *http.Client + cache *marshaler.Marshaler +} + +// clientConfig holds configurable settings for the Client. +type clientConfig struct { + httpClient *http.Client + timeout time.Duration + cacheTTL time.Duration +} + +// Option configures the Client. +type Option func(*clientConfig) + +// WithHTTPClient sets a custom *http.Client. If set, WithTimeout is ignored. +func WithHTTPClient(c *http.Client) Option { + return func(cfg *clientConfig) { + cfg.httpClient = c + } +} + +// WithTimeout sets the HTTP client timeout. Default: 30s. +func WithTimeout(d time.Duration) Option { + return func(cfg *clientConfig) { + cfg.timeout = d + } +} + +// WithCacheTTL sets how long responses are cached. Default: 5m. +func WithCacheTTL(d time.Duration) Option { + return func(cfg *clientConfig) { + cfg.cacheTTL = d + } +} + +// NewClient authenticates against the Delivery Service and returns an +// authenticated Client. +// +// API: GET /auth +func NewClient(ctx context.Context, baseURL string, ghClient *github.Client, opts ...Option) (*Client, error) { + cfg := &clientConfig{ + timeout: 30 * time.Second, + cacheTTL: 5 * time.Minute, + } + for _, opt := range opts { + opt(cfg) + } + + httpClient := cfg.httpClient + if httpClient == nil { + httpClient = &http.Client{Timeout: cfg.timeout} + } + + reqURL, err := url.JoinPath(baseURL, "auth") + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + params := url.Values{} + params.Add("api_url", ghClient.GetURL()) + params.Add("access_token", ghClient.GetToken()) + + fullURL := reqURL + "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + // Extract cookies + var bearerToken string + for _, cookie := range resp.Cookies() { + switch cookie.Name { + case "bearer_token": + bearerToken = cookie.Value + } + } + + if bearerToken == "" { + return nil, fmt.Errorf("bearer_token cookie not found in response") + } + + // instantiate cache + bigcacheCfg := bigcache.DefaultConfig(cfg.cacheTTL) + bigcacheCfg.Verbose = false + bigcacheClient, err := bigcache.New(ctx, bigcacheCfg) + if err != nil { + return nil, fmt.Errorf("failed to initialize cache: %w", err) + } + bigcacheStore := bigcachestore.NewBigcache(bigcacheClient) + cacheManager := cache.New[any](bigcacheStore) + + // Initializes marshaler + marshal := marshaler.New(cacheManager) + + return &Client{ + baseURL: baseURL, + token: bearerToken, + httpClient: httpClient, + cache: marshal, + }, nil +} + +func (c *Client) makeAuthenticatedRequest(ctx context.Context, method, reqURL string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(req) +} + +// checkResponse checks whether the API response indicates an error. If so, it +// reads and drains the response body (ensuring the TCP connection can be reused) +// and returns a structured *APIError. Returns nil for 2xx status codes. +func checkResponse(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(resp.Body) + return &APIError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Body: body, + } +} + +// QueryMetadataBySearchExpressionRaw executes a single-page artefact-metadata +// search. Criteria are combined with AND across types and OR within the same +// field/attr. Pass the NextCursor from a previous response as the cursor +// parameter to retrieve the next page, or nil for the first page. +// +// For most use cases, prefer QueryMetadataBySearchExpression which handles +// pagination automatically via an iterator. +// +// API: POST /artefacts/metadata/query/by-search-expression +func (c *Client) QueryMetadataBySearchExpressionRaw(ctx context.Context, criteria []MetadataQueryCriterion, limit int, sort []MetadataQuerySort, cursor *MetadataQueryCursor) (*MetadataQueryResponse, error) { + reqURL, err := url.JoinPath(c.baseURL, "artefacts", "metadata", "query", "by-search-expression") + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + body := MetadataQueryRequest{ + Criteria: criteria, + Limit: limit, + Sort: sort, + Cursor: cursor, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if err := checkResponse(resp); err != nil { + return nil, err + } + + var result MetadataQueryResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// QueryMetadataBySearchExpression returns an iterator that transparently pages +// through all results matching the given criteria. Pages are fetched lazily as +// the caller consumes items. Use break to stop early and avoid unnecessary API +// calls. +// +// API: POST /artefacts/metadata/query/by-search-expression +func (c *Client) QueryMetadataBySearchExpression(ctx context.Context, criteria []MetadataQueryCriterion, pageSize int, sort []MetadataQuerySort) iter.Seq2[MetadataQueryItem, error] { + return func(yield func(MetadataQueryItem, error) bool) { + var cursor *MetadataQueryCursor + for { + resp, err := c.QueryMetadataBySearchExpressionRaw(ctx, criteria, pageSize, sort, cursor) + if err != nil { + yield(MetadataQueryItem{}, err) + return + } + for _, item := range resp.Items { + if !yield(item, nil) { + return + } + } + if resp.NextCursor == nil || len(resp.Items) == 0 { + return + } + cursor = resp.NextCursor + } + } +} + +// GetComplianceSummary returns the most critical severity for artefact-metadata +// types across all component dependencies. Compliance summaries contain +// severities and scan-statuses for artefact-metadata types. +// +// API: GET /components/compliance-summary +func (c *Client) GetComplianceSummary(ctx context.Context, componentName, componentVersion string) (*ComplianceSummaryResponse, error) { + reqURL, err := url.JoinPath(c.baseURL, "components", "compliance-summary") + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + params := url.Values{} + params.Add("component_name", componentName) + params.Add("version", componentVersion) + + fullURL := reqURL + "?" + params.Encode() + + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if err := checkResponse(resp); err != nil { + return nil, err + } + + var result ComplianceSummaryResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetResponsiblesOptions configures the GetResponsibles request. +type GetResponsiblesOptions struct { + // Version of the component. Default: "greatest". + Version string + // Raw returns raw label data if true. Default: false. + Raw bool + // IgnoreCache bypasses the server-side cache if true. Default: false. + IgnoreCache bool +} + +// GetResponsibles returns the user identities responsible for a given component. +// Pass nil for opts to use defaults (version=greatest, raw=false, ignore_cache=false). +// +// API: GET /ocm/component/responsibles +func (c *Client) GetResponsibles(ctx context.Context, componentName string, opts *GetResponsiblesOptions) ([]Responsible, error) { + if opts == nil { + opts = &GetResponsiblesOptions{} + } + if opts.Version == "" { + opts.Version = "greatest" + } + + reqURL, err := url.JoinPath(c.baseURL, "ocm", "component", "responsibles") + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + params := url.Values{} + params.Add("component_name", componentName) + params.Add("version", opts.Version) + params.Add("raw", fmt.Sprintf("%t", opts.Raw)) + params.Add("ignore_cache", fmt.Sprintf("%t", opts.IgnoreCache)) + + fullURL := reqURL + "?" + params.Encode() + + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if err := checkResponse(resp); err != nil { + return nil, err + } + + var result ResponsiblesResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var responsibles []Responsible + for _, group := range result.Responsibles { + responsibles = append(responsibles, group...) + } + + return responsibles, nil +} + +// GetRescorings calculates vulnerability rescoring proposals based on +// cve-categorisation and cve-rescoring-ruleset for a given artefact. +// Results are cached for 5 minutes. +// +// API: GET /rescore +func (c *Client) GetRescorings(ctx context.Context, artefact Artefact) ([]Finding, error) { + findings := make([]Finding, 0) + _, err := c.cache.Get(ctx, cacheKeyForArtefactFindings(artefact), &findings) + if err == nil { + return findings, nil + } else if !errors.Is(err, bigcache.ErrEntryNotFound) { + return findings, fmt.Errorf("failed to get findings from cache: %w", err) + } + + reqURL, err := url.JoinPath(c.baseURL, "rescore") + if err != nil { + return nil, fmt.Errorf("failed to construct URL: %w", err) + } + + params := url.Values{} + params.Add("componentName", artefact.ComponentName) + params.Add("componentVersion", artefact.ComponentVersion) + params.Add("artefactKind", artefact.Kind) + params.Add("artefactName", artefact.Info.Name) + params.Add("artefactVersion", artefact.Info.Version) + params.Add("artefactType", artefact.Info.Type) + + if len(artefact.Info.ExtraID) > 0 { + extraIDJSON, err := json.Marshal(artefact.Info.ExtraID) + if err != nil { + return nil, fmt.Errorf("failed to marshal artefactExtraId: %w", err) + } + params.Add("artefactExtraId", string(extraIDJSON)) + } + + // TODO(future): also handle license or malware findings + params.Add("type", "finding/vulnerability") + + fullURL := reqURL + "?" + params.Encode() + + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if err := checkResponse(resp); err != nil { + return nil, err + } + + var result []Finding + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if err := c.cache.Set(ctx, cacheKeyForArtefactFindings(artefact), &result); err != nil { + return nil, fmt.Errorf("failed to cache findings: %w", err) + } + + return result, nil +} + +func cacheKeyForArtefactFindings(artefact Artefact) string { + extraID, err := json.Marshal(artefact.Info.ExtraID) + if err != nil { + extraID = []byte("unknown") + } + return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s:%s", + artefact.ComponentName, + artefact.ComponentVersion, + artefact.Kind, + artefact.Info.Name, + artefact.Info.Version, + artefact.Info.Type, + string(extraID), + "findings", + ) +} diff --git a/odgcli/pkg/odg/client_test.go b/odgcli/pkg/odg/client_test.go new file mode 100644 index 0000000..89de42c --- /dev/null +++ b/odgcli/pkg/odg/client_test.go @@ -0,0 +1,319 @@ +package odg + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-component-model/community/odgcli/pkg/github" +) + +// newTestServer creates an httptest.Server that mimics the Delivery Service API, +// returning fixture data for known endpoints. +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/auth": + http.SetCookie(w, &http.Cookie{Name: "bearer_token", Value: "test-token"}) + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/components/compliance-summary": + serveFixture(w, "testdata/compliance_summary.json") + + case r.URL.Path == "/ocm/component/responsibles": + serveFixture(w, "testdata/responsibles.json") + + case r.URL.Path == "/rescore": + serveFixture(w, "testdata/rescorings.json") + + case r.URL.Path == "/artefacts/metadata/query/by-search-expression": + // Check if cursor is present in request body for pagination test. + var body MetadataQueryRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if body.Cursor != nil { + serveFixture(w, "testdata/metadata_query_page2.json") + } else { + serveFixture(w, "testdata/metadata_query.json") + } + + default: + http.NotFound(w, r) + } + })) +} + +func serveFixture(w http.ResponseWriter, path string) { + data, err := os.ReadFile(path) + if err != nil { + http.Error(w, "fixture not found: "+path, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} + +// newTestClient creates a Client pointed at the given test server. +func newTestClient(t *testing.T, serverURL string) *Client { + t.Helper() + + ghClient := github.NewClient("https://api.github.com", "fake-token") + client, err := NewClient(context.Background(), serverURL, ghClient) + require.NoError(t, err) + return client +} + +func TestNewClient(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + + ghClient := github.NewClient("https://api.github.com", "fake-token") + client, err := NewClient(context.Background(), srv.URL, ghClient) + + require.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test-token", client.token) + assert.Equal(t, srv.URL, client.baseURL) +} + +func TestNewClient_MissingToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return 200 but no bearer_token cookie. + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ghClient := github.NewClient("https://api.github.com", "fake-token") + _, err := NewClient(context.Background(), srv.URL, ghClient) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bearer_token cookie not found") +} + +func TestNewClient_AuthError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error": "invalid token"}`)) + })) + defer srv.Close() + + ghClient := github.NewClient("https://api.github.com", "fake-token") + _, err := NewClient(context.Background(), srv.URL, ghClient) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bearer_token cookie not found") +} + +func TestGetComplianceSummary(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + result, err := client.GetComplianceSummary(context.Background(), "ocm.software/ocmcli", "greatest") + + require.NoError(t, err) + require.NotNil(t, result) + assert.GreaterOrEqual(t, len(result.ComplianceSummary), 1) + + first := result.ComplianceSummary[0] + assert.Equal(t, "ocm.software/ocmcli", first.ComponentID.Name) + assert.NotEmpty(t, first.ComponentID.Version) + assert.NotEmpty(t, first.Entries) + assert.NotEmpty(t, first.Artefacts) +} + +func TestGetResponsibles(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + responsibles, err := client.GetResponsibles(context.Background(), "ocm.software/ocmcli", nil) + + require.NoError(t, err) + assert.Len(t, responsibles, 2) + assert.Equal(t, "johndoe", responsibles[0].Username) + assert.Equal(t, "janedoe", responsibles[1].Username) + assert.Equal(t, "john.doe@example.com", responsibles[0].Email) +} + +func TestGetResponsibles_WithOptions(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + responsibles, err := client.GetResponsibles(context.Background(), "ocm.software/ocmcli", &GetResponsiblesOptions{ + Version: "1.0.0", + IgnoreCache: true, + }) + + require.NoError(t, err) + assert.Len(t, responsibles, 2) +} + +func TestGetRescorings(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + artefact := Artefact{ + ComponentName: "ocm.software/ocmcli", + ComponentVersion: "0.40.0", + Kind: "resource", + Info: ArtefactInfo{ + Name: "ocmcli-image", + Version: "0.40.0", + Type: "ociImage", + }, + } + + findings, err := client.GetRescorings(context.Background(), artefact) + + require.NoError(t, err) + assert.Len(t, findings, 3) + assert.NotEmpty(t, findings[0].Finding.CVE) + assert.NotEmpty(t, findings[0].Severity) +} + +func TestGetRescorings_Cached(t *testing.T) { + var callCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/auth": + http.SetCookie(w, &http.Cookie{Name: "bearer_token", Value: "test-token"}) + case r.URL.Path == "/rescore": + callCount.Add(1) + serveFixture(w, "testdata/rescorings.json") + } + })) + defer srv.Close() + client := newTestClient(t, srv.URL) + + artefact := Artefact{ + ComponentName: "ocm.software/ocmcli", + ComponentVersion: "0.40.0", + Kind: "resource", + Info: ArtefactInfo{ + Name: "ocmcli-image", + Version: "0.40.0", + Type: "ociImage", + }, + } + + // First call hits the server. + _, err := client.GetRescorings(context.Background(), artefact) + require.NoError(t, err) + + // Second call should be served from cache. + _, err = client.GetRescorings(context.Background(), artefact) + require.NoError(t, err) + + assert.Equal(t, int32(1), callCount.Load(), "expected only 1 server call due to caching") +} + +func TestQueryMetadataBySearchExpressionRaw(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + result, err := client.QueryMetadataBySearchExpressionRaw(context.Background(), []MetadataQueryCriterion{ + {Type: "artefact-metadata", Attr: "type", Op: "eq", Value: "rescorings"}, + }, 2, []MetadataQuerySort{ + {Field: "meta.creation_date", Order: "desc"}, + {Field: "id", Order: "desc"}, + }, nil) + + require.NoError(t, err) + assert.Len(t, result.Items, 2) + assert.NotNil(t, result.NextCursor) + assert.NotEmpty(t, result.NextCursor.ID) + assert.NotEmpty(t, result.NextCursor.CreationDate) +} + +func TestQueryMetadataBySearchExpression_Pagination(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + client := newTestClient(t, srv.URL) + + var items []MetadataQueryItem + for item, err := range client.QueryMetadataBySearchExpression(context.Background(), []MetadataQueryCriterion{ + {Type: "artefact-metadata", Attr: "type", Op: "eq", Value: "rescorings"}, + }, 2, []MetadataQuerySort{ + {Field: "meta.creation_date", Order: "desc"}, + {Field: "id", Order: "desc"}, + }) { + require.NoError(t, err) + items = append(items, item) + } + + // Page 1 has 2 items, page 2 has 1 item = 3 total. + assert.Len(t, items, 3) + // First items from page 1. + assert.Equal(t, "acme.org/sovereign/postgres", items[0].Artefact.ComponentName) + // Last item from page 2. + assert.Equal(t, "acme.org/sovereign/redis", items[2].Artefact.ComponentName) +} + +func TestQueryMetadataBySearchExpression_EarlyBreak(t *testing.T) { + var callCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/auth": + http.SetCookie(w, &http.Cookie{Name: "bearer_token", Value: "test-token"}) + case r.URL.Path == "/artefacts/metadata/query/by-search-expression": + callCount.Add(1) + serveFixture(w, "testdata/metadata_query.json") + } + })) + defer srv.Close() + client := newTestClient(t, srv.URL) + + count := 0 + for _, err := range client.QueryMetadataBySearchExpression(context.Background(), []MetadataQueryCriterion{ + {Type: "artefact-metadata", Attr: "type", Op: "eq", Value: "rescorings"}, + }, 2, []MetadataQuerySort{ + {Field: "meta.creation_date", Order: "desc"}, + {Field: "id", Order: "desc"}, + }) { + require.NoError(t, err) + count++ + break // Stop after first item. + } + + assert.Equal(t, 1, count) + // Only 1 API call was made (didn't fetch page 2). + assert.Equal(t, int32(1), callCount.Load()) +} + +func TestAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/auth": + http.SetCookie(w, &http.Cookie{Name: "bearer_token", Value: "test-token"}) + case r.URL.Path == "/components/compliance-summary": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error": "insufficient permissions"}`)) + } + })) + defer srv.Close() + client := newTestClient(t, srv.URL) + + _, err := client.GetComplianceSummary(context.Background(), "test", "1.0.0") + + require.Error(t, err) + var apiErr *APIError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, 403, apiErr.StatusCode) + assert.Contains(t, string(apiErr.Body), "insufficient permissions") +} diff --git a/odgcli/pkg/odg/testdata/compliance_summary.json b/odgcli/pkg/odg/testdata/compliance_summary.json new file mode 100644 index 0000000..9fe0def --- /dev/null +++ b/odgcli/pkg/odg/testdata/compliance_summary.json @@ -0,0 +1,365 @@ +{ + "complianceSummary": [ + { + "componentId": { + "name": "ocm.software/ocmcli", + "version": "0.40.0" + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/osid", + "source": "osid", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/sast", + "source": "sast", + "categorisation": "missing-linting", + "value": 16, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "CRITICAL", + "value": 8, + "scanStatus": "ok" + } + ], + "artefacts": [ + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli", + "artefact_type": "executable", + "artefact_version": "0.40.0", + "artefact_extra_id": { + "architecture": "amd64", + "os": "windows" + } + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "CRITICAL", + "value": 8, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli", + "artefact_type": "executable", + "artefact_version": "0.40.0", + "artefact_extra_id": { + "architecture": "arm64", + "os": "darwin" + } + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "HIGH", + "value": 4, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli", + "artefact_type": "executable", + "artefact_version": "0.40.0", + "artefact_extra_id": { + "architecture": "amd64", + "os": "darwin" + } + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "HIGH", + "value": 4, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli", + "artefact_type": "executable", + "artefact_version": "0.40.0", + "artefact_extra_id": { + "architecture": "amd64", + "os": "linux" + } + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "HIGH", + "value": 4, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli", + "artefact_type": "executable", + "artefact_version": "0.40.0", + "artefact_extra_id": { + "architecture": "arm64", + "os": "linux" + } + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "HIGH", + "value": 4, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "ocmcli-image", + "artefact_type": "ociImage", + "artefact_version": "0.40.0", + "artefact_extra_id": {} + }, + "artefact_kind": "resource", + "references": [] + }, + "entries": [ + { + "type": "finding/crypto", + "source": "crypto", + "categorisation": "not-standard-compliant", + "value": 8, + "scanStatus": "ok" + }, + { + "type": "finding/license", + "source": "bdba", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/malware", + "source": "clamav", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/osid", + "source": "osid", + "categorisation": "CLEAN", + "value": 0, + "scanStatus": "ok" + }, + { + "type": "finding/vulnerability", + "source": "bdba", + "categorisation": "HIGH", + "value": 4, + "scanStatus": "ok" + } + ] + }, + { + "artefact": { + "component_name": "ocm.software/ocmcli", + "component_version": "0.40.0", + "artefact": { + "artefact_name": "source", + "artefact_type": "filesytem", + "artefact_version": "0.40.0", + "artefact_extra_id": {} + }, + "artefact_kind": "source", + "references": [] + }, + "entries": [ + { + "type": "finding/sast", + "source": "sast", + "categorisation": "missing-linting", + "value": 16, + "scanStatus": "ok" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/odgcli/pkg/odg/testdata/metadata_query.json b/odgcli/pkg/odg/testdata/metadata_query.json new file mode 100644 index 0000000..e7ab97e --- /dev/null +++ b/odgcli/pkg/odg/testdata/metadata_query.json @@ -0,0 +1,94 @@ +{ + "items": [ + { + "artefact": { + "component_name": "acme.org/sovereign/postgres", + "component_version": null, + "artefact": { + "artefact_name": "image", + "artefact_type": "ociImage", + "artefact_version": null, + "artefact_extra_id": {} + }, + "artefact_kind": "resource", + "references": [] + }, + "meta": { + "datasource": "delivery-dashboard", + "type": "rescorings", + "creation_date": "2026-04-27T08:27:29.224000+00:00", + "last_update": "2026-04-27T08:27:29.224000+00:00", + "responsibles": null, + "assignee_mode": null + }, + "data": { + "finding": { + "package_name": "golang-runtime", + "cve": "CVE-2026-27143" + }, + "referenced_type": "finding/vulnerability", + "severity": "NONE", + "user": { + "username": "zkdev", + "type": "github-user", + "github_hostname": "api.github.com" + }, + "matching_rules": [ + "custom-rescoring" + ], + "comment": "(dummy) this is a false positive", + "allowed_processing_time": null, + "due_date": null + }, + "discovery_date": null, + "allowed_processing_time": null + }, + { + "artefact": { + "component_name": "acme.org/sovereign/notes", + "component_version": null, + "artefact": { + "artefact_name": null, + "artefact_type": "ociImage", + "artefact_version": null, + "artefact_extra_id": {} + }, + "artefact_kind": "resource", + "references": [] + }, + "meta": { + "datasource": "delivery-dashboard", + "type": "rescorings", + "creation_date": "2026-04-24T09:44:56.010000+00:00", + "last_update": "2026-04-24T09:44:56.010000+00:00", + "responsibles": null, + "assignee_mode": null + }, + "data": { + "finding": { + "package_name": "golang-runtime", + "cve": "CVE-2026-27143" + }, + "referenced_type": "finding/vulnerability", + "severity": "NONE", + "user": { + "username": "vasu1124", + "type": "github-user", + "github_hostname": "api.github.com" + }, + "matching_rules": [ + "custom-rescoring" + ], + "comment": "not exploitable because ...", + "allowed_processing_time": null, + "due_date": null + }, + "discovery_date": null, + "allowed_processing_time": null + } + ], + "nextCursor": { + "meta.creation_date": "2026-04-24T09:44:56.010000+00:00", + "id": "4df97d8c0db484adc93b4e6daf33e9b0" + } +} diff --git a/odgcli/pkg/odg/testdata/metadata_query_page2.json b/odgcli/pkg/odg/testdata/metadata_query_page2.json new file mode 100644 index 0000000..f145897 --- /dev/null +++ b/odgcli/pkg/odg/testdata/metadata_query_page2.json @@ -0,0 +1,46 @@ +{ + "items": [ + { + "artefact": { + "component_name": "acme.org/sovereign/redis", + "component_version": "7.0.0", + "artefact": { + "artefact_name": "redis-image", + "artefact_type": "ociImage", + "artefact_version": "7.0.0", + "artefact_extra_id": {} + }, + "artefact_kind": "resource", + "references": [] + }, + "meta": { + "datasource": "delivery-dashboard", + "type": "rescorings", + "creation_date": "2026-04-20T12:00:00.000000+00:00", + "last_update": "2026-04-20T12:00:00.000000+00:00", + "responsibles": null, + "assignee_mode": null + }, + "data": { + "finding": { + "package_name": "redis", + "cve": "CVE-2026-99999" + }, + "referenced_type": "finding/vulnerability", + "severity": "LOW", + "user": { + "username": "testuser", + "type": "githubUser", + "github_hostname": "github.com" + }, + "matching_rules": [], + "comment": "Low risk, no action needed", + "allowed_processing_time": null, + "due_date": null + }, + "discovery_date": null, + "allowed_processing_time": null + } + ], + "nextCursor": null +} diff --git a/odgcli/pkg/odg/testdata/rescorings.json b/odgcli/pkg/odg/testdata/rescorings.json new file mode 100644 index 0000000..5c63445 --- /dev/null +++ b/odgcli/pkg/odg/testdata/rescorings.json @@ -0,0 +1,143 @@ +[ + { + "finding": { + "severity": "MEDIUM", + "package_name": "golang-runtime", + "package_versions": [ + "1.26.2" + ], + "cve": "CVE-2026-39819", + "cvss_v3_score": 4.4, + "cvss": "AV:L/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:N", + "summary": "Golang Go on Linux Systems Vulnerable to Arbitrary File Overwrites via 'go bug' Command Predictable Temporary Filename Flaw", + "urls": [ + "https://nvd.nist.gov/vuln/detail/CVE-2026-39819" + ], + "filesystem_paths": [ + { + "path": [ + { + "path": "ocmcli-image_0.40.0_ocm.software_ocmcli_ociImage_1681428d-59ac-48f7-86d3-242bf7dd2d46", + "type": "tar" + }, + { + "path": "sha256:b7156fa15b040f79867c7bf7bd161cc2172abecb9c127878409038af63660a32.tar", + "type": "tar" + }, + { + "path": "usr/local/bin/ocm", + "type": "elf" + } + ], + "digest": "f058fb4dfb3ed99da907b8d1c8f43b3bb076093a" + } + ] + }, + "finding_type": "finding/vulnerability", + "severity": "MEDIUM", + "matching_rules": [ + "original-severity" + ], + "applicable_rescorings": [], + "discovery_date": "2026-04-10", + "due_date": "2026-07-09", + "sprint": { + "name": "2026-week-28", + "end_date": "2026-07-16" + } + }, + { + "finding": { + "severity": "HIGH", + "package_name": "go-yaml", + "package_versions": [ + "v1.18.0" + ], + "cve": "CVE-2022-3064", + "cvss_v3_score": 7.5, + "cvss": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "summary": "Parsing malicious or large YAML documents can consume excessive amounts of CPU or memory.", + "urls": [ + "https://nvd.nist.gov/vuln/detail/CVE-2022-3064" + ], + "filesystem_paths": [ + { + "path": [ + { + "path": "ocmcli-image_0.40.0_ocm.software_ocmcli_ociImage_1681428d-59ac-48f7-86d3-242bf7dd2d46", + "type": "tar" + }, + { + "path": "sha256:b7156fa15b040f79867c7bf7bd161cc2172abecb9c127878409038af63660a32.tar", + "type": "tar" + }, + { + "path": "usr/local/bin/ocm", + "type": "elf" + } + ], + "digest": "f058fb4dfb3ed99da907b8d1c8f43b3bb076093a" + } + ] + }, + "finding_type": "finding/vulnerability", + "severity": "HIGH", + "matching_rules": [ + "original-severity" + ], + "applicable_rescorings": [], + "discovery_date": "2026-04-08", + "due_date": "2026-05-08", + "sprint": { + "name": "2026-week-20", + "end_date": "2026-05-21" + } + }, + { + "finding": { + "severity": "HIGH", + "package_name": "go-yaml", + "package_versions": [ + "v1.18.0" + ], + "cve": "CVE-2022-28948", + "cvss_v3_score": 7.5, + "cvss": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "summary": "An issue in the Unmarshal function in Go-Yaml v3 causes the program to crash when attempting to deserialize invalid input.", + "urls": [ + "https://nvd.nist.gov/vuln/detail/CVE-2022-28948" + ], + "filesystem_paths": [ + { + "path": [ + { + "path": "ocmcli-image_0.40.0_ocm.software_ocmcli_ociImage_1681428d-59ac-48f7-86d3-242bf7dd2d46", + "type": "tar" + }, + { + "path": "sha256:b7156fa15b040f79867c7bf7bd161cc2172abecb9c127878409038af63660a32.tar", + "type": "tar" + }, + { + "path": "usr/local/bin/ocm", + "type": "elf" + } + ], + "digest": "f058fb4dfb3ed99da907b8d1c8f43b3bb076093a" + } + ] + }, + "finding_type": "finding/vulnerability", + "severity": "HIGH", + "matching_rules": [ + "original-severity" + ], + "applicable_rescorings": [], + "discovery_date": "2026-04-08", + "due_date": "2026-05-08", + "sprint": { + "name": "2026-week-20", + "end_date": "2026-05-21" + } + } +] \ No newline at end of file diff --git a/odgcli/pkg/odg/testdata/responsibles.json b/odgcli/pkg/odg/testdata/responsibles.json new file mode 100644 index 0000000..94bc847 --- /dev/null +++ b/odgcli/pkg/odg/testdata/responsibles.json @@ -0,0 +1,29 @@ +{ + "responsibles": [ + [ + { + "source": "github", + "type": "githubUser", + "username": "johndoe", + "github_hostname": "github.com", + "email": "john.doe@example.com", + "first_name": "John", + "last_name": "Doe", + "origin_type": "codeowners" + } + ], + [ + { + "source": "github", + "type": "githubUser", + "username": "janedoe", + "github_hostname": "github.com", + "email": "jane.doe@example.com", + "first_name": "Jane", + "last_name": "Doe", + "origin_type": "codeowners" + } + ] + ], + "statuses": [] +} diff --git a/odgcli/pkg/odg/types.go b/odgcli/pkg/odg/types.go new file mode 100644 index 0000000..0c91971 --- /dev/null +++ b/odgcli/pkg/odg/types.go @@ -0,0 +1,278 @@ +package odg + +import ( + "fmt" + "time" +) + +// APIError represents an error response from the Delivery Service API. +// Use errors.As to inspect the status code or response body. +type APIError struct { + StatusCode int + Status string + Body []byte +} + +func (e *APIError) Error() string { + if len(e.Body) > 0 { + return fmt.Sprintf("API error %s: %s", e.Status, string(e.Body)) + } + return fmt.Sprintf("API error %s", e.Status) +} + +// ComplianceSummaryResponse represents the full API response +type ComplianceSummaryResponse struct { + ComplianceSummary []ComplianceSummaryItem `json:"complianceSummary"` +} + +// ComplianceSummaryItem represents a single compliance summary item +type ComplianceSummaryItem struct { + ComponentID ComponentID `json:"componentId"` + Entries []Entry `json:"entries"` + Artefacts []ArtefactEntry `json:"artefacts"` +} + +// ComponentID represents a component identifier +type ComponentID struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Entry represents a compliance entry +type Entry struct { + Type string `json:"type"` + Source string `json:"source"` + Categorisation string `json:"categorisation"` + Value int `json:"value"` + ScanStatus string `json:"scanStatus"` +} + +// ArtefactInfo represents the inner artefact details +type ArtefactInfo struct { + Name string `json:"artefact_name"` + Version string `json:"artefact_version"` + Type string `json:"artefact_type"` + ExtraID map[string]interface{} `json:"artefact_extra_id"` +} + +// Artefact represents an artefact with its metadata +type Artefact struct { + ComponentName string `json:"component_name"` + ComponentVersion string `json:"component_version"` + Kind string `json:"artefact_kind"` + Info ArtefactInfo `json:"artefact"` + References []map[string]interface{} `json:"references"` +} + +// ArtefactEntry represents an artefact with its compliance entries +type ArtefactEntry struct { + Artefact Artefact `json:"artefact"` + Entries []Entry `json:"entries"` +} + +// Responsible represents a responsible person for a component +type Responsible struct { + Source string `json:"source"` + Type string `json:"type"` + Username string `json:"username"` + GithubHostname string `json:"github_hostname"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + OriginType string `json:"origin_type"` +} + +// ResponsiblesStatus represents a status message in the responsibles response +type ResponsiblesStatus struct { + Type string `json:"type"` + Msg string `json:"msg"` +} + +// ResponsiblesResponse represents the response from the responsibles endpoint +type ResponsiblesResponse struct { + Responsibles [][]Responsible `json:"responsibles"` + Statuses []ResponsiblesStatus `json:"statuses"` +} + +// Sprint represents sprint information +type Sprint struct { + Name string `json:"name"` + EndDate string `json:"end_date"` +} + +// Finding represents a single finding from the rescore endpoint +type Finding struct { + Finding FindingDetails `json:"finding"` + FindingType string `json:"finding_type"` + Severity string `json:"severity"` + MatchingRules []string `json:"matching_rules"` + ApplicableRescorings []ApplicableRescoring `json:"applicable_rescorings"` + DiscoveryDate string `json:"discovery_date"` + DueDate *string `json:"due_date"` + Sprint *Sprint `json:"sprint"` +} + +// FindingDetails represents the finding details +type FindingDetails struct { + Severity string `json:"severity"` + PackageName string `json:"package_name"` + PackageVersions []string `json:"package_versions"` + CVE string `json:"cve"` + CVSSv3Score float64 `json:"cvss_v3_score"` + CVSS string `json:"cvss"` + Summary string `json:"summary"` + URLs []string `json:"urls"` + FilesystemPaths []FilesystemPath `json:"filesystem_paths"` +} + +// ApplicableRescoring represents a rescoring that applies to a finding +type ApplicableRescoring struct { + Artefact RescoringArtefact `json:"artefact"` + Meta RescoringMeta `json:"meta"` + Data RescoringData `json:"data"` + DiscoveryDate *string `json:"discovery_date"` + AllowedProcessingTime *string `json:"allowed_processing_time"` + ID string `json:"id"` +} + +// PathElement represents a single path element in filesystem_paths +type PathElement struct { + Path string `json:"path"` + Type string `json:"type"` +} + +// FilesystemPath represents a filesystem path with digest +type FilesystemPath struct { + Path []PathElement `json:"path"` + Digest string `json:"digest"` +} + +// RescoringArtefact represents the artefact in a rescoring +type RescoringArtefact struct { + ComponentName string `json:"component_name"` + ComponentVersion *string `json:"component_version"` + Artefact RescoringArtefactInfo `json:"artefact"` + ArtefactKind string `json:"artefact_kind"` + References []map[string]interface{} `json:"references"` +} + +// RescoringArtefactInfo represents the inner artefact info in a rescoring +type RescoringArtefactInfo struct { + ArtefactName *string `json:"artefact_name"` + ArtefactType string `json:"artefact_type"` + ArtefactVersion *string `json:"artefact_version"` + ArtefactExtraID map[string]interface{} `json:"artefact_extra_id"` +} + +// RescoringMeta represents the meta information of a rescoring +type RescoringMeta struct { + Datasource string `json:"datasource"` + Type string `json:"type"` + CreationDate string `json:"creation_date"` + LastUpdate string `json:"last_update"` + Responsibles *string `json:"responsibles"` + AssigneeMode *string `json:"assignee_mode"` +} + +// RescoringUser represents the user who created the rescoring +type RescoringUser struct { + Username string `json:"username"` + Type string `json:"type"` + GithubHostname string `json:"github_hostname,omitempty"` + Email string `json:"email,omitempty"` + Firstname string `json:"firstname,omitempty"` + Lastname string `json:"lastname,omitempty"` +} + +// RescoringFinding represents the finding reference in a rescoring +type RescoringFinding struct { + PackageName string `json:"package_name"` + CVE string `json:"cve"` +} + +// RescoringData represents the data of a rescoring +type RescoringData struct { + Finding RescoringFinding `json:"finding"` + ReferencedType string `json:"referenced_type"` + Severity string `json:"severity"` + User RescoringUser `json:"user"` + MatchingRules []string `json:"matching_rules"` + Comment string `json:"comment"` + AllowedProcessingTime *string `json:"allowed_processing_time"` + DueDate *string `json:"due_date"` +} + +// Comment represents a rescoring comment extracted from the metadata query API. +type Comment struct { + Author string `json:"author"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + ComponentName string `json:"component_name"` + ComponentVersion string `json:"component_version"` + ArtefactName string `json:"artefact_name"` + ArtefactVersion string `json:"artefact_version"` + Severity string `json:"severity"` +} + +// MetadataQueryCriterion represents a search criterion for the metadata query. +type MetadataQueryCriterion struct { + Type string `json:"type"` + Attr string `json:"attr"` + Op string `json:"op"` + Value string `json:"value"` +} + +// MetadataQuerySort represents a sort order for the metadata query. +type MetadataQuerySort struct { + Field string `json:"field"` + Order string `json:"order"` +} + +// MetadataQueryCursor represents the cursor for seek-based pagination. +// It must contain all sort fields. +type MetadataQueryCursor struct { + CreationDate string `json:"meta.creation_date"` + ID string `json:"id"` +} + +// MetadataQueryRequest represents the request body for the metadata query endpoint. +type MetadataQueryRequest struct { + Criteria []MetadataQueryCriterion `json:"criteria"` + Limit int `json:"limit"` + Sort []MetadataQuerySort `json:"sort"` + Cursor *MetadataQueryCursor `json:"cursor,omitempty"` +} + +// MetadataQueryArtefact is like Artefact but with a nullable component_version. +type MetadataQueryArtefact struct { + ComponentName string `json:"component_name"` + ComponentVersion *string `json:"component_version"` + Info ArtefactInfo `json:"artefact"` + Kind string `json:"artefact_kind"` + References []map[string]interface{} `json:"references"` +} + +// MetadataQueryMeta represents the meta field in a metadata query response item. +type MetadataQueryMeta struct { + Datasource string `json:"datasource"` + Type string `json:"type"` + CreationDate string `json:"creation_date"` + LastUpdate string `json:"last_update"` + Responsibles *string `json:"responsibles"` + AssigneeMode *string `json:"assignee_mode"` +} + +// MetadataQueryItem represents a single item in the metadata query response. +type MetadataQueryItem struct { + Artefact MetadataQueryArtefact `json:"artefact"` + Meta MetadataQueryMeta `json:"meta"` + Data RescoringData `json:"data"` + DiscoveryDate *string `json:"discovery_date"` + AllowedProcessingTime *string `json:"allowed_processing_time"` +} + +// MetadataQueryResponse represents the response from the metadata query endpoint. +type MetadataQueryResponse struct { + Items []MetadataQueryItem `json:"items"` + NextCursor *MetadataQueryCursor `json:"nextCursor,omitempty"` +}