diff --git a/app/cli/cmd/artifact.go b/app/cli/cmd/artifact.go index 66725b6dc..2ce0b2a6c 100644 --- a/app/cli/cmd/artifact.go +++ b/app/cli/cmd/artifact.go @@ -48,7 +48,7 @@ func wrappedArtifactConn(cpConn *grpc.ClientConn, role pb.CASCredentialsServiceG } if apiInsecure() { - logger.Warn().Msg("API contacted in insecure mode") + logger.Debug().Msg("API contacted in insecure mode") } var opts = []grpcconn.Option{ diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 55e3ce8c2..6b7b9c1e9 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -53,6 +53,11 @@ var ( defaultCASAPI = "api.cas.chainloop.dev:443" apiToken string flagYes bool + // authTokenIsUser records whether the credentials in use are a user session + // rather than an API token. Commands that behave differently for the two, + // such as `trace init` skipping organization selection for org-scoped API + // tokens, read it instead of parsing the token again. + authTokenIsUser bool ) const ( @@ -120,13 +125,14 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { } if apiInsecure() { - logger.Warn().Msg("API contacted in insecure mode") + logger.Debug().Msg("API contacted in insecure mode") } authToken, isUserToken, err := loadAuthToken(cmd) if err != nil { return err } + authTokenIsUser = isUserToken // If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available if authToken == "" && cmdSupportsFederatedAuth(cmd) { diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go index a653d72be..8d2434c54 100644 --- a/app/cli/cmd/trace.go +++ b/app/cli/cmd/trace.go @@ -21,7 +21,7 @@ import ( // traceContractFlagDesc documents the --contract flag `trace init` and // `trace run` share. -const traceContractFlagDesc = `workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)` +const traceContractFlagDesc = `workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)` // newTraceCmd creates the trace parent command with all subcommands. func newTraceCmd() *cobra.Command { diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index f4d141ef5..206dfc49d 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -16,19 +16,24 @@ package cmd import ( + "cmp" "context" + "errors" "fmt" + "io" + "os" + "path/filepath" + "strings" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" + "github.com/spf13/viper" ) // newTraceInitCmd creates the trace init subcommand. @@ -47,10 +52,21 @@ func newTraceInitCmd() *cobra.Command { Long: `Initialize git hooks that automatically trace AI coding sessions and create Chainloop attestations when you push. -It installs the managed git hooks plus the hooks of the selected agent -providers (Claude Code when none is given), and creates the Chainloop workflow -the attestations target. Nothing is written to the repository until that -workflow exists, so you need to be logged in. +It installs the managed git hooks plus the hooks of the selected harnesses +(Claude Code when none is given), and creates the Chainloop workflow the +attestations target. Nothing is written to the repository until that workflow +exists, so you need to be logged in. + +On a terminal it asks which organization and project to use, offering what +.chainloop.yml already holds so pressing Enter keeps it. A new project can be +named freely; the name is normalized to the lowercase, dash-separated form +Chainloop stores. It then asks which harnesses to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a harness flag +(--claude, --cursor, --opencode) skips the matching question. + +Nothing is asked in CI or when the output is redirected: there --project is +required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to +turn the questions off on a terminal too. The organization, project, workflow and require-trace values are saved to .chainloop.yml, and every push reads them from there.`, @@ -65,60 +81,121 @@ The organization, project, workflow and require-trace values are saved to return err } - if err := ensureTraceInitWorkflow(cmd.Context(), cfg, contract); err != nil { - return err + // Fill in whatever the flags and .chainloop.yml did not settle, + // asking the user when there is one to ask. The executor it hands + // back is pinned to the resolved organization and reused below. + executor, err := resolveTraceIdentity(cmd.Context(), cfg, repoRoot) + if err != nil { + return stopIfAborted(err) + } + defer func() { _ = executor.Close() }() + + // Ask which agents to trace before anything is created or written, + // so every question is answered up front and an abort leaves the + // repository untouched. + selected, err := resolveTraceProviders(newNamingPrompter(os.LookupEnv), + traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}, + traceInitCanPrompt()) + if err != nil { + return stopIfAborted(err) } - // The workflow exists: from here on it is safe to leave hooks and - // configuration behind. - if err := cfg.save(repoRoot); err != nil { + selectedProviders := providers.ByNames(selected) + if len(selectedProviders) == 0 { + return fmt.Errorf("no trace providers selected") + } + + if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil { return err } - // Create trace directory structure - store := state.NewGitStore(gitDir) - if err := store.InitTraceDir(); err != nil { - return fmt.Errorf("create trace directory: %w", err) + // The harness hooks come first of everything that is left behind. + // They are what records a session, so a run where none of them lands + // has achieved nothing, and stopping here leaves the configuration, + // the git hooks and the initialized marker unwritten rather than + // leaving a repository that looks set up and records nothing. + // + // Pre-push infers the owning harness per-session from the recorded + // SessionRecord, so the list isn't persisted anywhere — only each + // harness's own hook config file, written here, determines which of + // them can register sessions. + installed := make([]trace.Provider, 0, len(selectedProviders)) + // before holds every repository file init is about to write, as it + // stood, so a later failure puts back exactly what was there. + // Uninstalling the hooks instead would strip a repository that was + // already set up, since installing over hooks changes nothing and + // still counts as installed. + before := make([]fileSnapshot, 0, len(selectedProviders)+1) + + for _, p := range selectedProviders { + snapshot, err := snapshotFile(p.SettingsFile(repoRoot)) + if err != nil { + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not read the harness configuration") + continue + } + + if err := p.InstallHooks(repoRoot); err != nil { + // A write that failed part-way through leaves the file as it + // got to, so put it back before moving on. + if rerr := snapshot.restore(); rerr != nil { + logger.Debug().Err(rerr).Str("path", snapshot.path). + Msg("could not put the harness configuration back") + } + + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") + + continue + } + + logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") + installed = append(installed, p) + before = append(before, snapshot) + } + + if len(installed) == 0 { + return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") } - // Install git hooks - hooksDir, err := hooks.Install(gitDir, false) + // .chainloop.yml is written by the step below, which stops at the + // first thing that fails, so it is recorded here alongside the rest. + ymlSnapshot, err := snapshotFile(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot))) if err != nil { return err } - logger.Info(). - Str("path", hooksDir). - Msg("git hooks installed (post-commit, pre-push)") - // Mark trace as initialized - if err := store.MarkTraceInitialized(); err != nil { - return fmt.Errorf("mark trace initialized: %w", err) - } + before = append(before, ymlSnapshot) + + // The workflow exists and something records into it. What remains is + // local, but a failure part-way through it would leave a repository + // half set up: harness hooks calling a configuration that is not + // there, or a configuration with nothing recording into it. + if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { + for _, snapshot := range before { + if rerr := snapshot.restore(); rerr != nil { + logger.Debug().Err(rerr).Str("path", snapshot.path). + Msg("could not put the file back") + } + } - // Resolve which providers to install. Pre-push will infer the - // owning provider per-session from the recorded SessionRecord, - // so the list isn't persisted anywhere — only the agent-side - // hook config files (.claude/settings.json, .cursor/hooks.json) - // determine which providers can register sessions. - selected := selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag) - selectedProviders := providers.ByNames(selected) - if len(selectedProviders) == 0 { - return fmt.Errorf("no trace providers selected") + return err } - for _, p := range selectedProviders { - if err := p.InstallHooks(repoRoot); err != nil { - logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") - continue - } - logger.Info().Str("provider", p.Name()).Msg("agent hooks installed") + workDir, err := os.Getwd() + if err != nil { + // Only used to render paths relative to where the user is; the + // repository root still names them correctly. + workDir = repoRoot } - logger.Info(). - Str("project", cfg.project). - Str("workflow", cfg.workflow). - Strs("providers", selected). - Msg("trace initialized") + // Nothing pinned an organization when cfg has none, so the workflow + // went to the one the CLI points at. That is the one to report: it is + // what the connection used, not a guess. + organization := cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey)) + + // What the run produced, and what to do with it. Every step above logs + // at debug, so this is what the user is left with. + writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed)) + writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil }, @@ -128,31 +205,230 @@ The organization, project, workflow and require-trace values are saved to cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc) cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")") cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits") - cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") return cmd } -// ensureTraceInitWorkflow makes sure the workflow the trace attestations target -// exists, creating it up front while someone is watching the output. Left to -// the pre-push hook, the control plane creates it implicitly with an empty -// contract and no one reads the result. Anything other than the workflow -// already existing is fatal, so a failed init leaves nothing behind. -func ensureTraceInitWorkflow(ctx context.Context, cfg *traceInitConfig, contractFlag string) error { - // If the repo pins an organization, use it so the workflow lands where the - // hooks will actually attest. An empty name keeps the default connection. - executor, err := action.NewAttestationExecutor(ActionOpts, Version, action.WithForcedOrganization(cfg.organization)) +// traceDocsURL is the guide covering what this command set up and what can be +// done with it, which is more than belongs in a command's own output. +const traceDocsURL = "https://docs.chainloop.dev/guides/chainloop-trace" + +// writeTraceInitSummary reports what the repository was set up with. It goes to +// stdout rather than through the logger because it is the command's result, +// not a note about something that happened on the way there. +func writeTraceInitSummary(w io.Writer, organization, project, workflow string, harnesses []string) { + fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") + + // Empty only when the CLI has no organization configured either, which + // leaves nothing truthful to name. + if organization != "" { + fmt.Fprintf(w, " organization %s\n", organization) + } + + fmt.Fprintf(w, " project %s\n", project) + fmt.Fprintf(w, " workflow %s\n", workflow) + fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) +} + +// writeTraceNextSteps says what is left for the user to do. Committing comes +// first: what init wrote into the repository is shared, and until it is +// committed this setup exists in one working copy only. +// +// It stops short of promising that a teammate who pulls is set up: the git +// hooks live in .git/hooks, which git does not carry between clones, so they +// still have to run init themselves. What they gain is having nothing to answer +// when they do. +// +// workDir is where the user ran the command, which is what `git add` resolves +// its arguments against; it is not always the repository root. +func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) { + files := make([]string, 0, len(installed)+1) + add := func(absolute string) { + // An absolute path stages just as well, so it is the fallback for the + // rare case where no relative one exists. + if rel, err := filepath.Rel(workDir, absolute); err == nil { + files = append(files, rel) + return + } + + files = append(files, absolute) + } + + add(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot))) + + for _, p := range installed { + add(p.SettingsFile(repoRoot)) + } + + fmt.Fprintf(w, ` +What's next + + 1. Commit these files. Teammates then only need to run chainloop trace init: + git add %s + 2. Start %s and write some code + 3. Commit and push as usual. The AI coding sessions behind those commits are + recorded and stored automatically + +Learn more: %s +`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL) +} + +// providerNames names the harnesses, in the order they were installed. +func providerNames(installed []trace.Provider) []string { + names := make([]string, 0, len(installed)) + for _, p := range installed { + names = append(names, p.Name()) + } + + return names +} + +// joinWithOr renders a list the way a sentence needs it: "a", "a or b", or +// "a, b or c". +func joinWithOr(names []string) string { + switch len(names) { + case 0: + return "" + case 1: + return names[0] + default: + return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1] + } +} + +// fileSnapshot is a file init is about to write to, as it stood beforehand. +// Every write init makes to the repository merges into or replaces something +// that may already be there — a harness's hooks sit beside the rest of its +// configuration, and installing over hooks that are present changes nothing at +// all — so putting the file back is the only way to undo a run without taking a +// working setup with it. +type fileSnapshot struct { + path string + // content is what the file held, and existed distinguishes an empty file + // from one init created. + content []byte + mode os.FileMode + existed bool + // existingAncestor is the closest directory that was already there, so + // restoring removes the directories init created and no others. + existingAncestor string +} + +// snapshotFile records a file before it is written to. +func snapshotFile(path string) (fileSnapshot, error) { + content, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + ancestor, err := closestExistingDir(filepath.Dir(path)) + if err != nil { + return fileSnapshot{}, err + } + + return fileSnapshot{path: path, existingAncestor: ancestor}, nil + } + + if err != nil { + return fileSnapshot{}, err + } + + // The mode is kept so restoring does not quietly widen or narrow it. + info, err := os.Stat(path) if err != nil { + return fileSnapshot{}, err + } + + return fileSnapshot{path: path, content: content, mode: info.Mode().Perm(), existed: true}, nil +} + +// closestExistingDir walks up from dir to the first directory that is there, +// which is the point below which anything is init's to remove again. +func closestExistingDir(dir string) (string, error) { + for { + info, err := os.Stat(dir) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", dir) + } + + return dir, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + parent := filepath.Dir(dir) + if parent == dir { + // The root, which always exists, so this is unreachable in practice. + return dir, nil + } + + dir = parent + } +} + +// restore puts the file back as it was, removing it when init was what created +// it, along with the directories that came with it. A directory holding +// anything else stays, which os.Remove sees to by refusing to remove it. +func (f fileSnapshot) restore() error { + if f.existed { + return os.WriteFile(f.path, f.content, f.mode) + } + + if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + for dir := filepath.Dir(f.path); dir != f.existingAncestor; dir = filepath.Dir(dir) { + if err := os.Remove(dir); err != nil { + // Not empty, or already gone: either way there is nothing more of + // init's to take back above it. + return nil + } + } + + return nil +} + +// writeTraceInitState leaves the repository set up: the configuration, the +// trace directory, the managed git hooks and the marker that says init ran. It +// is one step so its caller can undo the harness hooks if any part of it fails, +// rather than leaving a half-configured repository behind. +func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error { + if err := cfg.save(repoRoot); err != nil { return err } - defer func() { _ = executor.Close() }() - if err := executor.CheckAuth(ctx); err != nil { + store := state.NewGitStore(gitDir) + if err := store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + hooksDir, err := hooks.Install(gitDir, false) + if err != nil { return err } + logger.Debug().Str("path", hooksDir).Msg("git hooks installed (post-commit, pre-push)") + + if err := store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + return nil +} + +// ensureTraceInitWorkflow makes sure the workflow the trace attestations target +// exists, creating it up front while someone is watching the output. Left to +// the pre-push hook, the control plane creates it implicitly with an empty +// contract and no one reads the result. Anything other than the workflow +// already existing is fatal, so a failed init leaves nothing behind. +// +// It runs on the executor resolveTraceIdentity opened, which is already pinned +// to the resolved organization and authenticated. +func ensureTraceInitWorkflow(ctx context.Context, executor *action.AttestationExecutor, cfg *traceInitConfig, contractFlag string) error { contractName, contractRequired := config.ResolveContract(contractFlag) wf, err := executor.EnsureWorkflow(ctx, action.EnsureTraceWorkflowOpts{ ProjectName: cfg.project, @@ -165,7 +441,10 @@ func ensureTraceInitWorkflow(ctx context.Context, cfg *traceInitConfig, contract } if wf.Created { - logger.Info(). + // The workflow name is the same in every project, so the project is what + // identifies what was just created. + logger.Debug(). + Str("project", cfg.project). Str("workflow", cfg.workflow). Str("contract", wf.ContractName). Msg("workflow created") @@ -200,12 +479,11 @@ func resolveTraceInitConfig(cmd *cobra.Command, repoRoot, projectFlag string) (* organization: config.LoadOrganizationFromYML(repoRoot), } + // A project may still be missing here. resolveTraceIdentity either asks for + // one or, when nobody can be asked, reports that it is required. if cfg.project == "" { cfg.project = config.LoadProjectFromYML(repoRoot) } - if cfg.project == "" { - return nil, fmt.Errorf("--project is required (or add projectName to .chainloop.yml)") - } // --org is inherited from the root command, so it is only meant for this // repository when the user passed it explicitly. @@ -248,21 +526,21 @@ func (c *traceInitConfig) save(repoRoot string) error { if err := config.SaveProjectToYML(repoRoot, c.project); err != nil { return err } - logger.Info().Str("project", c.project).Msg("project name saved to .chainloop.yml") + logger.Debug().Str("project", c.project).Msg("project name saved to .chainloop.yml") } if c.saveOrganization { if err := config.SaveOrganizationToYML(repoRoot, c.organization); err != nil { return err } - logger.Info().Str("organization", c.organization).Msg("organization saved to .chainloop.yml") + logger.Debug().Str("organization", c.organization).Msg("organization saved to .chainloop.yml") } if c.saveWorkflow { if err := config.SaveWorkflowToYML(repoRoot, c.workflow); err != nil { return err } - logger.Info().Str("workflow", c.workflow).Msg("workflow saved to .chainloop.yml") + logger.Debug().Str("workflow", c.workflow).Msg("workflow saved to .chainloop.yml") } if c.saveRequireTrace { @@ -273,25 +551,3 @@ func (c *traceInitConfig) save(repoRoot string) error { return nil } - -// selectedTraceProviders resolves the provider names to install based on the -// --claude, --cursor, and --opencode flags. When none is set, Claude Code is -// used as the default so existing users get the same behavior. -func selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag bool) []string { - if !claudeFlag && !cursorFlag && !opencodeFlag { - return []string{providers.DefaultProvider} - } - - var out []string - if claudeFlag { - out = append(out, claude.Name) - } - if cursorFlag { - out = append(out, cursor.Name) - } - if opencodeFlag { - out = append(out, opencode.Name) - } - - return out -} diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go new file mode 100644 index 000000000..ee5ceeadd --- /dev/null +++ b/app/cli/cmd/trace_init_identity.go @@ -0,0 +1,443 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/app/cli/pkg/prompt" + + "golang.org/x/term" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + orgPromptTitle = "Select a Chainloop organization" + newOrgPromptTitle = "Name for your new organization" + + // newOrgPromptReason says why a name is being asked for, since arriving at + // this question means the user has no organization to pick from. + newOrgPromptReason = "you do not belong to one yet, so this is the first step" + + // readOnlyOrgMarker labels an organization the caller can only view. Those + // are shown rather than dropped — an organization missing from the list + // reads as something gone wrong — and refused when picked. Organization + // names are DNS-1123 labels, so no real one can end in this. + readOnlyOrgMarker = " (read-only)" + + // The project picker offers creating one only to a caller allowed to, so it + // has a title for each case: promising an action that is not on the list + // would be the same dead end as offering the action itself. + projectPromptTitle = "Select or create a Chainloop project" + selectProjectPromptTitle = "Select a Chainloop project" + + newProjectPromptTitle = "Name for the new project" + + // createNewProjectOption closes the project picker, below the projects + // themselves. huh has no separator or grouping in a list — every entry is a + // label and a value, drawn alike — so the "+" is what sets an action apart + // from the names it sits under. + createNewProjectOption = "+ Create a new project…" +) + +// errAborted is what the prompter returns when the user dismisses a question. +// It stops `trace init` before anything is created or written, so the +// repository is left untouched. It is the prompt package's error rather than +// one of its own, since that is what comes back. +var errAborted = prompt.ErrAborted + +// stopIfAborted turns a dismissed prompt into a clean stop and passes every +// other error through. Dismissing a question is a decision, not a failure, and +// nothing has been created or written by the time one can be dismissed, so +// reporting it as an error would be both noisy and wrong. +func stopIfAborted(err error) error { + if !errors.Is(err, errAborted) { + return err + } + + logger.Info().Msg("aborted, nothing was changed") + + return nil +} + +// ciSignals are environment variables that mean the CLI is running inside a CI +// system, where a prompt would hang the build. CI covers almost every provider; +// the rest are for the ones that do not set it. +var ciSignals = []string{ + "CI", + "CONTINUOUS_INTEGRATION", + "BUILD_NUMBER", + "BUILD_BUILDID", + "TEAMCITY_VERSION", + "JENKINS_URL", +} + +// noPromptEnvVar lets a user with a real terminal turn prompting off, for +// wrappers and automation that run the CLI attached to a pseudo-terminal. +const noPromptEnvVar = "CHAINLOOP_NO_PROMPT" + +// orgAPI lists the organizations the current user belongs to, and creates one +// for a user who belongs to none. It is the slice of the attestation executor +// the organization resolver needs, so that resolver can be table-tested without +// a control plane. +type orgAPI interface { + ListOrganizations(ctx context.Context) ([]*action.MembershipItem, error) + CreateOrganization(ctx context.Context, name string) error +} + +// projectLister lists the projects visible in the selected organization, and +// is likewise the slice of the executor the project resolver needs. +type projectLister interface { + ListProjects(ctx context.Context) (*action.TraceProjects, error) +} + +// prompter asks the user to pick from a list or type a value. It keeps the +// resolution logic below independent of the terminal library, so it can be +// table-tested with a scripted fake. +// +// Every prompt takes a description, drawn under its title and inside the same +// frame, for what the question itself has to explain — why a name is being +// asked for at all, when arriving at it means there was nothing to pick. Empty +// means no line. +type prompter interface { + // Select asks for one of options. validate, when set, refuses an answer and + // keeps the prompt open, the way Input's does. + Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) + // MultiSelect asks for zero or more of options, starting with defaults + // ticked. Implementations refuse an empty submission. + MultiSelect(title string, options, defaults []string) ([]string, error) + Input(title, description, defaultValue string, validate func(string) error) (string, error) +} + +// resolvedValue is one setting the interactive path settled on. +type resolvedValue struct { + value string + // save is true when the value differs from what .chainloop.yml holds and so + // has to be written back. An absent value in the file counts as different, + // which leaves an interactive init with a fully self-describing repository. + save bool + // prompted is false when the value was settled without asking, so the caller + // can report what was picked on the user's behalf. + prompted bool +} + +// traceInitCanPrompt reports whether `trace init` has a user to ask, wiring +// isInteractive to the real environment and terminals. +func traceInitCanPrompt() bool { + return isInteractive(os.LookupEnv, + term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stderr.Fd()))) +} + +// isInteractive reports whether the session can show a prompt: both streams the +// prompt uses are terminals, the terminal can render one, and this is not a CI +// run. It takes its inputs so the decision can be unit tested, the same way +// colorSupported does. +func isInteractive(lookupEnv func(string) (string, bool), stdinIsTerminal, stderrIsTerminal bool) bool { + if envEnabled(lookupEnv, noPromptEnvVar) { + return false + } + + // The prompt reads from stdin and draws on stderr; either one redirected + // means there is nobody to answer. + if !stdinIsTerminal || !stderrIsTerminal { + return false + } + + if v, ok := lookupEnv("TERM"); ok && v == "dumb" { + return false + } + + for _, key := range ciSignals { + if envEnabled(lookupEnv, key) { + return false + } + } + + return true +} + +// envEnabled reports whether an environment variable is set to something that +// means "yes". Present but empty, "0" or "false" is treated as unset, since +// developers do set CI=false locally. +func envEnabled(lookupEnv func(string) (string, bool), key string) bool { + v, ok := lookupEnv(key) + return ok && v != "" && v != "0" && v != "false" +} + +// resolveInteractiveOrganization picks the organization the trace attestations +// land in. fromYML is what .chainloop.yml pins, currentOrg the one the CLI is +// pointing at; both may be empty. +func resolveInteractiveOrganization(ctx context.Context, api orgAPI, p prompter, fromYML, currentOrg string) (*resolvedValue, error) { + memberships, err := api.ListOrganizations(ctx) + if err != nil { + return nil, fmt.Errorf("listing your organizations: %w", err) + } + + // labels are what the picker shows; usable holds only the ones that can be + // picked, which is where the cursor has to start. + labels := make([]string, 0, len(memberships)) + usable := make([]string, 0, len(memberships)) + // serverDefault is the membership the control plane marks as the current one. + var serverDefault string + + for _, m := range memberships { + if m.Org == nil || m.Org.Name == "" { + continue + } + + // An organization the caller can only view cannot hold a trace workflow, + // since creating one is not a viewer's to do. It is shown marked rather + // than left out: an organization that simply disappeared from the list + // reads as something gone wrong. + if m.Role == action.RoleViewer { + labels = append(labels, m.Org.Name+readOnlyOrgMarker) + continue + } + + labels = append(labels, m.Org.Name) + usable = append(usable, m.Org.Name) + + if m.Default { + serverDefault = m.Org.Name + } + } + + if len(labels) == 0 { + // Belonging to none is the one case where there is nothing to pick and + // something to do about it. + return promptNewOrganization(ctx, api, p, fromYML) + } + + if len(usable) == 0 { + return nil, errors.New("you can only view the organizations you belong to, ask someone with write permissions on one of their projects to set this up") + } + + // A single entry, which the check above makes a usable one: there is nothing + // to weigh it against, so it is not worth asking. + if len(labels) == 1 { + return &resolvedValue{value: usable[0], save: usable[0] != fromYML}, nil + } + + // Preselect what the repository already pins, then what the CLI points at, + // then what the server considers current, among the ones that can be picked. + chosen, err := p.Select(orgPromptTitle, "", labels, + firstPresent(usable, fromYML, currentOrg, serverDefault), refuseReadOnlyOrg) + if err != nil { + return nil, err + } + + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil +} + +// refuseReadOnlyOrg keeps the picker open on an organization the caller can +// only view, saying why instead of failing later on a workflow that cannot be +// created there. +func refuseReadOnlyOrg(chosen string) error { + name, readOnly := strings.CutSuffix(chosen, readOnlyOrgMarker) + if !readOnly { + return nil + } + + return fmt.Errorf("you can only view %q, ask someone with write permissions on one of its projects to set this up", name) +} + +// promptNewOrganization collects a name and creates the organization, for a +// user who belongs to none. There is nothing to select in that case and every +// later step needs an organization, so creating the first one is the only way +// forward. An instance can restrict who may create them, which leaves nothing +// to do but say so. +// +// It is the one point where trace init changes anything before the repository +// is written to. An organization left behind by an init that is then abandoned +// is the user's to keep, and is waiting for them on the next run. +func promptNewOrganization(ctx context.Context, api orgAPI, p prompter, fromYML string) (*resolvedValue, error) { + answer, err := p.Input(newOrgPromptTitle, newOrgPromptReason, "", validateNewOrganization) + if err != nil { + return nil, err + } + + name := config.SlugifyDNS1123(answer) + if err := api.CreateOrganization(ctx, name); err != nil { + if status.Code(err) == codes.PermissionDenied { + return nil, fmt.Errorf("this instance only lets administrators create organizations, so ask to be invited to one: %w", err) + } + + return nil, fmt.Errorf("creating organization %q: %w", name, err) + } + + logger.Info().Str("organization", name).Msg("organization created") + + return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil +} + +// validateNewOrganization checks what a free-form name normalizes to, so a name +// the control plane would refuse is caught at the prompt. Organizations are +// named by the same rule projects are. +func validateNewOrganization(answer string) error { + return config.ValidateDNS1123Label(config.OrganizationSubject, config.SlugifyDNS1123(answer)) +} + +// resolveInteractiveProject picks the project the workflow is created in, or +// collects a name for a new one. fromYML is what .chainloop.yml pins and +// repoDir the repository's directory name, used to seed a new name. +func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir string) (*resolvedValue, error) { + listing, err := lister.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("listing the projects you can see: %w", err) + } + + visible := listing.Projects + + // Only the projects the caller can add a workflow to are worth offering; + // picking any other one would fail at creation time. The rest are still + // worth knowing about, to refuse one that gets typed in by name. + writable := make([]string, 0, len(visible)) + readOnly := make([]string, 0, len(visible)) + + for _, project := range visible { + if project.CanCreateWorkflow { + writable = append(writable, project.Name) + continue + } + + readOnly = append(readOnly, project.Name) + } + + // newProjectSeed is what the new-project prompt starts from: the repository's + // own directory name, no matter what .chainloop.yml holds. A project the + // organization already has is picked from the list rather than typed, and a + // name it does not have is one nothing was ever created under — proposing it + // again would hand back a name as likely to be a typo as an intention. + newProjectSeed := config.SlugifyDNS1123(repoDir) + + // pinnedIsMissing records that .chainloop.yml names a project the + // organization does not have, which decides where the cursor starts. + var pinnedIsMissing bool + + // The listing is the source of truth: what .chainloop.yml names is only worth + // offering if the organization actually has it. Anything else it holds is + // stale, names a project in another organization, or was never created. + if fromYML != "" && !slices.Contains(writable, fromYML) { + if slices.Contains(readOnly, fromYML) { + logger.Warn().Str("project", fromYML). + Msg("you cannot create a workflow in the project this repository points at, pick another one") + } else { + // Not in the organization at all, so it names a project that does not + // exist rather than one that can be picked: offering it as if it were + // real is what makes picking it fail at creation time. Nothing pinned + // points anywhere usable, so the cursor starts on creating instead. + pinnedIsMissing = true + + logger.Debug().Str("project", fromYML). + Msg("the project this repository points at does not exist in this organization") + } + } + + // Creating one is a permission of the organization role, not of any project: + // an organization contributor administering every project it can see still + // cannot create another. With nothing to pick either, there is no way + // forward, so say so instead of asking. + if !listing.CanCreateProject && len(writable) == 0 { + return nil, errors.New("you cannot create projects in this organization, and none of the projects you can see accepts a new workflow; ask an administrator for access to one") + } + + // Nothing to choose from, so go straight to naming one rather than showing a + // list holding nothing but the create entry. + if len(writable) == 0 { + return promptNewProject(p, newProjectSeed, fromYML, readOnly) + } + + // The create entry is only offered to a role that may act on it; offering it + // anyway would fail on the name they just typed. It goes last, under the + // projects, since picking one of those is the common answer. + title, options := selectProjectPromptTitle, writable + if listing.CanCreateProject { + title = projectPromptTitle + options = append(slices.Clone(writable), createNewProjectOption) + } + + // The cursor starts on what the repository pins, and on creating when what it + // pins does not exist, since that name is what it is asking to be created. + preselect := fromYML + if pinnedIsMissing { + preselect = createNewProjectOption + } + + chosen, err := p.Select(title, "", options, firstPresent(options, preselect), nil) + if err != nil { + return nil, err + } + + if chosen == createNewProjectOption { + return promptNewProject(p, newProjectSeed, fromYML, readOnly) + } + + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil +} + +// promptNewProject collects a free-form project name and returns it normalized +// to the DNS-1123 label the control plane stores. A name that normalizes to one +// of the existing projects simply resolves to that project, so readOnly names +// are refused: typing one is the same dead end as picking it from the list. +func promptNewProject(p prompter, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { + answer, err := p.Input(newProjectPromptTitle, "", defaultName, newProjectValidator(readOnly)) + if err != nil { + return nil, err + } + + name := config.SlugifyDNS1123(answer) + + return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil +} + +// newProjectValidator checks what a free-form name normalizes to, so a name +// that cannot become a valid project is refused at the prompt instead of by the +// control plane a moment later. readOnly are the projects the caller can see but +// not write to, refused here for the same reason they are not offered. +func newProjectValidator(readOnly []string) func(string) error { + return func(answer string) error { + name := config.SlugifyDNS1123(answer) + if err := config.ValidateDNS1123Label(config.ProjectSubject, name); err != nil { + return err + } + + if slices.Contains(readOnly, name) { + return fmt.Errorf("you can only view the project %q, so a workflow cannot be created in it", name) + } + + return nil + } +} + +// firstPresent returns the first candidate that appears in options, falling +// back to the first option. +func firstPresent(options []string, candidates ...string) string { + for _, c := range candidates { + if c != "" && slices.Contains(options, c) { + return c + } + } + + return options[0] +} diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go new file mode 100644 index 000000000..42274fcda --- /dev/null +++ b/app/cli/cmd/trace_init_identity_test.go @@ -0,0 +1,798 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// --- fakes ------------------------------------------------------------------- + +// fakeOrgAPI serves a canned membership list and records what the resolver +// asked to have created. +type fakeOrgAPI struct { + orgs []*action.MembershipItem + err error + + // createErr is what creating an organization fails with, and created + // records the name it was asked for. + createErr error + created string +} + +func (f *fakeOrgAPI) ListOrganizations(context.Context) ([]*action.MembershipItem, error) { + return f.orgs, f.err +} + +func (f *fakeOrgAPI) CreateOrganization(_ context.Context, name string) error { + f.created = name + + return f.createErr +} + +// fakeProjectLister serves a canned listing. projects are the ones the caller +// may create a workflow in, readOnly the ones it can only see. cannotCreate +// inverts the default so the common case stays untyped in the table. +type fakeProjectLister struct { + projects []string + readOnly []string + cannotCreate bool + err error +} + +func (f *fakeProjectLister) ListProjects(context.Context) (*action.TraceProjects, error) { + if f.err != nil { + return nil, f.err + } + + out := make([]*action.TraceProject, 0, len(f.projects)+len(f.readOnly)) + for _, name := range f.projects { + out = append(out, &action.TraceProject{Name: name, CanCreateWorkflow: true}) + } + + for _, name := range f.readOnly { + out = append(out, &action.TraceProject{Name: name}) + } + + return &action.TraceProjects{Projects: out, CanCreateProject: !f.cannotCreate}, nil +} + +// recordedPrompt captures what a prompt was shown, so tests can assert on the +// options and the preselected default rather than only on the outcome. +type recordedPrompt struct { + title string + // description is the line drawn under the title, inside the same frame. + description string + options []string + defaultValue string + // defaults is the preselected set for a multi-select. + defaults []string +} + +// fakePrompter answers with scripted values. Input runs the real validator +// first, the way a terminal prompt would, so validation is covered through the +// same seam the huh implementation uses. +type fakePrompter struct { + selectAnswer string + selectErr error + multiSelectAnswer []string + multiSelectErr error + inputAnswer string + inputErr error + + selects []recordedPrompt + multiSelects []recordedPrompt + inputs []recordedPrompt +} + +func (f *fakePrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { + f.multiSelects = append(f.multiSelects, recordedPrompt{title: title, options: options, defaults: defaults}) + if f.multiSelectErr != nil { + return nil, f.multiSelectErr + } + + return f.multiSelectAnswer, nil +} + +func (f *fakePrompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { + f.selects = append(f.selects, recordedPrompt{title: title, description: description, options: options, defaultValue: defaultValue}) + if f.selectErr != nil { + return "", f.selectErr + } + + // Run the validator the way a terminal prompt would, so what it refuses is + // covered through the same seam the huh implementation uses. + if validate != nil { + if err := validate(f.selectAnswer); err != nil { + return "", err + } + } + + return f.selectAnswer, nil +} + +func (f *fakePrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { + f.inputs = append(f.inputs, recordedPrompt{title: title, description: description, defaultValue: defaultValue}) + if f.inputErr != nil { + return "", f.inputErr + } + + if validate != nil { + if err := validate(f.inputAnswer); err != nil { + return "", err + } + } + + return f.inputAnswer, nil +} + +func membership(name string, isDefault bool) *action.MembershipItem { + return &action.MembershipItem{Default: isDefault, Org: &action.OrgItem{Name: name}} +} + +// viewerMembership is one the caller can only read, where no workflow can be +// created and so no tracing can be set up. +func viewerMembership(name string, isDefault bool) *action.MembershipItem { + m := membership(name, isDefault) + m.Role = action.RoleViewer + + return m +} + +// Fixtures the table cases share. +const ( + // nameWithNothingUsable normalizes to an empty string, so it is refused + // wherever a name is asked for. + nameWithNothingUsable = "!!!" + + orgAcme = "acme" + orgGlobex = "globex" + projectAPI = "backend-api" + repoDirMyRepo = "My Repo" +) + +// --- isInteractive ----------------------------------------------------------- + +func TestIsInteractive(t *testing.T) { + testCases := []struct { + name string + env map[string]string + stdin bool + stderr bool + want bool + }{ + {name: "both streams are terminals and no CI signal", stdin: true, stderr: true, want: true}, + {name: "stdin is not a terminal", stdin: false, stderr: true, want: false}, + {name: "stderr is not a terminal", stdin: true, stderr: false, want: false}, + {name: "neither is a terminal", want: false}, + { + name: "CI is set, even with a pseudo-terminal", + env: map[string]string{"CI": "true"}, + stdin: true, stderr: true, want: false, + }, + { + name: "CI explicitly set to false is not CI", + env: map[string]string{"CI": "false"}, + stdin: true, stderr: true, want: true, + }, + { + name: "CI set to an empty value is not CI", + env: map[string]string{"CI": ""}, + stdin: true, stderr: true, want: true, + }, + // Providers that do not set CI are covered by TestIsInteractiveCISignals. + { + name: "a dumb terminal cannot render a prompt", + env: map[string]string{"TERM": "dumb"}, + stdin: true, stderr: true, want: false, + }, + { + name: "the explicit opt-out wins over a real terminal", + env: map[string]string{"CHAINLOOP_NO_PROMPT": "1"}, + stdin: true, stderr: true, want: false, + }, + { + name: "the opt-out set to 0 does not opt out", + env: map[string]string{"CHAINLOOP_NO_PROMPT": "0"}, + stdin: true, stderr: true, want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lookupEnv := func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + } + assert.Equal(t, tc.want, isInteractive(lookupEnv, tc.stdin, tc.stderr)) + }) + } +} + +// TestIsInteractiveCISignals covers every provider in the list, so adding one +// without it actually suppressing prompting fails here. +func TestIsInteractiveCISignals(t *testing.T) { + for _, key := range ciSignals { + t.Run(key, func(t *testing.T) { + lookupEnv := func(k string) (string, bool) { + if k == key { + return "1", true + } + + return "", false + } + assert.False(t, isInteractive(lookupEnv, true, true), "%s must disable prompting", key) + }) + } +} + +// --- organization ------------------------------------------------------------ + +func TestResolveInteractiveOrganization(t *testing.T) { + testCases := []struct { + name string + orgs []*action.MembershipItem + fromYML string + currentOrg string + answer string + // inputAnswer is what the user types when there is no organization to + // pick and one has to be created. + inputAnswer string + // createErr is what creating the organization fails with. + createErr error + wantValue string + wantSave bool + // wantDefault is the preselected entry; empty means no prompt is expected + wantDefault string + // wantCreated is the organization the resolver asked to have created. + wantCreated string + wantErr string + }{ + { + // Nothing to select, so the only way forward is the first one. + name: "belonging to no organization creates one", + orgs: nil, + inputAnswer: "My First Org", + wantValue: "my-first-org", + wantSave: true, + wantCreated: "my-first-org", + }, + { + // An instance can restrict creating organizations to its + // administrators, which leaves a user who belongs to none with + // nothing to do but ask. + name: "an instance that forbids creating organizations says so", + inputAnswer: "my-org", + createErr: status.Error(codes.PermissionDenied, "restricted to instance admins"), + wantCreated: "my-org", + wantErr: "ask to be invited", + }, + { + name: "a creation failure is returned", + inputAnswer: "my-org", + createErr: errors.New("boom"), + wantCreated: "my-org", + wantErr: "boom", + }, + { + // A name the control plane would refuse is caught at the prompt, so + // nothing is attempted with it. + name: "an unusable new name is refused before creating", + inputAnswer: nameWithNothingUsable, + wantErr: "organization name cannot be empty", + }, + { + name: "a single organization is used without prompting", + orgs: []*action.MembershipItem{membership(orgAcme, true)}, + wantValue: orgAcme, + wantSave: true, + }, + { + name: "a single organization already pinned needs no save", + orgs: []*action.MembershipItem{membership(orgAcme, true)}, + fromYML: orgAcme, + wantValue: orgAcme, + wantSave: false, + }, + { + name: "several organizations preselect the pinned one", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: orgGlobex, + currentOrg: orgAcme, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: false, + wantDefault: orgGlobex, + }, + { + name: "with nothing pinned the CLI's current organization is preselected", + orgs: []*action.MembershipItem{membership(orgAcme, false), membership(orgGlobex, true)}, + currentOrg: orgAcme, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + wantDefault: orgAcme, + }, + { + name: "with no current organization the default membership is preselected", + orgs: []*action.MembershipItem{membership(orgAcme, false), membership(orgGlobex, true)}, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgGlobex, + }, + { + name: "a pinned organization the user no longer belongs to falls back to the current one", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: "left-this-one", + currentOrg: orgAcme, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + wantDefault: orgAcme, + }, + { + name: "picking a different organization than the pinned one saves it", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: orgAcme, + currentOrg: orgAcme, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgAcme, + }, + { + // Shown so it is clear the organization is still there, and why it + // cannot be used, rather than going missing from the list. + name: "an organization the user can only view is marked and refused", + orgs: []*action.MembershipItem{membership(orgAcme, true), viewerMembership(orgGlobex, false)}, + answer: orgGlobex + readOnlyOrgMarker, + wantDefault: orgAcme, + wantErr: "you can only view", + }, + { + // The cursor cannot start on an entry that would be refused, even + // when the server calls it the current one. + name: "a viewer organization is never the preselected one", + orgs: []*action.MembershipItem{viewerMembership(orgAcme, true), membership(orgGlobex, false)}, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgGlobex, + }, + { + // Not the same as belonging to none: there is nothing to create here, + // only someone to ask. + name: "only viewer organizations leaves nothing to set up", + orgs: []*action.MembershipItem{viewerMembership(orgAcme, true), viewerMembership(orgGlobex, false)}, + wantErr: "write permissions on one of their projects", + }, + { + // The one usable organization is not the only entry, so the picker is + // still shown: the viewer one has to be visible to be understood. + name: "a single usable organization beside a viewer one still prompts", + orgs: []*action.MembershipItem{membership(orgAcme, false), viewerMembership(orgGlobex, false)}, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + wantDefault: orgAcme, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{selectAnswer: tc.answer, inputAnswer: tc.inputAnswer} + api := &fakeOrgAPI{orgs: tc.orgs, createErr: tc.createErr} + got, err := resolveInteractiveOrganization(context.Background(), api, p, tc.fromYML, tc.currentOrg) + + // Asserted before the error check too, so a case that must not reach + // the control plane says so. + assert.Equal(t, tc.wantCreated, api.created, "organization created") + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValue, got.value) + assert.Equal(t, tc.wantSave, got.save) + + if tc.wantCreated != "" { + assert.Empty(t, p.selects, "there was nothing to pick from") + require.Len(t, p.inputs, 1, "the name should have been asked for") + assert.True(t, got.prompted) + return + } + + if tc.wantDefault == "" { + assert.Empty(t, p.selects, "no prompt should have been shown") + assert.False(t, got.prompted, "the value was not prompted for") + return + } + + assert.True(t, got.prompted) + require.Len(t, p.selects, 1) + assert.Equal(t, tc.wantDefault, p.selects[0].defaultValue) + assert.Len(t, p.selects[0].options, len(tc.orgs)) + }) + } +} + +func TestResolveInteractiveOrganizationErrors(t *testing.T) { + t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { + _, err := resolveInteractiveOrganization(context.Background(), + &fakeOrgAPI{err: errors.New("boom")}, &fakePrompter{}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + }) + + t.Run("an aborted prompt is returned", func(t *testing.T) { + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + _, err := resolveInteractiveOrganization(context.Background(), + &fakeOrgAPI{orgs: orgs}, &fakePrompter{selectErr: errAborted}, "", "") + require.ErrorIs(t, err, errAborted) + }) +} + +// --- project ----------------------------------------------------------------- + +func TestResolveInteractiveProject(t *testing.T) { + testCases := []struct { + name string + projects []string + fromYML string + repoDir string + // readOnly are projects the caller can see but not create a workflow in + readOnly []string + // cannotCreate is the organization role refusing new projects + cannotCreate bool + // selectAnswer is what the user picks in the list; empty means the list + // is not expected to be shown + selectAnswer string + // inputAnswer is what the user types when creating + inputAnswer string + wantValue string + wantSave bool + // wantOptions is the full option list, in order + wantOptions []string + // wantSelectDefault / wantInputDefault are the preselected values + wantSelectDefault string + wantInputDefault string + wantErr string + }{ + { + name: "with no visible projects the name is typed straight away", + repoDir: repoDirMyRepo, + inputAnswer: "my-repo", + wantValue: "my-repo", + wantSave: true, + wantInputDefault: "my-repo", + }, + { + // Answering with what is already pinned writes nothing back, however + // the answer was arrived at. + name: "with no visible projects the repository name is the default", + fromYML: "existing", + repoDir: repoDirMyRepo, + inputAnswer: "existing", + wantValue: "existing", + wantSave: false, + wantInputDefault: "my-repo", + }, + { + name: "the pinned project is preselected in the list", + projects: []string{projectAPI, "payments"}, + fromYML: "payments", + repoDir: "whatever", + selectAnswer: "payments", + wantValue: "payments", + wantSave: false, + wantOptions: []string{projectAPI, "payments", createNewProjectOption}, + wantSelectDefault: "payments", + }, + { + name: "with nothing pinned the first project is preselected", + projects: []string{projectAPI, "payments"}, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, "payments", createNewProjectOption}, + wantSelectDefault: projectAPI, + }, + { + // The listing is the source of truth. Offering a name only + // .chainloop.yml knows about makes it look like an existing project, + // and picking it asks the control plane to create one, which fails + // outright for a caller who may not create projects. + name: "a pinned project the organization does not have is not offered", + projects: []string{projectAPI}, + fromYML: "gone-or-never-existed", + repoDir: "whatever", + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: createNewProjectOption, + }, + { + // Nothing was ever created under that name, so it is as likely to be a + // typo as an intention. The cursor starts on creating, since nothing + // the repository points at can be picked, but the name is asked for + // rather than proposed back. + name: "a pinned project the organization does not have does not seed the new name", + projects: []string{projectAPI}, + fromYML: "gone-or-never-existed", + repoDir: repoDirMyRepo, + selectAnswer: createNewProjectOption, + inputAnswer: "gone-or-never-existed", + wantValue: "gone-or-never-existed", + wantSave: false, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: createNewProjectOption, + wantInputDefault: "my-repo", + }, + { + name: "choosing to create opens an input prefilled with the repository name", + projects: []string{projectAPI}, + repoDir: "My Cool Project", + selectAnswer: createNewProjectOption, + inputAnswer: "My Cool Project", + wantValue: "my-cool-project", + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, + wantInputDefault: "my-cool-project", + }, + { + name: "a free-form new name is normalized", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: " Payments API (v2)!! ", + wantValue: "payments-api-v2", + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, + wantInputDefault: "repo", + }, + { + name: "a new name that normalizes to an existing project resolves to it", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: "Backend API", + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + }, + { + name: "a name that normalizes to nothing is rejected", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: nameWithNothingUsable, + wantErr: "cannot be empty", + }, + { + // Offering it would fail at creation time, which is the dead end + // this whole prompt exists to remove. + name: "a project the caller can only view is not offered", + projects: []string{projectAPI}, + readOnly: []string{"locked"}, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, + }, + { + // The listing did carry it, so it is a real project, but picking it + // would fail at creation time just the same. + // It is a real project rather than a missing one, so the cursor stays + // on the list instead of starting on creating it. + name: "a pinned project the caller can only view is neither offered nor preselected", + projects: []string{projectAPI}, + readOnly: []string{"payments"}, + fromYML: "payments", + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, + }, + { + name: "a pinned read-only project does not seed the new name", + readOnly: []string{"payments"}, + fromYML: "payments", + repoDir: repoDirMyRepo, + inputAnswer: "my-repo", + wantValue: "my-repo", + wantSave: true, + wantInputDefault: "my-repo", + }, + { + // Keeping a read-only project out of the list is not enough: typing + // its name reaches the same dead end. + name: "typing the name of a project the caller can only view is refused", + readOnly: []string{"payments"}, + repoDir: repoDirMyRepo, + inputAnswer: "Payments", + wantErr: "you can only view the project", + }, + { + name: "typing a read-only name is refused from the create entry too", + projects: []string{projectAPI}, + readOnly: []string{"payments"}, + repoDir: repoDirMyRepo, + selectAnswer: createNewProjectOption, + inputAnswer: "payments", + wantErr: "you can only view the project", + }, + { + // Creating is an organization-role permission, so a contributor that + // administers projects still cannot have the option: it would fail on + // whatever name they typed. + name: "a caller who cannot create projects is not offered the option", + projects: []string{projectAPI, "payments"}, + cannotCreate: true, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, "payments"}, + wantSelectDefault: projectAPI, + }, + { + name: "a pinned project is still preselected without the create option", + projects: []string{projectAPI, "payments"}, + cannotCreate: true, + fromYML: "payments", + repoDir: repoDirMyRepo, + selectAnswer: "payments", + wantValue: "payments", + wantSave: false, + wantOptions: []string{projectAPI, "payments"}, + wantSelectDefault: "payments", + }, + { + // Nothing to pick and nothing to create is a dead end, so it says so + // instead of asking for a name the control plane would refuse. + name: "no writable project and no permission to create says so", + readOnly: []string{"payments"}, + cannotCreate: true, + repoDir: repoDirMyRepo, + wantErr: "you cannot create projects in this organization", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} + got, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValue, got.value) + assert.Equal(t, tc.wantSave, got.save) + + if tc.wantOptions == nil { + assert.Empty(t, p.selects, "no list should have been shown") + } else { + require.Len(t, p.selects, 1) + assert.Equal(t, tc.wantOptions, p.selects[0].options) + if tc.wantSelectDefault != "" { + assert.Equal(t, tc.wantSelectDefault, p.selects[0].defaultValue) + } + + // The title offers creating a project exactly when the list does. + wantTitle := selectProjectPromptTitle + if slices.Contains(tc.wantOptions, createNewProjectOption) { + wantTitle = projectPromptTitle + } + + assert.Equal(t, wantTitle, p.selects[0].title) + } + + if tc.wantInputDefault != "" { + require.Len(t, p.inputs, 1) + assert.Equal(t, tc.wantInputDefault, p.inputs[0].defaultValue) + } + }) + } +} + +func TestResolveInteractiveProjectErrors(t *testing.T) { + t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + }) + + t.Run("an aborted list is returned", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo") + require.ErrorIs(t, err, errAborted) + }) + + t.Run("an aborted input is returned", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo") + require.ErrorIs(t, err, errAborted) + }) +} + +// --- non-interactive --------------------------------------------------------- + +// TestResolveTraceIdentityNonInteractive covers the path a CI run takes. Test +// binaries have no terminal attached, so isInteractive is false here and the +// step never reaches the control plane. +func TestResolveTraceIdentityNonInteractive(t *testing.T) { + t.Run("a missing project is reported, naming both ways to supply one", func(t *testing.T) { + _, err := resolveTraceIdentity(context.Background(), &traceInitConfig{}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required in non-interactive mode") + assert.Contains(t, err.Error(), "projectName to .chainloop.yml") + }) +} + +// --- aborting ---------------------------------------------------------------- + +// TestStopIfAborted pins down that dismissing a prompt ends the command +// quietly. Returning the error instead would exit 1 and print "ERROR aborted", +// which reads as a failure when the user simply changed their mind. +func TestStopIfAborted(t *testing.T) { + other := errors.New("boom") + + testCases := []struct { + name string + err error + want error + }{ + {name: "a dismissed prompt is not an error", err: errAborted, want: nil}, + {name: "a wrapped dismissal is recognized", err: fmt.Errorf("asking: %w", errAborted), want: nil}, + {name: "any other error is passed through", err: other, want: other}, + {name: "no error stays no error", err: nil, want: nil}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, stopIfAborted(tc.err)) + }) + } +} diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go new file mode 100644 index 000000000..f58926588 --- /dev/null +++ b/app/cli/cmd/trace_init_prompt.go @@ -0,0 +1,63 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/pkg/prompt" +) + +// namingPrompter is the prompt package plus the one thing about these questions +// that is Chainloop's rather than the terminal's: an answer typed freely is +// stored as a DNS-1123 label, so the input shows what it will become. Nothing +// else here knows about naming rules. +type namingPrompter struct { + *prompt.Prompter +} + +// newNamingPrompter builds the prompter `trace init` asks its questions through. +func newNamingPrompter(lookupEnv func(string) (string, bool)) namingPrompter { + return namingPrompter{prompt.New(lookupEnv)} +} + +// Input asks for a name, showing the label it will be stored as once the two +// differ. Seeing what will be created matters more at that point than the +// context it will be created in, so it takes the description's place. +func (n namingPrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { + return n.Prompter.Input(prompt.InputOpts{ + Title: title, + Description: description, + Default: defaultValue, + Validate: validate, + Describe: func(value string) string { + return nameDescription(description, value) + }, + }) +} + +// nameDescription is the line under a name prompt's title: the standing +// context, giving way to what the answer normalizes to once the answer and its +// label differ. +func nameDescription(description, value string) string { + slug := config.SlugifyDNS1123(value) + if slug == "" || slug == value { + return description + } + + return fmt.Sprintf("will be created as %q", slug) +} diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go new file mode 100644 index 000000000..0c05f27c6 --- /dev/null +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -0,0 +1,59 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestNameDescription covers the line under a name prompt's title, which is the +// only thing about these prompts that is Chainloop's rather than the terminal's. +// The prompt package covers the rest. +func TestNameDescription(t *testing.T) { + const context = "you do not belong to one yet" + + testCases := []struct { + name string + description string + value string + want string + }{ + {name: "an empty answer keeps the context", description: context, want: context}, + { + name: "an answer that needs no normalizing keeps it", + description: context, value: "my-project", want: context, + }, + { + name: "an answer that normalizes shows what it becomes", + description: context, value: "My Project", want: `will be created as "my-project"`, + }, + { + // Nothing usable to show yet, so the context stands rather than + // flickering to an empty line. + name: "an answer with nothing usable in it keeps the context", + description: context, value: nameWithNothingUsable, want: context, + }, + {name: "no context and no answer shows nothing", value: "my-project"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, nameDescription(tc.description, tc.value)) + }) + } +} diff --git a/app/cli/cmd/trace_init_providers.go b/app/cli/cmd/trace_init_providers.go new file mode 100644 index 000000000..e4f4cfc18 --- /dev/null +++ b/app/cli/cmd/trace_init_providers.go @@ -0,0 +1,106 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" +) + +const providersPromptTitle = "Harnesses to trace in this repository" + +// traceProviderFlags records which --claude / --cursor / --opencode flags the +// user set. +type traceProviderFlags struct { + claude, cursor, opencode bool +} + +// any reports whether the user named at least one provider. +func (f traceProviderFlags) any() bool { + return f.claude || f.cursor || f.opencode +} + +// names returns the providers the flags select, in the registry's order rather +// than the order the flags happen to appear in. +func (f traceProviderFlags) names() []string { + out := make([]string, 0, 3) + if f.claude { + out = append(out, claude.Name) + } + if f.cursor { + out = append(out, cursor.Name) + } + if f.opencode { + out = append(out, opencode.Name) + } + + return out +} + +// namesOrDefault returns the providers the flags select, falling back to the +// default when none was named. `trace run` is flag-driven and never prompts, +// so it resolves its providers this way. +func (f traceProviderFlags) namesOrDefault() []string { + if !f.any() { + return []string{providers.DefaultProvider} + } + + return f.names() +} + +// traceProviderNames lists every registered provider, in the order the registry +// declares them. +func traceProviderNames() []string { + all := providers.All() + + names := make([]string, 0, len(all)) + for _, p := range all { + names = append(names, p.Name()) + } + + return names +} + +// resolveTraceProviders picks the agents whose hooks init installs. Flags win +// and skip the question, the same way --org and --project do. Otherwise a +// terminal session ticks them off a list with the default preselected, and a +// non-interactive one keeps that default so scripted runs are unchanged. +func resolveTraceProviders(p prompter, flags traceProviderFlags, interactive bool) ([]string, error) { + if flags.any() { + return flags.names(), nil + } + + if !interactive { + return []string{providers.DefaultProvider}, nil + } + + chosen, err := p.MultiSelect(providersPromptTitle, traceProviderNames(), []string{providers.DefaultProvider}) + if err != nil { + return nil, err + } + + // The prompt refuses an empty submission, so this only catches a prompter + // that does not, but tracing nothing would install hooks that never fire. + if len(chosen) == 0 { + return nil, errors.New("select at least one harness to trace") + } + + return chosen, nil +} diff --git a/app/cli/cmd/trace_init_providers_test.go b/app/cli/cmd/trace_init_providers_test.go new file mode 100644 index 000000000..c9972d3ce --- /dev/null +++ b/app/cli/cmd/trace_init_providers_test.go @@ -0,0 +1,134 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// providerClaudeCode is the wire name of the default provider, spelled out +// rather than taken from the package constant so that renaming it shows up here +// as a failing test instead of passing silently. +const providerClaudeCode = "claude-code" + +func TestResolveTraceProviders(t *testing.T) { + testCases := []struct { + name string + flags traceProviderFlags + interactive bool + // answer is what the user ticks in the multi-select + answer []string + want []string + // wantPrompted is whether the multi-select should have been shown + wantPrompted bool + wantErr string + }{ + { + name: "a single flag wins and asks nothing", + flags: traceProviderFlags{cursor: true}, + // Interactive, to show the flag is what suppresses the question. + interactive: true, + want: []string{"cursor"}, + }, + { + name: "several flags are all honored", + flags: traceProviderFlags{claude: true, opencode: true}, + interactive: true, + want: []string{providerClaudeCode, "opencode"}, + }, + { + name: "without a terminal the default provider is kept", + want: []string{providers.DefaultProvider}, + }, + { + name: "on a terminal the user picks", + interactive: true, + answer: []string{"cursor", "opencode"}, + want: []string{"cursor", "opencode"}, + wantPrompted: true, + }, + { + name: "picking nothing is refused", + interactive: true, + answer: []string{}, + wantPrompted: true, + wantErr: "at least one", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{multiSelectAnswer: tc.answer} + + got, err := resolveTraceProviders(p, tc.flags, tc.interactive) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + + if !tc.wantPrompted { + assert.Empty(t, p.multiSelects, "no question should have been asked") + return + } + + require.Len(t, p.multiSelects, 1) + // Every registered provider is offered, with the default ticked. + assert.Equal(t, traceProviderNames(), p.multiSelects[0].options) + assert.Equal(t, []string{providers.DefaultProvider}, p.multiSelects[0].defaults) + }) + } +} + +func TestResolveTraceProvidersPromptError(t *testing.T) { + _, err := resolveTraceProviders(&fakePrompter{multiSelectErr: errAborted}, traceProviderFlags{}, true) + require.ErrorIs(t, err, errAborted) +} + +func TestTraceProviderFlags(t *testing.T) { + t.Run("no flag set", func(t *testing.T) { + assert.False(t, traceProviderFlags{}.any()) + assert.Empty(t, traceProviderFlags{}.names()) + }) + + t.Run("names follow the registry order, not the flag order", func(t *testing.T) { + f := traceProviderFlags{opencode: true, claude: true} + assert.True(t, f.any()) + assert.Equal(t, []string{providerClaudeCode, "opencode"}, f.names()) + }) +} + +// TestTraceProviderNamesMatchesRegistry guards the multi-select against a +// provider being added to the registry but never offered. +func TestTraceProviderNamesMatchesRegistry(t *testing.T) { + names := traceProviderNames() + require.Len(t, names, len(providers.All())) + + for _, name := range names { + assert.NotNil(t, providers.ByName(name), "%q is offered but not registered", name) + } + + assert.Contains(t, names, providers.DefaultProvider, "the default must be offerable") +} diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go new file mode 100644 index 000000000..97004a9cd --- /dev/null +++ b/app/cli/cmd/trace_init_resolve.go @@ -0,0 +1,154 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/spf13/viper" +) + +// resolveTraceIdentity settles the organization and project that were not fixed +// by a flag or by .chainloop.yml, prompting for them when the session is +// interactive. It updates cfg in place, marking for write-back only the values +// that differ from what the file holds. +// +// It returns an authenticated executor pinned to the resolved organization, +// which the caller reuses to create the workflow and must close. Nothing is +// written to the repository here: the caller saves only after the workflow +// exists, so an aborted or failed init leaves the repository untouched. +func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot string) (*action.AttestationExecutor, error) { + // An API token is bound to its own organization server-side, so there is + // nothing to choose and no membership list to read. + interactive := authTokenIsUser && traceInitCanPrompt() + + if !interactive { + if cfg.project == "" { + return nil, errors.New("--project is required in non-interactive mode (or add projectName to .chainloop.yml)") + } + + return openTraceExecutor(ctx, cfg.organization) + } + + // Authenticate before asking anything: the pickers are populated from the + // control plane, so an unauthenticated user should see the login error + // rather than answer questions that lead to one. + // + // The connection starts unpinned. A .chainloop.yml pinning an organization + // the user has since left would otherwise fail this check before they get + // the chance to pick another one, and listing memberships is a user-scoped + // call that ignores the organization anyway. + executor, err := openTraceExecutor(ctx, "") + if err != nil { + return nil, err + } + + // pinTo swaps the connection for one pinned to the resolved organization, + // so the project listing and the workflow creation that follows both + // target it. The replacement is opened before the old one is dropped, so + // executor always names a live connection for the error path to close. + pinTo := func(org string) (projectLister, error) { + if org == "" { + return executor, nil + } + + pinned, err := openTraceExecutor(ctx, org) + if err != nil { + return nil, err + } + + _ = executor.Close() + executor = pinned + + return pinned, nil + } + + if err := resolveIdentityInteractively(ctx, cfg, newNamingPrompter(os.LookupEnv), + viper.GetString(confOptions.organization.viperKey), filepath.Base(repoRoot), executor, pinTo); err != nil { + _ = executor.Close() + return nil, err + } + + return executor, nil +} + +// resolveIdentityInteractively asks for the settings the flags left open and +// records them on cfg. pinTo is called once the organization is settled, and +// always, since the caller works on the repinned connection whether or not a +// project question follows. +// +// A value the user passed as a flag is already settled: it skips its question +// and keeps the value and the save behavior resolveTraceInitConfig gave it, +// which is what the save flags mean at this point. +func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p prompter, + currentOrg, repoDir string, orgs orgAPI, pinTo func(org string) (projectLister, error)) error { + askOrg, askProject := !cfg.saveOrganization, !cfg.saveProject + + if askOrg { + org, err := resolveInteractiveOrganization(ctx, orgs, p, cfg.organization, currentOrg) + if err != nil { + return err + } + + // An organization settled without asking is still reported: the questions + // that follow are answered inside it, and nothing else on the way through + // says which one it is. + if !org.prompted { + logger.Info().Str("organization", org.value).Msg("using your only organization") + } + + cfg.organization, cfg.saveOrganization = org.value, org.save + } + + projects, err := pinTo(cfg.organization) + if err != nil { + return err + } + + if !askProject { + return nil + } + + project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir) + if err != nil { + return err + } + + cfg.project, cfg.saveProject = project.value, project.save + + return nil +} + +// openTraceExecutor dials the control plane pinned to orgName, an empty name +// keeping the default connection, and verifies the credentials work. +func openTraceExecutor(ctx context.Context, orgName string) (*action.AttestationExecutor, error) { + executor, err := action.NewAttestationExecutor(ActionOpts, Version, action.WithForcedOrganization(orgName)) + if err != nil { + return nil, err + } + + if err := executor.CheckAuth(ctx); err != nil { + _ = executor.Close() + return nil, err + } + + return executor, nil +} diff --git a/app/cli/cmd/trace_init_steps_test.go b/app/cli/cmd/trace_init_steps_test.go new file mode 100644 index 000000000..6829b701c --- /dev/null +++ b/app/cli/cmd/trace_init_steps_test.go @@ -0,0 +1,186 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// identityStepsFixture wires resolveIdentityInteractively to fakes and records +// which organization the connection was repinned to. +type identityStepsFixture struct { + orgs *fakeOrgAPI + prompt *fakePrompter + + // defaultProjects is what a pinned connection lists, unless projectsByOrg + // names that organization. Giving each organization its own projects is what + // lets a test tell which connection the project step actually ran on. + defaultProjects []string + projectsByOrg map[string][]string + + pinnedTo string + pinCalls int + pinErr error +} + +func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { + f.pinCalls++ + f.pinnedTo = org + if f.pinErr != nil { + return nil, f.pinErr + } + + projects, ok := f.projectsByOrg[org] + if !ok { + projects = f.defaultProjects + } + + // A fresh lister per pin, so a stale one cannot answer for the new + // organization without the assertions noticing. + return &fakeProjectLister{projects: projects}, nil +} + +func newIdentityStepsFixture(orgs []*action.MembershipItem, projects []string, p *fakePrompter) *identityStepsFixture { + return &identityStepsFixture{ + orgs: &fakeOrgAPI{orgs: orgs}, + defaultProjects: projects, + prompt: p, + } +} + +func (f *identityStepsFixture) run(cfg *traceInitConfig) error { + return resolveIdentityInteractively(context.Background(), cfg, f.prompt, orgAcme, repoDirMyRepo, f.orgs, f.pinTo) +} + +// TestResolveIdentityInteractivelyFlags pins down that a value the user passed +// explicitly is never second-guessed: no question is asked for it, its value +// survives, and so does the save flag resolveTraceInitConfig set. +func TestResolveIdentityInteractivelyFlags(t *testing.T) { + logger = zerolog.Nop() + + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + projects := []string{projectAPI, "payments"} + + t.Run("--org skips the organization question and stays saved", func(t *testing.T) { + // saveOrganization is what resolveTraceInitConfig sets for an explicit --org. + cfg := &traceInitConfig{organization: "flag-org", saveOrganization: true, project: "yml-project"} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: projectAPI}) + + require.NoError(t, f.run(cfg)) + assert.Equal(t, "flag-org", cfg.organization) + assert.True(t, cfg.saveOrganization, "an explicit --org must still be written to .chainloop.yml") + assert.Equal(t, "flag-org", f.pinnedTo, "the connection must be pinned to the flag's organization") + // Only the project question ran. + require.Len(t, f.prompt.selects, 1) + assert.Contains(t, f.prompt.selects[0].title, "project") + }) + + t.Run("--project skips the project question and stays saved", func(t *testing.T) { + cfg := &traceInitConfig{project: "flag-project", saveProject: true} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: orgGlobex}) + + require.NoError(t, f.run(cfg)) + assert.Equal(t, "flag-project", cfg.project) + assert.True(t, cfg.saveProject, "an explicit --project must still be written to .chainloop.yml") + // Only the organization question ran. + require.Len(t, f.prompt.selects, 1) + assert.Contains(t, f.prompt.selects[0].title, "organization") + assert.Empty(t, f.prompt.inputs) + }) + + t.Run("both flags ask nothing at all", func(t *testing.T) { + cfg := &traceInitConfig{ + organization: "flag-org", saveOrganization: true, + project: "flag-project", saveProject: true, + } + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{}) + + require.NoError(t, f.run(cfg)) + assert.Empty(t, f.prompt.selects) + assert.Empty(t, f.prompt.inputs) + assert.Equal(t, "flag-org", cfg.organization) + assert.Equal(t, "flag-project", cfg.project) + assert.True(t, cfg.saveOrganization) + assert.True(t, cfg.saveProject) + }) + + t.Run("with no flags both questions are asked", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: orgGlobex}) + + require.NoError(t, f.run(cfg)) + // The organization picker, then the project picker. + require.Len(t, f.prompt.selects, 2) + assert.Contains(t, f.prompt.selects[0].title, "organization") + assert.Contains(t, f.prompt.selects[1].title, "project") + assert.Equal(t, orgGlobex, cfg.organization) + assert.True(t, cfg.saveOrganization) + }) +} + +func TestResolveIdentityInteractivelyPinning(t *testing.T) { + logger = zerolog.Nop() + + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + + t.Run("the project listing runs on the chosen organization", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, nil, &fakePrompter{selectAnswer: orgGlobex}) + // Each organization owns a distinct project, so the options the picker + // was given say which connection listed them. Without this the assertion + // would hold even if the project step used a stale connection. + f.projectsByOrg = map[string][]string{ + orgAcme: {"acme-only"}, + orgGlobex: {"globex-only"}, + } + + require.NoError(t, f.run(cfg)) + assert.Equal(t, orgGlobex, f.pinnedTo, "pinning must happen after the organization is picked") + assert.Equal(t, 1, f.pinCalls) + + require.Len(t, f.prompt.selects, 2) + assert.Contains(t, f.prompt.selects[1].options, "globex-only", "the project step must list the chosen organization") + assert.NotContains(t, f.prompt.selects[1].options, "acme-only", "it must not list the organization left behind") + }) + + t.Run("a pinning failure stops before the project question", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, []string{projectAPI}, &fakePrompter{selectAnswer: orgGlobex}) + f.pinErr = errors.New("dial failed") + + err := f.run(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "dial failed") + // The organization question ran, the project one did not. + assert.Len(t, f.prompt.selects, 1) + }) + + t.Run("an aborted question stops before pinning", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, nil, &fakePrompter{selectErr: errAborted}) + + require.ErrorIs(t, f.run(cfg), errAborted) + assert.Zero(t, f.pinCalls) + }) +} diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 1b0bb0f69..2b16b373f 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -16,11 +16,13 @@ package cmd import ( + "bytes" "os" "path/filepath" "slices" "testing" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,6 +41,17 @@ func traceInitTestCmd(t *testing.T, args ...string) *cobra.Command { return cmd } +// defaultTraceWorkflow is the workflow name trace init falls back to. +const defaultTraceWorkflow = "ai-coding-session" + +// The files trace init leaves in the repository: the config it writes and the +// harness hooks it installs. The closing message has to name them so they get +// committed. +const ( + chainloopYMLName = ".chainloop.yml" + claudeSettings = ".claude/settings.json" +) + func TestResolveTraceInitConfig(t *testing.T) { const existingYML = "projectName: yml-project\norganization: yml-org\nworkflowName: yml-workflow\nrequireTrace: true\n" @@ -69,7 +82,7 @@ func TestResolveTraceInitConfig(t *testing.T) { name: "the workflow name falls back to the trace default", yml: "projectName: yml-project\n", wantProject: "yml-project", - wantWorkflow: "ai-coding-session", + wantWorkflow: defaultTraceWorkflow, }, { name: "flags win and are marked for saving", @@ -92,16 +105,21 @@ func TestResolveTraceInitConfig(t *testing.T) { wantSaved: []string{"requireTrace"}, }, { - name: "a project name is required", - yml: "projectVersion: v1.0.0\n", - wantErr: "--project is required", + // Resolving no longer fails on a missing project: the interactive + // path asks for one, and the non-interactive path reports it. See + // TestResolveTraceIdentityNonInteractive. + name: "a missing project name is left for the identity step", + yml: "projectVersion: v1.0.0\n", + wantProject: "", + // The workflow still gets its default. + wantWorkflow: defaultTraceWorkflow, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".chainloop.yml") + path := filepath.Join(dir, chainloopYMLName) require.NoError(t, os.WriteFile(path, []byte(tc.yml), 0600)) cfg, err := resolveTraceInitConfig(traceInitTestCmd(t, tc.args...), dir, tc.projectFlag) @@ -139,7 +157,7 @@ func TestResolveTraceInitConfig(t *testing.T) { func TestTraceInitConfigSave(t *testing.T) { t.Run("only the values the user passed are written", func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".chainloop.yml") + path := filepath.Join(dir, chainloopYMLName) require.NoError(t, os.WriteFile(path, []byte("projectName: yml-project\n"), 0600)) cfg := &traceInitConfig{ @@ -156,3 +174,238 @@ func TestTraceInitConfigSave(t *testing.T) { assert.NotContains(t, string(data), "organization:") }) } + +// TestFileSnapshotRestore covers undoing what init wrote to a file. The +// case that matters is the second run over a repository already set up: +// installing over hooks that are present changes nothing, so undoing by +// uninstalling would strip a working configuration instead. +func TestFileSnapshotRestore(t *testing.T) { + t.Run("a configuration that was already there comes back untouched", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + original := []byte(`{"hooks":{"SessionStart":"chainloop"},"mine":"keep me"}`) + require.NoError(t, os.WriteFile(path, original, 0o600)) + + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, got, "the hooks that predate this run must survive it") + }) + + t.Run("a configuration this run created is removed", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".claude", "settings.json") + + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(path) + assert.True(t, os.IsNotExist(err), "a file this run created should not outlive it") + + _, err = os.Stat(filepath.Dir(path)) + assert.True(t, os.IsNotExist(err), "nor the directory that came with it") + }) + + // opencode keeps its hook a level deeper, so undoing a run has to take + // every directory it created rather than only the last one. + t.Run("a nested path loses every directory this run created", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".opencode", "plugins", "chainloop-trace.ts") + + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("export default {}"), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(filepath.Join(dir, ".opencode")) + assert.True(t, os.IsNotExist(err), "the outer directory came with the run too") + }) + + // An empty directory that was already there is not this run's to remove. + t.Run("a directory that predates the run stays, even empty", func(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, ".claude") + require.NoError(t, os.MkdirAll(existing, 0o755)) + + path := filepath.Join(existing, "settings.json") + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(existing) + assert.NoError(t, err, "the directory was not created by this run") + }) + + t.Run("a directory holding anything else is left alone", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + + before, err := snapshotFile(path) + require.NoError(t, err) + + theirs := filepath.Join(filepath.Dir(path), "theirs.json") + require.NoError(t, os.WriteFile(theirs, []byte("{}"), 0o600)) + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(theirs) + assert.NoError(t, err, "somebody else's file must survive") + }) +} + +// TestWriteTraceInitStateFailureLeavesNothing covers the step the harness hooks +// are rolled back for. Its parts run in order and each one can fail, so a +// caller that did not undo them would leave hooks calling a repository that was +// never set up. +func TestWriteTraceInitStateFailureLeavesNothing(t *testing.T) { + repoRoot := t.TempDir() + gitDir := filepath.Join(repoRoot, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o755)) + + // A file where the trace state goes, so the step fails after saving the + // configuration rather than before it: failing on the first thing it does + // would leave nothing behind whatever the caller did about it. + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "chainloop-trace"), []byte("x"), 0o600)) + + ymlPath := filepath.Join(repoRoot, chainloopYMLName) + before, err := snapshotFile(ymlPath) + require.NoError(t, err) + + cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow, saveProject: true} + require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir), + "a trace directory that cannot be created has to fail the step") + + // The step is not atomic on its own: it saved the configuration before it + // failed, which is exactly why the caller snapshots it. + _, err = os.Stat(ymlPath) + require.NoError(t, err, "the save this test relies on did happen") + + require.NoError(t, before.restore()) + + _, err = os.Stat(ymlPath) + assert.True(t, os.IsNotExist(err), "the configuration must not outlive a failed run") +} + +// TestWriteTraceNextSteps checks the closing message names the files that have +// to be committed. Everything init wrote lives in the repository, so leaving +// them uncommitted keeps the tracing on one working copy. +func TestWriteTraceNextSteps(t *testing.T) { + testCases := []struct { + name string + providers []string + // wantFiles are the paths the git add line must carry + wantFiles []string + // wantHarnesses is how the harnesses are named in the sentence + wantHarnesses string + }{ + { + name: "one harness", + providers: []string{providerClaudeCode}, + wantFiles: []string{chainloopYMLName, claudeSettings}, + wantHarnesses: providerClaudeCode, + }, + { + name: "two harnesses", + providers: []string{providerClaudeCode, "cursor"}, + wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json"}, + wantHarnesses: "claude-code or cursor", + }, + { + name: "every harness", + providers: []string{providerClaudeCode, "cursor", "opencode"}, + // Named in full: ".opencode" alone would pass on any file under that + // directory, so it would not catch the plugin being renamed. + wantFiles: []string{ + chainloopYMLName, claudeSettings, ".cursor/hooks.json", + ".opencode/plugins/chainloop-trace.ts", + }, + wantHarnesses: "claude-code, cursor or opencode", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := &bytes.Buffer{} + repoRoot := t.TempDir() + writeTraceNextSteps(out, repoRoot, repoRoot, providers.ByNames(tc.providers)) + + got := out.String() + for _, f := range tc.wantFiles { + assert.Contains(t, got, f, "the file has to be committed, so it has to be named") + } + + assert.Contains(t, got, tc.wantHarnesses) + assert.Contains(t, got, traceDocsURL) + }) + } +} + +// TestWriteTraceNextStepsFromSubdirectory pins down that the git add line works +// where it is printed. Its arguments are resolved against the working +// directory, so repository-root paths would stage nothing from a subdirectory. +func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { + repoRoot := t.TempDir() + workDir := filepath.Join(repoRoot, "services", "api") + require.NoError(t, os.MkdirAll(workDir, 0750)) + + out := &bytes.Buffer{} + writeTraceNextSteps(out, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) + + got := out.String() + assert.Contains(t, got, filepath.Join("..", "..", chainloopYMLName)) + assert.Contains(t, got, filepath.Join("..", "..", claudeSettings)) +} + +func TestWriteTraceInitSummary(t *testing.T) { + t.Run("every value is reported", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, "acme", "backend-api", defaultTraceWorkflow, + []string{providerClaudeCode, "cursor"}) + + got := out.String() + assert.Contains(t, got, "your repository is initialized") + assert.Contains(t, got, "acme") + assert.Contains(t, got, "backend-api") + assert.Contains(t, got, defaultTraceWorkflow) + assert.Contains(t, got, "claude-code, cursor") + }) + + t.Run("no organization anywhere leaves the line out rather than guessing", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, "", "backend-api", defaultTraceWorkflow, []string{providerClaudeCode}) + + assert.NotContains(t, out.String(), "organization") + }) +} + +func TestJoinWithOr(t *testing.T) { + testCases := []struct { + names []string + want string + }{ + {names: nil, want: ""}, + {names: []string{"a"}, want: "a"}, + {names: []string{"a", "b"}, want: "a or b"}, + {names: []string{"a", "b", "c"}, want: "a, b or c"}, + } + + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + assert.Equal(t, tc.want, joinWithOr(tc.names)) + }) + } +} diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 3659b717a..998351966 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -114,7 +114,7 @@ func newTraceRunCmd() *cobra.Command { return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{ Store: store, RepoRoot: repoRoot, - Providers: selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag), + Providers: traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}.namesOrDefault(), Command: args, ProjectName: projectFlag, Organization: organization, diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index bf1041a5f..18995514d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3306,10 +3306,21 @@ Synopsis Initialize git hooks that automatically trace AI coding sessions and create Chainloop attestations when you push. -It installs the managed git hooks plus the hooks of the selected agent -providers (Claude Code when none is given), and creates the Chainloop workflow -the attestations target. Nothing is written to the repository until that -workflow exists, so you need to be logged in. +It installs the managed git hooks plus the hooks of the selected harnesses +(Claude Code when none is given), and creates the Chainloop workflow the +attestations target. Nothing is written to the repository until that workflow +exists, so you need to be logged in. + +On a terminal it asks which organization and project to use, offering what +.chainloop.yml already holds so pressing Enter keeps it. A new project can be +named freely; the name is normalized to the lowercase, dash-separated form +Chainloop stores. It then asks which harnesses to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a harness flag +(--claude, --cursor, --opencode) skips the matching question. + +Nothing is asked in CI or when the output is redirected: there --project is +required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to +turn the questions off on a terminal too. The organization, project, workflow and require-trace values are saved to .chainloop.yml, and every push reads them from there. @@ -3321,8 +3332,8 @@ chainloop trace init [flags] Options ``` ---claude install Claude Code hooks (default when no provider flag is set) ---contract string workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) +--claude install Claude Code hooks (default when no harness flag is set) +--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init --opencode install opencode hooks @@ -3376,7 +3387,7 @@ Options ``` --claude install Claude Code hooks (default when no provider flag is set) ---contract string workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) +--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for run --opencode install opencode hooks diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go index b86a38775..fe86839ef 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -340,6 +340,14 @@ func findChainloopYMLAny(dir string) *ChainloopYML { return nil } +// ChainloopYMLName returns the base name of the Chainloop config file in dir: +// whichever of the two spellings is already there, or .chainloop.yml when +// neither is. Callers use it to name the file, for instance when asking the +// user to commit it. +func ChainloopYMLName(dir string) string { + return filepath.Base(resolveChainloopYMLPath(dir)) +} + // resolveChainloopYMLPath returns the path to the existing .chainloop.yml // (or .chainloop.yaml) in dir. If neither exists, defaults to .chainloop.yml. func resolveChainloopYMLPath(dir string) string { diff --git a/app/cli/internal/trace/config/slug.go b/app/cli/internal/trace/config/slug.go new file mode 100644 index 000000000..0c298b02e --- /dev/null +++ b/app/cli/internal/trace/config/slug.go @@ -0,0 +1,95 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +// SlugifyDNS1123 turns a free-form project name into a DNS-1123 label, the form +// the control plane accepts. It lowercases, replaces every run of characters +// that are not lowercase alphanumerics with a single dash, and trims the dashes +// off both ends. Names longer than the limit are cut, and cutting never leaves a +// trailing dash behind. +// +// It returns an empty string for input that has nothing usable in it, e.g. +// "!!!". Callers are expected to reject that rather than pass it on; +// ValidateDNS1123Label produces the message for it. +// +// materials.SanitizeMaterialName does the same to material names, and the two +// agree on everything but the length cap, which it leaves to its own caller. +// They are kept apart on purpose: importing it here would put the whole +// material crafter, some 377 packages, behind this one. +func SlugifyDNS1123(name string) string { + var b strings.Builder + b.Grow(len(name)) + + // Collapse separator runs as we go, so "a b" and "a-b" produce the same slug. + pendingSeparator := false + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if pendingSeparator && b.Len() > 0 { + b.WriteByte('-') + } + pendingSeparator = false + b.WriteRune(r) + continue + } + + pendingSeparator = true + } + + slug := b.String() + if len(slug) > validation.DNS1123LabelMaxLength { + slug = strings.TrimRight(slug[:validation.DNS1123LabelMaxLength], "-") + } + + return slug +} + +// Subject is the kind of thing ValidateDNS1123Label names in what it reports. +// Projects and organizations are named by the same rule, so the caller says +// which one it is asking about and the message names it. It is a type of its +// own so it cannot be swapped with the name being validated. +type Subject string + +const ( + ProjectSubject Subject = "project" + OrganizationSubject Subject = "organization" +) + +// ValidateDNS1123Label reports whether name is one the control plane will +// accept for a subject of that kind, using the same rule the server applies at +// creation time so a bad name is caught at the prompt rather than by a failing +// API call. +func ValidateDNS1123Label(subject Subject, name string) error { + if name == "" { + return fmt.Errorf("the %s name cannot be empty", subject) + } + + if len(name) > validation.DNS1123LabelMaxLength { + return fmt.Errorf("the %s name is %d characters long, the maximum is %d", subject, len(name), validation.DNS1123LabelMaxLength) + } + + if errs := validation.IsDNS1123Label(name); len(errs) > 0 { + return fmt.Errorf("the %s name must contain only lowercase letters, numbers and dashes, and start and end with a letter or a number", subject) + } + + return nil +} diff --git a/app/cli/internal/trace/config/slug_test.go b/app/cli/internal/trace/config/slug_test.go new file mode 100644 index 000000000..a5dbbb3cc --- /dev/null +++ b/app/cli/internal/trace/config/slug_test.go @@ -0,0 +1,116 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/validation" +) + +// Fixtures the table cases share. +const ( + validName = "my-project" + // errLowercase is the fragment the DNS-1123 message uses for a name whose + // characters or edges are wrong, as opposed to its length. + errLowercase = "lowercase" +) + +func TestSlugifyDNS1123(t *testing.T) { + testCases := []struct { + name string + input string + want string + }{ + {name: "empty input", input: "", want: ""}, + {name: "an already valid name is unchanged", input: validName, want: validName}, + {name: "capitals are lowercased", input: "MyProject", want: "myproject"}, + {name: "spaces become dashes", input: "My Cool Project", want: "my-cool-project"}, + {name: "runs of separators collapse into one dash", input: " --Foo_Bar!! ", want: "foo-bar"}, + {name: "punctuation is dropped", input: "acme.io/payments (v2)", want: "acme-io-payments-v2"}, + {name: "leading and trailing dashes are trimmed", input: "---edge---", want: "edge"}, + {name: "digits are kept, including as the first character", input: "3D Renderer", want: "3d-renderer"}, + {name: "underscores are separators", input: "snake_case_name", want: "snake-case-name"}, + {name: "non-ascii characters are dropped", input: "café münchen", want: "caf-m-nchen"}, + {name: "a name of only punctuation normalizes to empty", input: "!!!", want: ""}, + { + name: "an over-long name is cut to 63 characters", + input: strings.Repeat("a", 70), + want: strings.Repeat("a", 63), + }, + { + // Cutting at 63 must not leave a trailing dash, which would be invalid. + name: "cutting does not leave a trailing dash", + input: strings.Repeat("a", 63) + " tail", + want: strings.Repeat("a", 63), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := SlugifyDNS1123(tc.input) + assert.Equal(t, tc.want, got) + + // Whatever comes out must either be empty or be a name the server accepts. + if got != "" { + assert.Empty(t, validation.IsDNS1123Label(got), "slug %q is not a valid DNS-1123 label", got) + } + }) + } +} + +func TestSlugifyDNS1123IsIdempotent(t *testing.T) { + for _, input := range []string{"My Cool Project", " --Foo_Bar!! ", strings.Repeat("a", 70), "already-valid"} { + once := SlugifyDNS1123(input) + assert.Equal(t, once, SlugifyDNS1123(once), "slugifying %q twice changed the result", input) + } +} + +func TestValidateDNS1123Label(t *testing.T) { + testCases := []struct { + name string + input string + wantErr string + }{ + {name: "a valid name passes", input: validName}, + {name: "a single character is valid", input: "a"}, + {name: "63 characters is valid", input: strings.Repeat("a", 63)}, + {name: "empty is rejected", input: "", wantErr: "cannot be empty"}, + {name: "capitals are rejected", input: "MyProject", wantErr: errLowercase}, + {name: "spaces are rejected", input: "my project", wantErr: errLowercase}, + {name: "a leading dash is rejected", input: "-my-project", wantErr: errLowercase}, + {name: "a trailing dash is rejected", input: "my-project-", wantErr: errLowercase}, + {name: "64 characters is rejected", input: strings.Repeat("a", 64), wantErr: "63"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateDNS1123Label(ProjectSubject, tc.input) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + // The rule is the same for both; only the message differs. + assert.ErrorContains(t, ValidateDNS1123Label(OrganizationSubject, tc.input), "organization") + }) + } +} diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go new file mode 100644 index 000000000..65ba00de4 --- /dev/null +++ b/app/cli/pkg/action/trace_identity.go @@ -0,0 +1,156 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "cmp" + "context" + "fmt" + "slices" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" +) + +// traceProjectPageSize is how many projects are fetched per request. It is the +// maximum the control plane accepts, so most organizations need a single call. +const traceProjectPageSize int32 = 100 + +// ListOrganizations returns the organizations the current user belongs to, so +// `trace init` can offer them. It is a UserService call, which ignores the +// organization header, and so returns the same list on any connection. +func (e *AttestationExecutor) ListOrganizations(ctx context.Context) ([]*MembershipItem, error) { + return NewMembershipList(e.actionOpts).ListOrgs(ctx) +} + +// CreateOrganization creates one and makes it the caller's current +// organization, which is what the control plane does on creation. `trace init` +// calls it for a user who belongs to none, where there is nothing to select and +// every later step needs one. +func (e *AttestationExecutor) CreateOrganization(ctx context.Context, name string) error { + _, err := NewOrgCreate(e.actionOpts).Run(ctx, name) + + return err +} + +// ListProjects returns the projects visible in the organization the executor is +// pinned to, sorted by name and deduplicated. It runs over the executor's +// connection, so it targets the organization pinned with WithForcedOrganization +// like every other trace operation. +func (e *AttestationExecutor) ListProjects(ctx context.Context) (*TraceProjects, error) { + return listAllTraceProjects(ctx, &cpTraceProjectAPI{cfg: e.actionOpts}) +} + +// TraceProjects is what a project listing offers: the projects themselves and +// whether creating another one is possible at all. +type TraceProjects struct { + Projects []*TraceProject + // CanCreateProject is false when the caller's organization role does not + // allow creating projects, which an organization contributor's does not even + // where it administers every project it can see. + CanCreateProject bool +} + +// TraceProject is one entry of the project listing. +type TraceProject struct { + Name string + // CanCreateWorkflow is false for a project the caller can see but not add a + // workflow to, such as one they only view. Offering one of those would end + // in a permission error the moment init tried to create the workflow. + CanCreateWorkflow bool +} + +// traceProjectAPI is the slice of the control-plane API the project listing +// drives, so the paging can be tested without a live control plane. +type traceProjectAPI interface { + // listProjectsPage returns one page as the server sent it. + listProjectsPage(ctx context.Context, page, pageSize int32) (*traceProjectPage, error) +} + +// traceProjectPage is one page of the listing. +type traceProjectPage struct { + Projects []*TraceProject + // TotalPages is what the server reports, which is what ends the walk. + TotalPages int32 + // CanCreateProject is a property of the caller rather than of the page, so + // every page carries the same answer. + CanCreateProject bool +} + +// listAllTraceProjects walks every page and returns what the caller can see. +// Deciding which of those to offer is left to the caller, which needs to tell a +// project it may not write to apart from one that is missing entirely. +func listAllTraceProjects(ctx context.Context, api traceProjectAPI) (*TraceProjects, error) { + projects := make([]*TraceProject, 0, traceProjectPageSize) + // Every page repeats the same answer, so the last one read is as good as any. + var canCreateProject bool + + for page := int32(1); ; page++ { + got, err := api.listProjectsPage(ctx, page, traceProjectPageSize) + if err != nil { + return nil, fmt.Errorf("listing projects: %w", err) + } + + projects = append(projects, got.Projects...) + canCreateProject = got.CanCreateProject + + // Stop on the last page the server reports, and also on a page the server + // returned nothing for, so a server reporting more pages than it serves + // cannot spin here. + if len(got.Projects) == 0 || page >= got.TotalPages { + break + } + } + + slices.SortFunc(projects, func(a, b *TraceProject) int { + return cmp.Compare(a.Name, b.Name) + }) + + projects = slices.CompactFunc(projects, func(a, b *TraceProject) bool { + return a.Name == b.Name + }) + + return &TraceProjects{Projects: projects, CanCreateProject: canCreateProject}, nil +} + +// cpTraceProjectAPI implements traceProjectAPI against the control plane. +type cpTraceProjectAPI struct { + cfg *ActionsOpts +} + +func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) (*traceProjectPage, error) { + client := pb.NewProjectServiceClient(a.cfg.CPConnection) + + resp, err := client.List(ctx, &pb.ProjectServiceListRequest{ + Pagination: &pb.OffsetPaginationRequest{Page: page, PageSize: pageSize}, + }) + if err != nil { + return nil, err + } + + projects := make([]*TraceProject, 0, len(resp.GetProjects())) + for _, p := range resp.GetProjects() { + projects = append(projects, &TraceProject{ + Name: p.GetName(), + CanCreateWorkflow: p.GetCanCreateWorkflow(), + }) + } + + return &traceProjectPage{ + Projects: projects, + TotalPages: resp.GetPagination().GetTotalPages(), + CanCreateProject: resp.GetCanCreateProject(), + }, nil +} diff --git a/app/cli/pkg/action/trace_identity_test.go b/app/cli/pkg/action/trace_identity_test.go new file mode 100644 index 000000000..3779994a4 --- /dev/null +++ b/app/cli/pkg/action/trace_identity_test.go @@ -0,0 +1,165 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writable builds a page of projects the caller can add a workflow to. +func writable(names ...string) []*TraceProject { + page := make([]*TraceProject, 0, len(names)) + for _, n := range names { + page = append(page, &TraceProject{Name: n, CanCreateWorkflow: true}) + } + + return page +} + +// readOnly builds a page of projects the caller can see but not write to. +func readOnly(names ...string) []*TraceProject { + page := make([]*TraceProject, 0, len(names)) + for _, n := range names { + page = append(page, &TraceProject{Name: n}) + } + + return page +} + +// fakeProjectPager serves canned pages, recording what it was asked for. +type fakeProjectPager struct { + pages [][]*TraceProject + // totalPages is what the server reports; it defaults to len(pages) + totalPages int32 + canCreateProject bool + err error + + requested []int32 +} + +func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) (*traceProjectPage, error) { + f.requested = append(f.requested, page) + if f.err != nil { + return nil, f.err + } + + total := f.totalPages + if total == 0 { + total = int32(len(f.pages)) + } + + out := &traceProjectPage{TotalPages: total, CanCreateProject: f.canCreateProject} + + idx := int(page) - 1 + if idx >= 0 && idx < len(f.pages) { + out.Projects = f.pages[idx] + } + + return out, nil +} + +// Project names the table cases share. +const ( + projAlpha = "alpha" + projBeta = "beta" + projGamma = "gamma" +) + +func TestListAllTraceProjects(t *testing.T) { + testCases := []struct { + name string + pages [][]*TraceProject + totalPages int32 + want []*TraceProject + wantRequested []int32 + }{ + { + name: "no projects", + pages: nil, + want: []*TraceProject{}, + wantRequested: []int32{1}, + }, + { + name: "a single page is returned as is", + pages: [][]*TraceProject{writable(projAlpha, projBeta)}, + want: writable(projAlpha, projBeta), + wantRequested: []int32{1}, + }, + { + name: "every page is fetched and concatenated", + pages: [][]*TraceProject{writable(projAlpha, projBeta), writable(projGamma)}, + want: writable(projAlpha, projBeta, projGamma), + wantRequested: []int32{1, 2}, + }, + { + name: "a project repeated across pages appears once", + pages: [][]*TraceProject{writable(projAlpha, projBeta), writable(projBeta, projGamma)}, + want: writable(projAlpha, projBeta, projGamma), + wantRequested: []int32{1, 2}, + }, + { + name: "an empty page stops the walk even when the server claims more", + // A server that reports more pages than it serves must not spin here. + pages: [][]*TraceProject{writable(projAlpha), {}}, + totalPages: 50, + want: writable(projAlpha), + wantRequested: []int32{1, 2}, + }, + { + // A project the caller cannot write to is reported, not dropped. The + // caller needs it to tell a read-only project apart from a missing + // one, which are offered differently. + name: "a project the caller cannot write to is still reported", + pages: [][]*TraceProject{append(writable(projAlpha), readOnly(projBeta)...)}, + want: append(writable(projAlpha), readOnly(projBeta)...), + wantRequested: []int32{1}, + }, + { + name: "a page of read-only projects does not stop the walk", + pages: [][]*TraceProject{readOnly(projBeta), writable(projGamma)}, + want: append(readOnly(projBeta), writable(projGamma)...), + wantRequested: []int32{1, 2}, + }, + { + name: "projects are sorted by name across pages", + pages: [][]*TraceProject{writable(projGamma), writable(projAlpha)}, + want: writable(projAlpha, projGamma), + wantRequested: []int32{1, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pager := &fakeProjectPager{pages: tc.pages, totalPages: tc.totalPages, canCreateProject: true} + got, err := listAllTraceProjects(context.Background(), pager) + require.NoError(t, err) + assert.Equal(t, tc.want, got.Projects) + assert.Equal(t, tc.wantRequested, pager.requested) + assert.True(t, got.CanCreateProject, "what the server said about creating must survive the walk") + }) + } +} + +func TestListAllTraceProjectsError(t *testing.T) { + _, err := listAllTraceProjects(context.Background(), &fakeProjectPager{err: errors.New("boom")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/app/cli/pkg/action/trace_workflow.go b/app/cli/pkg/action/trace_workflow.go index d24a169ab..f044930ea 100644 --- a/app/cli/pkg/action/trace_workflow.go +++ b/app/cli/pkg/action/trace_workflow.go @@ -56,9 +56,10 @@ type traceWorkflowAPI interface { // viewWorkflow returns the workflow, or a NotFound error when the workflow // or its project does not exist. viewWorkflow(ctx context.Context, project, workflow string) (*WorkflowItem, error) - // contractExists reports whether a contract with that name exists in the - // organization. A lookup failure is returned as an error, not as false. - contractExists(ctx context.Context, name string) (bool, error) + // findContract returns the contract with that name, or nil when the + // organization does not have one. A lookup failure is returned as an error, + // not as a missing contract. + findContract(ctx context.Context, name string) (*WorkflowContractItem, error) // createWorkflow creates the workflow, attached to contract when non-empty. createWorkflow(ctx context.Context, project, workflow, contract string) (*WorkflowItem, error) } @@ -111,32 +112,52 @@ func ensureTraceWorkflow(ctx context.Context, api traceWorkflowAPI, log zerolog. } // resolveTraceContract returns the contract name to attach on creation: the -// requested one when the organization has it, or an empty string to let the -// control plane create its per-project default. A contract that cannot be found -// is only an error when the caller marked it required, because the shipped -// default is absent in organizations that never imported it. +// requested one when the organization has it and the project may use it, or an +// empty string to let the control plane create its per-project default. A +// contract that cannot be used is only an error when the caller marked it +// required, because the shipped default is absent in organizations that never +// imported it, and a contract of the user's own may happen to carry its name. func resolveTraceContract(ctx context.Context, api traceWorkflowAPI, log zerolog.Logger, opts EnsureTraceWorkflowOpts) (string, error) { if opts.ContractName == "" { return "", nil } - exists, err := api.contractExists(ctx, opts.ContractName) + contract, err := api.findContract(ctx, opts.ContractName) switch { case err != nil && opts.ContractRequired: return "", fmt.Errorf("looking up contract %q: %w", opts.ContractName, err) case err != nil: log.Debug().Err(err).Str("contract", opts.ContractName).Msg("could not look up the contract; falling back to the default one") return "", nil - case !exists && opts.ContractRequired: + case contract == nil && opts.ContractRequired: return "", fmt.Errorf("contract %q does not exist in this organization", opts.ContractName) - case !exists: + case contract == nil: log.Debug().Str("contract", opts.ContractName).Msg("contract not found; falling back to the default one") return "", nil } + // A contract scoped to a project belongs to that project alone: the control + // plane refuses to attach it anywhere else, comparing project names. The + // name is all we have to go on here, and it is the same thing it compares. + if scope := contract.ScopedEntity; scope != nil && scope.Type == contractScopeProject && scope.Name != opts.ProjectName { + if opts.ContractRequired { + return "", fmt.Errorf("contract %q is scoped to project %q, so it cannot be used in project %q", + opts.ContractName, scope.Name, opts.ProjectName) + } + + log.Debug().Str("contract", opts.ContractName).Str("scoped_to", scope.Name). + Msg("contract belongs to another project; falling back to the default one") + + return "", nil + } + return opts.ContractName, nil } +// contractScopeProject is the scope a contract carries when it belongs to a +// single project rather than to the whole organization, which has none. +const contractScopeProject = "project" + // cpTraceWorkflowAPI implements traceWorkflowAPI against the control plane, // reusing the actions the equivalent commands run. type cpTraceWorkflowAPI struct { @@ -147,16 +168,17 @@ func (a *cpTraceWorkflowAPI) viewWorkflow(ctx context.Context, project, workflow return NewWorkflowDescribe(a.cfg).Run(ctx, workflow, project) } -func (a *cpTraceWorkflowAPI) contractExists(ctx context.Context, name string) (bool, error) { - if _, err := NewWorkflowContractDescribe(a.cfg).Run(ctx, name, 0); err != nil { +func (a *cpTraceWorkflowAPI) findContract(ctx context.Context, name string) (*WorkflowContractItem, error) { + described, err := NewWorkflowContractDescribe(a.cfg).Run(ctx, name, 0) + if err != nil { if status.Code(err) == codes.NotFound { - return false, nil + return nil, nil } - return false, err + return nil, err } - return true, nil + return described.Contract, nil } func (a *cpTraceWorkflowAPI) createWorkflow(ctx context.Context, project, workflow, contract string) (*WorkflowItem, error) { diff --git a/app/cli/pkg/action/trace_workflow_test.go b/app/cli/pkg/action/trace_workflow_test.go index c4ce41da9..ec748655c 100644 --- a/app/cli/pkg/action/trace_workflow_test.go +++ b/app/cli/pkg/action/trace_workflow_test.go @@ -34,8 +34,10 @@ type fakeTraceWorkflowAPI struct { viewResult *WorkflowItem viewErr error - contractFound bool - contractErr error + // contract is what the lookup finds, nil when the organization does not + // have one by that name. + contract *WorkflowContractItem + contractErr error createErr error @@ -48,9 +50,23 @@ func (f *fakeTraceWorkflowAPI) viewWorkflow(_ context.Context, _, _ string) (*Wo return f.viewResult, f.viewErr } -func (f *fakeTraceWorkflowAPI) contractExists(_ context.Context, _ string) (bool, error) { +func (f *fakeTraceWorkflowAPI) findContract(_ context.Context, _ string) (*WorkflowContractItem, error) { f.contractCalls++ - return f.contractFound, f.contractErr + return f.contract, f.contractErr +} + +// globalContract is a contract the whole organization can attach. +func globalContract(name string) *WorkflowContractItem { + return &WorkflowContractItem{Name: name} +} + +// projectContract is a contract scoped to a single project, which the control +// plane refuses to attach to a workflow in any other one. +func projectContract(name, project string) *WorkflowContractItem { + return &WorkflowContractItem{ + Name: name, + ScopedEntity: &ScopedEntity{Type: "project", Name: project}, + } } // defaultContract stands in for the empty contract the control plane creates @@ -106,7 +122,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "missing workflow with an existing contract is created attached to it", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract)}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantCreated: true, wantContract: contract, @@ -132,6 +148,39 @@ func TestEnsureTraceWorkflow(t *testing.T) { wantContractCall: true, wantCreateCall: true, }, + { + // The shipped contract is picked by name, so an unrelated contract of + // the user's own that happens to carry it, scoped to another project, + // must not be attached: the control plane refuses it outright. + name: "a contract scoped to another project is not used", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, "another-project")}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, + wantCreated: true, + wantContract: defaultContract, + wantContractCall: true, + wantCreateCall: true, + }, + { + // Scoped to the project being created in, it is the contract that + // project is meant to use. + name: "a contract scoped to this project is used", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, project)}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, + wantCreated: true, + wantContract: contract, + wantContractCall: true, + wantCreateCall: true, + wantCreatedWith: contract, + }, + { + // Asked for by name, so it is reported rather than silently swapped + // for the default, and before the control plane refuses it. + name: "a required contract scoped to another project fails", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, "another-project")}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract, ContractRequired: true}, + wantErr: `is scoped to project "another-project"`, + wantContractCall: true, + }, { name: "a required contract that does not exist fails", api: &fakeTraceWorkflowAPI{viewErr: notFound}, @@ -156,7 +205,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "a workflow created concurrently is not an error", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true, createErr: status.Error(codes.AlreadyExists, "already exists")}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract), createErr: status.Error(codes.AlreadyExists, "already exists")}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantContractCall: true, wantCreateCall: true, @@ -176,7 +225,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "a create failure fails", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true, createErr: denied}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract), createErr: denied}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantErr: forbidden, wantContractCall: true, @@ -236,7 +285,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { for name, api := range map[string]*fakeTraceWorkflowAPI{ "on view": {viewErr: authErr}, "on contract": {viewErr: notFound, contractErr: authErr}, - "on create": {viewErr: notFound, contractFound: true, createErr: authErr}, + "on create": {viewErr: notFound, contract: globalContract(contract), createErr: authErr}, } { t.Run(name, func(t *testing.T) { _, err := ensureTraceWorkflow(context.Background(), api, zerolog.Nop(), diff --git a/app/cli/pkg/prompt/prompt.go b/app/cli/pkg/prompt/prompt.go new file mode 100644 index 000000000..c4e08bf5a --- /dev/null +++ b/app/cli/pkg/prompt/prompt.go @@ -0,0 +1,234 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package prompt asks a user to pick from a list or type a value, on the +// terminal or in plain text where there is no terminal to draw on. It knows +// nothing about what is being asked for: callers pass the question, the answers +// and whatever validation belongs to their own domain. +package prompt + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + + "charm.land/huh/v2" +) + +// ErrAborted is returned when the user dismisses a prompt. Dismissing a +// question is a decision rather than a failure, so callers usually stop +// cleanly on it instead of reporting an error. +var ErrAborted = errors.New("aborted") + +// accessibleEnvVars turn on plain, numbered prompts instead of the full +// terminal UI, for screen readers and terminals that cannot render one. +// ACCESSIBLE is the convention the Charm tools use. +var accessibleEnvVars = []string{"CHAINLOOP_ACCESSIBLE", "ACCESSIBLE"} + +// Prompter asks the questions. It is deliberately thin: it owns the terminal +// and nothing else, so the code deciding what to ask can be tested against a +// scripted fake rather than against a terminal. +type Prompter struct { + accessible bool + // in and out are the streams the prompt uses. Prompts are drawn on stderr so + // stdout stays clean for anything piping the command's output. + in io.Reader + out io.Writer +} + +// New builds a prompter over the real terminal, in plain-text mode when the +// environment asks for it. +func New(lookupEnv func(string) (string, bool)) *Prompter { + accessible := slices.ContainsFunc(accessibleEnvVars, func(key string) bool { + return envEnabled(lookupEnv, key) + }) + + return &Prompter{accessible: accessible, in: os.Stdin, out: os.Stderr} +} + +// NewForStreams builds a prompter over the given streams, in plain-text mode. +// It is how a test drives the real implementation without a terminal. +func NewForStreams(in io.Reader, out io.Writer) *Prompter { + return &Prompter{accessible: true, in: in, out: out} +} + +// Accessible reports whether prompts are drawn as plain numbered text. +func (p *Prompter) Accessible() bool { + return p.accessible +} + +// envEnabled reports whether an environment variable is set to something that +// means "yes". Present but empty, "0" or "false" is treated as unset. +func envEnabled(lookupEnv func(string) (string, bool), key string) bool { + v, ok := lookupEnv(key) + return ok && v != "" && v != "0" && v != "false" +} + +// framed folds the description into the title when huh will not draw it. +// Accessible mode renders the title and the options and nothing else, so a +// description left in its own slot is simply lost there — and it carries the +// context the question needs, which is worth more than the layout. +func (p *Prompter) framed(title, description string) (string, string) { + if description == "" || !p.accessible { + return title, description + } + + return fmt.Sprintf("%s (%s)", title, description), "" +} + +// Select asks the user to pick one of options, starting on defaultValue. Long +// lists can be narrowed by typing. description is drawn under the title, inside +// the same frame, and validate — when set — refuses an answer and keeps the +// prompt open rather than returning it. +func (p *Prompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { + value := defaultValue + title, description = p.framed(title, description) + + // Filtering() is deliberately not set: it does not enable filtering, it puts + // the field straight into filter-input mode, which replaces the title with an + // empty "/" box and sends the arrow keys to the filter. Pressing "/" opens it + // on demand, which is what the help line offers. + field := huh.NewSelect[string](). + Title(title). + Description(description). + Options(huh.NewOptions(options...)...). + Value(&value) + + // Set only when there is one: huh calls whatever it is handed, so a nil + // validator panics rather than reading as "nothing to check". + if validate != nil { + field = field.Validate(validate) + } + + if err := p.run(field); err != nil { + return "", err + } + + return value, nil +} + +// MultiSelect asks the user to tick any number of options, with defaults +// already ticked. An empty submission is refused at the prompt, so the caller +// never has to send the user back to fix it. +func (p *Prompter) MultiSelect(title string, options, defaults []string) ([]string, error) { + value := slices.Clone(defaults) + + if err := p.run(NewMultiSelectField(title, options, &value)); err != nil { + return nil, err + } + + return value, nil +} + +// MultiSelectMaxVisible caps how many options are on screen at once, so a long +// list scrolls instead of pushing the prompt off the top. It is the height huh +// itself falls back to for dynamic options. +const MultiSelectMaxVisible = 10 + +// NewMultiSelectField builds the multi-select. It is separate from MultiSelect +// so a test can render it without a terminal to run a form against. +func NewMultiSelectField(title string, options []string, value *[]string) *huh.MultiSelect[string] { + return huh.NewMultiSelect[string](). + Title(title). + Options(huh.NewOptions(options...)...). + // The height has to be set. Left unset, huh sizes the viewport from the + // options and then subtracts the title's line from that same number, so + // the last option is silently cut off. Asking for one line more than the + // options occupy cancels the subtraction out. Select does not share this + // quirk, which is why only this field sets a height. + Height(min(len(options), MultiSelectMaxVisible) + 1). + Value(value). + Validate(func(selected []string) error { + if len(selected) == 0 { + return errors.New("pick at least one, with space") + } + + return nil + }) +} + +// InputOpts is a question asking for a typed answer. +type InputOpts struct { + Title string + // Description is the line under the title, for context the question needs. + Description string + // Default prefills the field. + Default string + // Validate refuses an answer and keeps the prompt open. + Validate func(string) error + // Describe replaces Description as the answer is typed, for showing what an + // answer will become before it is submitted. It is called with what has been + // typed so far and returns the line to draw, which is usually Description + // itself while there is nothing to add. Accessible mode draws no description + // at all, so what it returns is not shown there. + Describe func(value string) string +} + +// Input asks the user to type a value. +func (p *Prompter) Input(opts InputOpts) (string, error) { + value := opts.Default + opts.Title, opts.Description = p.framed(opts.Title, opts.Description) + + if err := p.run(NewInputField(opts, &value)); err != nil { + return "", err + } + + return value, nil +} + +// NewInputField builds the input. It is separate from Input so a test can +// render it without a terminal to run a form against, the same way +// NewMultiSelectField is. +func NewInputField(opts InputOpts, value *string) *huh.Input { + field := huh.NewInput(). + Title(opts.Title). + Description(opts.Description). + Value(value) + + // Set only when there is one: huh calls whatever it is handed, so a nil + // validator panics rather than reading as "nothing to check". Select has + // the same trap. + if opts.Validate != nil { + field = field.Validate(opts.Validate) + } + + if opts.Describe == nil { + return field + } + + // Both the static description and the function: DescriptionFunc is not + // evaluated for the first draw, only once its binding changes, so on its own + // it leaves an empty line where the context should be until the user types + // something. + return field.DescriptionFunc(func() string { return opts.Describe(*value) }, value) +} + +// run shows a single-field form and translates a dismissed prompt into +// ErrAborted. +func (p *Prompter) run(field huh.Field) error { + form := huh.NewForm(huh.NewGroup(field)). + WithAccessible(p.accessible). + WithInput(p.in). + WithOutput(p.out) + + err := form.Run() + if errors.Is(err, huh.ErrUserAborted) { + return ErrAborted + } + + return err +} diff --git a/app/cli/pkg/prompt/prompt_test.go b/app/cli/pkg/prompt/prompt_test.go new file mode 100644 index 000000000..fb8408bdc --- /dev/null +++ b/app/cli/pkg/prompt/prompt_test.go @@ -0,0 +1,175 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prompt + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + + "charm.land/huh/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// accessiblePrompter drives the real implementation in accessible mode, where +// prompts are plain text over the given streams. It is the only way to exercise +// the terminal code without a terminal. +func accessiblePrompter(input string) (*Prompter, *bytes.Buffer) { + out := &bytes.Buffer{} + + return NewForStreams(strings.NewReader(input), out), out +} + +// The options the Select tests pick between, in the order they are offered. +const ( + optionAlpha = "alpha" + optionBeta = "beta" + optionGamma = "gamma" +) + +func TestSelect(t *testing.T) { + // Accessible mode numbers the options and reads the choice as a line. + p, out := accessiblePrompter("2\n") + + got, err := p.Select("Pick one", "in some context", []string{optionAlpha, optionBeta, optionGamma}, optionAlpha, nil) + require.NoError(t, err) + assert.Equal(t, optionBeta, got) + assert.Contains(t, out.String(), "Pick one") +} + +// A validator lets a picker show an option it will not accept, which is how an +// entry can stay visible without being usable. +func TestSelectValidate(t *testing.T) { + // The refused option first, then an acceptable one: accessible mode asks + // again rather than giving up. + p, out := accessiblePrompter("2\n1\n") + + const refusal = "beta is not available" + + got, err := p.Select("Pick one", "", []string{optionAlpha, optionBeta}, optionAlpha, func(chosen string) error { + if chosen == optionBeta { + return errors.New(refusal) + } + + return nil + }) + require.NoError(t, err) + assert.Equal(t, optionAlpha, got) + assert.Contains(t, out.String(), refusal) +} + +func TestInput(t *testing.T) { + p, out := accessiblePrompter("typed-value\n") + + // No Validate and no Describe on purpose: huh panics on a nil validator, so + // a question that checks nothing has to reach the prompt all the same. + got, err := p.Input(InputOpts{Title: "A name", Description: "in some context", Default: "a-default"}) + require.NoError(t, err) + assert.Equal(t, "typed-value", got) + assert.Contains(t, out.String(), "A name") +} + +func TestMultiSelect(t *testing.T) { + // Accessible mode toggles an option by its number and finishes on 0, so + // this ticks the second option on top of the preselected first one. + p, out := accessiblePrompter("2\n0\n") + + const first, second = "first", "second" + + got, err := p.MultiSelect("Pick options", []string{first, second, "third"}, []string{first}) + require.NoError(t, err) + assert.Equal(t, []string{first, second}, got) + assert.Contains(t, out.String(), "Pick options") +} + +func TestNewAccessibility(t *testing.T) { + testCases := []struct { + name string + env map[string]string + want bool + }{ + {name: "off by default", want: false}, + {name: "the Chainloop variable turns it on", env: map[string]string{"CHAINLOOP_ACCESSIBLE": "1"}, want: true}, + {name: "the Charm convention turns it on", env: map[string]string{"ACCESSIBLE": "true"}, want: true}, + {name: "set to 0 leaves it off", env: map[string]string{"ACCESSIBLE": "0"}, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := New(func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + }) + assert.Equal(t, tc.want, p.Accessible()) + }) + } +} + +// Accessible mode draws the title and the options and nothing else, so context +// left in the description slot would be lost to a screen reader. +func TestAccessibleFoldsTheDescriptionIn(t *testing.T) { + p, out := accessiblePrompter("1\n") + + _, err := p.Select("Pick one", "some context", []string{optionAlpha}, optionAlpha, nil) + require.NoError(t, err) + assert.Contains(t, out.String(), "Pick one (some context)") +} + +// TestMultiSelectFieldShowsEveryOption renders the real field and looks for +// every option in the output. Left to size itself, huh's multi-select subtracts +// the title's line from the height it derived from the options, so the last one +// never appears and the user cannot tell there is more to tick. +func TestMultiSelectFieldShowsEveryOption(t *testing.T) { + for _, count := range []int{1, 2, 3, 5, MultiSelectMaxVisible} { + t.Run(fmt.Sprintf("%d options", count), func(t *testing.T) { + options := make([]string, 0, count) + for i := range count { + options = append(options, fmt.Sprintf("option-%d", i)) + } + + var value []string + form := huh.NewForm(huh.NewGroup(NewMultiSelectField("Pick some", options, &value))) + // Init sizes the field, which is what this is about. Its command + // starts the cursor blinking and is nothing to run here. + _ = form.Init() + + view := form.View() + for _, o := range options { + assert.Contains(t, view, o, "every option must be on screen") + } + }) + } +} + +// An input's description has to be there on the first draw, before anything is +// typed: huh does not evaluate DescriptionFunc until its binding changes, so a +// field relying on that alone opens with an empty line where the context goes. +func TestInputFieldShowsItsDescriptionBeforeTyping(t *testing.T) { + const context = "some context" + + value := "" + form := huh.NewForm(huh.NewGroup(NewInputField(InputOpts{ + Title: "A name", + Description: context, + Describe: func(string) string { return "only once something is typed" }, + }, &value))) + _ = form.Init() + + assert.Contains(t, form.View(), context, "the context must be on screen before anything is typed") +} diff --git a/app/controlplane/api/controlplane/v1/project.pb.go b/app/controlplane/api/controlplane/v1/project.pb.go index 9a5f57de5..0279bd367 100644 --- a/app/controlplane/api/controlplane/v1/project.pb.go +++ b/app/controlplane/api/controlplane/v1/project.pb.go @@ -38,6 +38,129 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization +type ProjectServiceListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Filter by project name, case-insensitive substring match + Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"` + // Pagination parameters to limit and offset results + Pagination *OffsetPaginationRequest `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListRequest) Reset() { + *x = ProjectServiceListRequest{} + mi := &file_controlplane_v1_project_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListRequest) ProtoMessage() {} + +func (x *ProjectServiceListRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListRequest.ProtoReflect.Descriptor instead. +func (*ProjectServiceListRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{0} +} + +func (x *ProjectServiceListRequest) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *ProjectServiceListRequest) GetPagination() *OffsetPaginationRequest { + if x != nil { + return x.Pagination + } + return nil +} + +// ProjectServiceListResponse contains a paginated list of the organization's projects +type ProjectServiceListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of projects matching the request criteria + Projects []*ProjectServiceListResponse_ProjectItem `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"` + // Pagination information for the response + Pagination *OffsetPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + // Whether the caller may create a project in this organization. It is a + // property of their organization role, not of any project, so it does not + // belong on the items: an organization contributor, for instance, can be an + // administrator of several projects and still create none. Clients offering + // to create one need this to avoid an option that always fails. + CanCreateProject bool `protobuf:"varint,3,opt,name=can_create_project,json=canCreateProject,proto3" json:"can_create_project,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListResponse) Reset() { + *x = ProjectServiceListResponse{} + mi := &file_controlplane_v1_project_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListResponse) ProtoMessage() {} + +func (x *ProjectServiceListResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListResponse.ProtoReflect.Descriptor instead. +func (*ProjectServiceListResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1} +} + +func (x *ProjectServiceListResponse) GetProjects() []*ProjectServiceListResponse_ProjectItem { + if x != nil { + return x.Projects + } + return nil +} + +func (x *ProjectServiceListResponse) GetPagination() *OffsetPaginationResponse { + if x != nil { + return x.Pagination + } + return nil +} + +func (x *ProjectServiceListResponse) GetCanCreateProject() bool { + if x != nil { + return x.CanCreateProject + } + return false +} + // ProjectServiceListMembersRequest contains the information needed to list members of a project type ProjectServiceListMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -51,7 +174,7 @@ type ProjectServiceListMembersRequest struct { func (x *ProjectServiceListMembersRequest) Reset() { *x = ProjectServiceListMembersRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[0] + mi := &file_controlplane_v1_project_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -63,7 +186,7 @@ func (x *ProjectServiceListMembersRequest) String() string { func (*ProjectServiceListMembersRequest) ProtoMessage() {} func (x *ProjectServiceListMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[0] + mi := &file_controlplane_v1_project_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -76,7 +199,7 @@ func (x *ProjectServiceListMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceListMembersRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceListMembersRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{0} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{2} } func (x *ProjectServiceListMembersRequest) GetProjectReference() *IdentityReference { @@ -106,7 +229,7 @@ type ProjectServiceListMembersResponse struct { func (x *ProjectServiceListMembersResponse) Reset() { *x = ProjectServiceListMembersResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[1] + mi := &file_controlplane_v1_project_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -118,7 +241,7 @@ func (x *ProjectServiceListMembersResponse) String() string { func (*ProjectServiceListMembersResponse) ProtoMessage() {} func (x *ProjectServiceListMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[1] + mi := &file_controlplane_v1_project_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -131,7 +254,7 @@ func (x *ProjectServiceListMembersResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceListMembersResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceListMembersResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{3} } func (x *ProjectServiceListMembersResponse) GetMembers() []*ProjectMember { @@ -174,7 +297,7 @@ type ProjectMember struct { func (x *ProjectMember) Reset() { *x = ProjectMember{} - mi := &file_controlplane_v1_project_proto_msgTypes[2] + mi := &file_controlplane_v1_project_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -186,7 +309,7 @@ func (x *ProjectMember) String() string { func (*ProjectMember) ProtoMessage() {} func (x *ProjectMember) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[2] + mi := &file_controlplane_v1_project_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -199,7 +322,7 @@ func (x *ProjectMember) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectMember.ProtoReflect.Descriptor instead. func (*ProjectMember) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{2} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{4} } func (x *ProjectMember) GetSubject() isProjectMember_Subject { @@ -295,7 +418,7 @@ type ProjectServiceAddMemberRequest struct { func (x *ProjectServiceAddMemberRequest) Reset() { *x = ProjectServiceAddMemberRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[3] + mi := &file_controlplane_v1_project_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -307,7 +430,7 @@ func (x *ProjectServiceAddMemberRequest) String() string { func (*ProjectServiceAddMemberRequest) ProtoMessage() {} func (x *ProjectServiceAddMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[3] + mi := &file_controlplane_v1_project_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -320,7 +443,7 @@ func (x *ProjectServiceAddMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceAddMemberRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceAddMemberRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{3} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{5} } func (x *ProjectServiceAddMemberRequest) GetProjectReference() *IdentityReference { @@ -353,7 +476,7 @@ type ProjectServiceAddMemberResponse struct { func (x *ProjectServiceAddMemberResponse) Reset() { *x = ProjectServiceAddMemberResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[4] + mi := &file_controlplane_v1_project_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -365,7 +488,7 @@ func (x *ProjectServiceAddMemberResponse) String() string { func (*ProjectServiceAddMemberResponse) ProtoMessage() {} func (x *ProjectServiceAddMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[4] + mi := &file_controlplane_v1_project_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -378,7 +501,7 @@ func (x *ProjectServiceAddMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceAddMemberResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceAddMemberResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{4} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{6} } type ProjectServiceRemoveMemberRequest struct { @@ -393,7 +516,7 @@ type ProjectServiceRemoveMemberRequest struct { func (x *ProjectServiceRemoveMemberRequest) Reset() { *x = ProjectServiceRemoveMemberRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[5] + mi := &file_controlplane_v1_project_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -405,7 +528,7 @@ func (x *ProjectServiceRemoveMemberRequest) String() string { func (*ProjectServiceRemoveMemberRequest) ProtoMessage() {} func (x *ProjectServiceRemoveMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[5] + mi := &file_controlplane_v1_project_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -418,7 +541,7 @@ func (x *ProjectServiceRemoveMemberRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceRemoveMemberRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceRemoveMemberRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{5} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{7} } func (x *ProjectServiceRemoveMemberRequest) GetProjectReference() *IdentityReference { @@ -444,7 +567,7 @@ type ProjectServiceRemoveMemberResponse struct { func (x *ProjectServiceRemoveMemberResponse) Reset() { *x = ProjectServiceRemoveMemberResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[6] + mi := &file_controlplane_v1_project_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -456,7 +579,7 @@ func (x *ProjectServiceRemoveMemberResponse) String() string { func (*ProjectServiceRemoveMemberResponse) ProtoMessage() {} func (x *ProjectServiceRemoveMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[6] + mi := &file_controlplane_v1_project_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -469,7 +592,7 @@ func (x *ProjectServiceRemoveMemberResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceRemoveMemberResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceRemoveMemberResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{6} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{8} } // ProjectMembershipReference is used to reference a user or group in the context of project membership @@ -488,7 +611,7 @@ type ProjectMembershipReference struct { func (x *ProjectMembershipReference) Reset() { *x = ProjectMembershipReference{} - mi := &file_controlplane_v1_project_proto_msgTypes[7] + mi := &file_controlplane_v1_project_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -500,7 +623,7 @@ func (x *ProjectMembershipReference) String() string { func (*ProjectMembershipReference) ProtoMessage() {} func (x *ProjectMembershipReference) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[7] + mi := &file_controlplane_v1_project_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -513,7 +636,7 @@ func (x *ProjectMembershipReference) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectMembershipReference.ProtoReflect.Descriptor instead. func (*ProjectMembershipReference) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{7} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{9} } func (x *ProjectMembershipReference) GetMembershipReference() isProjectMembershipReference_MembershipReference { @@ -575,7 +698,7 @@ type ProjectServiceUpdateMemberRoleRequest struct { func (x *ProjectServiceUpdateMemberRoleRequest) Reset() { *x = ProjectServiceUpdateMemberRoleRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[8] + mi := &file_controlplane_v1_project_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -587,7 +710,7 @@ func (x *ProjectServiceUpdateMemberRoleRequest) String() string { func (*ProjectServiceUpdateMemberRoleRequest) ProtoMessage() {} func (x *ProjectServiceUpdateMemberRoleRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[8] + mi := &file_controlplane_v1_project_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -600,7 +723,7 @@ func (x *ProjectServiceUpdateMemberRoleRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ProjectServiceUpdateMemberRoleRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceUpdateMemberRoleRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{8} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{10} } func (x *ProjectServiceUpdateMemberRoleRequest) GetProjectReference() *IdentityReference { @@ -633,7 +756,7 @@ type ProjectServiceUpdateMemberRoleResponse struct { func (x *ProjectServiceUpdateMemberRoleResponse) Reset() { *x = ProjectServiceUpdateMemberRoleResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[9] + mi := &file_controlplane_v1_project_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -645,7 +768,7 @@ func (x *ProjectServiceUpdateMemberRoleResponse) String() string { func (*ProjectServiceUpdateMemberRoleResponse) ProtoMessage() {} func (x *ProjectServiceUpdateMemberRoleResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[9] + mi := &file_controlplane_v1_project_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -658,7 +781,7 @@ func (x *ProjectServiceUpdateMemberRoleResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ProjectServiceUpdateMemberRoleResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceUpdateMemberRoleResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{9} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{11} } type ProjectServiceListPendingInvitationsRequest struct { @@ -673,7 +796,7 @@ type ProjectServiceListPendingInvitationsRequest struct { func (x *ProjectServiceListPendingInvitationsRequest) Reset() { *x = ProjectServiceListPendingInvitationsRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[10] + mi := &file_controlplane_v1_project_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -685,7 +808,7 @@ func (x *ProjectServiceListPendingInvitationsRequest) String() string { func (*ProjectServiceListPendingInvitationsRequest) ProtoMessage() {} func (x *ProjectServiceListPendingInvitationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[10] + mi := &file_controlplane_v1_project_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -698,7 +821,7 @@ func (x *ProjectServiceListPendingInvitationsRequest) ProtoReflect() protoreflec // Deprecated: Use ProjectServiceListPendingInvitationsRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceListPendingInvitationsRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{10} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{12} } func (x *ProjectServiceListPendingInvitationsRequest) GetProjectReference() *IdentityReference { @@ -728,7 +851,7 @@ type ProjectServiceListPendingInvitationsResponse struct { func (x *ProjectServiceListPendingInvitationsResponse) Reset() { *x = ProjectServiceListPendingInvitationsResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[11] + mi := &file_controlplane_v1_project_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -740,7 +863,7 @@ func (x *ProjectServiceListPendingInvitationsResponse) String() string { func (*ProjectServiceListPendingInvitationsResponse) ProtoMessage() {} func (x *ProjectServiceListPendingInvitationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[11] + mi := &file_controlplane_v1_project_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -753,7 +876,7 @@ func (x *ProjectServiceListPendingInvitationsResponse) ProtoReflect() protorefle // Deprecated: Use ProjectServiceListPendingInvitationsResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceListPendingInvitationsResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{11} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{13} } func (x *ProjectServiceListPendingInvitationsResponse) GetInvitations() []*PendingProjectInvitation { @@ -787,7 +910,7 @@ type PendingProjectInvitation struct { func (x *PendingProjectInvitation) Reset() { *x = PendingProjectInvitation{} - mi := &file_controlplane_v1_project_proto_msgTypes[12] + mi := &file_controlplane_v1_project_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +922,7 @@ func (x *PendingProjectInvitation) String() string { func (*PendingProjectInvitation) ProtoMessage() {} func (x *PendingProjectInvitation) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[12] + mi := &file_controlplane_v1_project_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +935,7 @@ func (x *PendingProjectInvitation) ProtoReflect() protoreflect.Message { // Deprecated: Use PendingProjectInvitation.ProtoReflect.Descriptor instead. func (*PendingProjectInvitation) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{12} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{14} } func (x *PendingProjectInvitation) GetUserEmail() string { @@ -843,11 +966,128 @@ func (x *PendingProjectInvitation) GetInvitationId() string { return "" } +// ProjectItem represents a project of the organization +type ProjectServiceListResponse_ProjectItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unique identifier of the project + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Name of the project + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Description of the project + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Timestamp when the project was created + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Timestamp when the project was last updated + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Whether the caller may create a workflow in this project. The listing is + // filtered by what the caller can see, which is not the same as what they + // can write to: a project viewer can read a project but not add a workflow + // to it. This is resolved server-side because a caller can hold several + // roles on the same project, directly and through products, and any one of + // them granting the permission is enough. + CanCreateWorkflow bool `protobuf:"varint,6,opt,name=can_create_workflow,json=canCreateWorkflow,proto3" json:"can_create_workflow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListResponse_ProjectItem) Reset() { + *x = ProjectServiceListResponse_ProjectItem{} + mi := &file_controlplane_v1_project_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListResponse_ProjectItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListResponse_ProjectItem) ProtoMessage() {} + +func (x *ProjectServiceListResponse_ProjectItem) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListResponse_ProjectItem.ProtoReflect.Descriptor instead. +func (*ProjectServiceListResponse_ProjectItem) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *ProjectServiceListResponse_ProjectItem) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *ProjectServiceListResponse_ProjectItem) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +func (x *ProjectServiceListResponse_ProjectItem) GetCanCreateWorkflow() bool { + if x != nil { + return x.CanCreateWorkflow + } + return false +} + var File_controlplane_v1_project_proto protoreflect.FileDescriptor const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + - "\x1dcontrolplane/v1/project.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bcontrolplane/v1/group.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a$controlplane/v1/shared_message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc5\x01\n" + + "\x1dcontrolplane/v1/project.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bcontrolplane/v1/group.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a$controlplane/v1/shared_message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x87\x01\n" + + "\x19ProjectServiceListRequest\x12\x17\n" + + "\x04name\x18\x01 \x01(\tH\x00R\x04name\x88\x01\x01\x12H\n" + + "\n" + + "pagination\x18\x02 \x01(\v2(.controlplane.v1.OffsetPaginationRequestR\n" + + "paginationB\a\n" + + "\x05_name\"\xe6\x03\n" + + "\x1aProjectServiceListResponse\x12S\n" + + "\bprojects\x18\x01 \x03(\v27.controlplane.v1.ProjectServiceListResponse.ProjectItemR\bprojects\x12I\n" + + "\n" + + "pagination\x18\x02 \x01(\v2).controlplane.v1.OffsetPaginationResponseR\n" + + "pagination\x12,\n" + + "\x12can_create_project\x18\x03 \x01(\bR\x10canCreateProject\x1a\xf9\x01\n" + + "\vProjectItem\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x129\n" + + "\n" + + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12.\n" + + "\x13can_create_workflow\x18\x06 \x01(\bR\x11canCreateWorkflow\"\xc5\x01\n" + " ProjectServiceListMembersRequest\x12W\n" + "\x11project_reference\x18\x01 \x01(\v2\".controlplane.v1.IdentityReferenceB\x06\xbaH\x03\xc8\x01\x01R\x10projectReference\x12H\n" + "\n" + @@ -909,8 +1149,9 @@ const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12#\n" + "\rinvitation_id\x18\x04 \x01(\tR\finvitationIdB\r\n" + - "\v_invited_by2\x8f\x05\n" + - "\x0eProjectService\x12t\n" + + "\v_invited_by2\xf2\x05\n" + + "\x0eProjectService\x12a\n" + + "\x04List\x12*.controlplane.v1.ProjectServiceListRequest\x1a+.controlplane.v1.ProjectServiceListResponse\"\x00\x12t\n" + "\vListMembers\x121.controlplane.v1.ProjectServiceListMembersRequest\x1a2.controlplane.v1.ProjectServiceListMembersResponse\x12n\n" + "\tAddMember\x12/.controlplane.v1.ProjectServiceAddMemberRequest\x1a0.controlplane.v1.ProjectServiceAddMemberResponse\x12w\n" + "\fRemoveMember\x122.controlplane.v1.ProjectServiceRemoveMemberRequest\x1a3.controlplane.v1.ProjectServiceRemoveMemberResponse\x12\x83\x01\n" + @@ -929,69 +1170,79 @@ func file_controlplane_v1_project_proto_rawDescGZIP() []byte { return file_controlplane_v1_project_proto_rawDescData } -var file_controlplane_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_controlplane_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_controlplane_v1_project_proto_goTypes = []any{ - (*ProjectServiceListMembersRequest)(nil), // 0: controlplane.v1.ProjectServiceListMembersRequest - (*ProjectServiceListMembersResponse)(nil), // 1: controlplane.v1.ProjectServiceListMembersResponse - (*ProjectMember)(nil), // 2: controlplane.v1.ProjectMember - (*ProjectServiceAddMemberRequest)(nil), // 3: controlplane.v1.ProjectServiceAddMemberRequest - (*ProjectServiceAddMemberResponse)(nil), // 4: controlplane.v1.ProjectServiceAddMemberResponse - (*ProjectServiceRemoveMemberRequest)(nil), // 5: controlplane.v1.ProjectServiceRemoveMemberRequest - (*ProjectServiceRemoveMemberResponse)(nil), // 6: controlplane.v1.ProjectServiceRemoveMemberResponse - (*ProjectMembershipReference)(nil), // 7: controlplane.v1.ProjectMembershipReference - (*ProjectServiceUpdateMemberRoleRequest)(nil), // 8: controlplane.v1.ProjectServiceUpdateMemberRoleRequest - (*ProjectServiceUpdateMemberRoleResponse)(nil), // 9: controlplane.v1.ProjectServiceUpdateMemberRoleResponse - (*ProjectServiceListPendingInvitationsRequest)(nil), // 10: controlplane.v1.ProjectServiceListPendingInvitationsRequest - (*ProjectServiceListPendingInvitationsResponse)(nil), // 11: controlplane.v1.ProjectServiceListPendingInvitationsResponse - (*PendingProjectInvitation)(nil), // 12: controlplane.v1.PendingProjectInvitation - (*IdentityReference)(nil), // 13: controlplane.v1.IdentityReference - (*OffsetPaginationRequest)(nil), // 14: controlplane.v1.OffsetPaginationRequest - (*OffsetPaginationResponse)(nil), // 15: controlplane.v1.OffsetPaginationResponse - (*User)(nil), // 16: controlplane.v1.User - (*Group)(nil), // 17: controlplane.v1.Group - (ProjectMemberRole)(0), // 18: controlplane.v1.ProjectMemberRole - (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp + (*ProjectServiceListRequest)(nil), // 0: controlplane.v1.ProjectServiceListRequest + (*ProjectServiceListResponse)(nil), // 1: controlplane.v1.ProjectServiceListResponse + (*ProjectServiceListMembersRequest)(nil), // 2: controlplane.v1.ProjectServiceListMembersRequest + (*ProjectServiceListMembersResponse)(nil), // 3: controlplane.v1.ProjectServiceListMembersResponse + (*ProjectMember)(nil), // 4: controlplane.v1.ProjectMember + (*ProjectServiceAddMemberRequest)(nil), // 5: controlplane.v1.ProjectServiceAddMemberRequest + (*ProjectServiceAddMemberResponse)(nil), // 6: controlplane.v1.ProjectServiceAddMemberResponse + (*ProjectServiceRemoveMemberRequest)(nil), // 7: controlplane.v1.ProjectServiceRemoveMemberRequest + (*ProjectServiceRemoveMemberResponse)(nil), // 8: controlplane.v1.ProjectServiceRemoveMemberResponse + (*ProjectMembershipReference)(nil), // 9: controlplane.v1.ProjectMembershipReference + (*ProjectServiceUpdateMemberRoleRequest)(nil), // 10: controlplane.v1.ProjectServiceUpdateMemberRoleRequest + (*ProjectServiceUpdateMemberRoleResponse)(nil), // 11: controlplane.v1.ProjectServiceUpdateMemberRoleResponse + (*ProjectServiceListPendingInvitationsRequest)(nil), // 12: controlplane.v1.ProjectServiceListPendingInvitationsRequest + (*ProjectServiceListPendingInvitationsResponse)(nil), // 13: controlplane.v1.ProjectServiceListPendingInvitationsResponse + (*PendingProjectInvitation)(nil), // 14: controlplane.v1.PendingProjectInvitation + (*ProjectServiceListResponse_ProjectItem)(nil), // 15: controlplane.v1.ProjectServiceListResponse.ProjectItem + (*OffsetPaginationRequest)(nil), // 16: controlplane.v1.OffsetPaginationRequest + (*OffsetPaginationResponse)(nil), // 17: controlplane.v1.OffsetPaginationResponse + (*IdentityReference)(nil), // 18: controlplane.v1.IdentityReference + (*User)(nil), // 19: controlplane.v1.User + (*Group)(nil), // 20: controlplane.v1.Group + (ProjectMemberRole)(0), // 21: controlplane.v1.ProjectMemberRole + (*timestamppb.Timestamp)(nil), // 22: google.protobuf.Timestamp } var file_controlplane_v1_project_proto_depIdxs = []int32{ - 13, // 0: controlplane.v1.ProjectServiceListMembersRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 14, // 1: controlplane.v1.ProjectServiceListMembersRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest - 2, // 2: controlplane.v1.ProjectServiceListMembersResponse.members:type_name -> controlplane.v1.ProjectMember - 15, // 3: controlplane.v1.ProjectServiceListMembersResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse - 16, // 4: controlplane.v1.ProjectMember.user:type_name -> controlplane.v1.User - 17, // 5: controlplane.v1.ProjectMember.group:type_name -> controlplane.v1.Group - 18, // 6: controlplane.v1.ProjectMember.role:type_name -> controlplane.v1.ProjectMemberRole - 19, // 7: controlplane.v1.ProjectMember.created_at:type_name -> google.protobuf.Timestamp - 19, // 8: controlplane.v1.ProjectMember.updated_at:type_name -> google.protobuf.Timestamp - 13, // 9: controlplane.v1.ProjectServiceAddMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 10: controlplane.v1.ProjectServiceAddMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 18, // 11: controlplane.v1.ProjectServiceAddMemberRequest.role:type_name -> controlplane.v1.ProjectMemberRole - 13, // 12: controlplane.v1.ProjectServiceRemoveMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 13: controlplane.v1.ProjectServiceRemoveMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 13, // 14: controlplane.v1.ProjectMembershipReference.group_reference:type_name -> controlplane.v1.IdentityReference - 13, // 15: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 16: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 18, // 17: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.new_role:type_name -> controlplane.v1.ProjectMemberRole - 13, // 18: controlplane.v1.ProjectServiceListPendingInvitationsRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 14, // 19: controlplane.v1.ProjectServiceListPendingInvitationsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest - 12, // 20: controlplane.v1.ProjectServiceListPendingInvitationsResponse.invitations:type_name -> controlplane.v1.PendingProjectInvitation - 15, // 21: controlplane.v1.ProjectServiceListPendingInvitationsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse - 16, // 22: controlplane.v1.PendingProjectInvitation.invited_by:type_name -> controlplane.v1.User - 19, // 23: controlplane.v1.PendingProjectInvitation.created_at:type_name -> google.protobuf.Timestamp - 0, // 24: controlplane.v1.ProjectService.ListMembers:input_type -> controlplane.v1.ProjectServiceListMembersRequest - 3, // 25: controlplane.v1.ProjectService.AddMember:input_type -> controlplane.v1.ProjectServiceAddMemberRequest - 5, // 26: controlplane.v1.ProjectService.RemoveMember:input_type -> controlplane.v1.ProjectServiceRemoveMemberRequest - 8, // 27: controlplane.v1.ProjectService.UpdateMemberRole:input_type -> controlplane.v1.ProjectServiceUpdateMemberRoleRequest - 10, // 28: controlplane.v1.ProjectService.ListPendingInvitations:input_type -> controlplane.v1.ProjectServiceListPendingInvitationsRequest - 1, // 29: controlplane.v1.ProjectService.ListMembers:output_type -> controlplane.v1.ProjectServiceListMembersResponse - 4, // 30: controlplane.v1.ProjectService.AddMember:output_type -> controlplane.v1.ProjectServiceAddMemberResponse - 6, // 31: controlplane.v1.ProjectService.RemoveMember:output_type -> controlplane.v1.ProjectServiceRemoveMemberResponse - 9, // 32: controlplane.v1.ProjectService.UpdateMemberRole:output_type -> controlplane.v1.ProjectServiceUpdateMemberRoleResponse - 11, // 33: controlplane.v1.ProjectService.ListPendingInvitations:output_type -> controlplane.v1.ProjectServiceListPendingInvitationsResponse - 29, // [29:34] is the sub-list for method output_type - 24, // [24:29] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 16, // 0: controlplane.v1.ProjectServiceListRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 15, // 1: controlplane.v1.ProjectServiceListResponse.projects:type_name -> controlplane.v1.ProjectServiceListResponse.ProjectItem + 17, // 2: controlplane.v1.ProjectServiceListResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 18, // 3: controlplane.v1.ProjectServiceListMembersRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 16, // 4: controlplane.v1.ProjectServiceListMembersRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 4, // 5: controlplane.v1.ProjectServiceListMembersResponse.members:type_name -> controlplane.v1.ProjectMember + 17, // 6: controlplane.v1.ProjectServiceListMembersResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 19, // 7: controlplane.v1.ProjectMember.user:type_name -> controlplane.v1.User + 20, // 8: controlplane.v1.ProjectMember.group:type_name -> controlplane.v1.Group + 21, // 9: controlplane.v1.ProjectMember.role:type_name -> controlplane.v1.ProjectMemberRole + 22, // 10: controlplane.v1.ProjectMember.created_at:type_name -> google.protobuf.Timestamp + 22, // 11: controlplane.v1.ProjectMember.updated_at:type_name -> google.protobuf.Timestamp + 18, // 12: controlplane.v1.ProjectServiceAddMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 13: controlplane.v1.ProjectServiceAddMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 21, // 14: controlplane.v1.ProjectServiceAddMemberRequest.role:type_name -> controlplane.v1.ProjectMemberRole + 18, // 15: controlplane.v1.ProjectServiceRemoveMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 16: controlplane.v1.ProjectServiceRemoveMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 18, // 17: controlplane.v1.ProjectMembershipReference.group_reference:type_name -> controlplane.v1.IdentityReference + 18, // 18: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 19: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 21, // 20: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.new_role:type_name -> controlplane.v1.ProjectMemberRole + 18, // 21: controlplane.v1.ProjectServiceListPendingInvitationsRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 16, // 22: controlplane.v1.ProjectServiceListPendingInvitationsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 14, // 23: controlplane.v1.ProjectServiceListPendingInvitationsResponse.invitations:type_name -> controlplane.v1.PendingProjectInvitation + 17, // 24: controlplane.v1.ProjectServiceListPendingInvitationsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 19, // 25: controlplane.v1.PendingProjectInvitation.invited_by:type_name -> controlplane.v1.User + 22, // 26: controlplane.v1.PendingProjectInvitation.created_at:type_name -> google.protobuf.Timestamp + 22, // 27: controlplane.v1.ProjectServiceListResponse.ProjectItem.created_at:type_name -> google.protobuf.Timestamp + 22, // 28: controlplane.v1.ProjectServiceListResponse.ProjectItem.updated_at:type_name -> google.protobuf.Timestamp + 0, // 29: controlplane.v1.ProjectService.List:input_type -> controlplane.v1.ProjectServiceListRequest + 2, // 30: controlplane.v1.ProjectService.ListMembers:input_type -> controlplane.v1.ProjectServiceListMembersRequest + 5, // 31: controlplane.v1.ProjectService.AddMember:input_type -> controlplane.v1.ProjectServiceAddMemberRequest + 7, // 32: controlplane.v1.ProjectService.RemoveMember:input_type -> controlplane.v1.ProjectServiceRemoveMemberRequest + 10, // 33: controlplane.v1.ProjectService.UpdateMemberRole:input_type -> controlplane.v1.ProjectServiceUpdateMemberRoleRequest + 12, // 34: controlplane.v1.ProjectService.ListPendingInvitations:input_type -> controlplane.v1.ProjectServiceListPendingInvitationsRequest + 1, // 35: controlplane.v1.ProjectService.List:output_type -> controlplane.v1.ProjectServiceListResponse + 3, // 36: controlplane.v1.ProjectService.ListMembers:output_type -> controlplane.v1.ProjectServiceListMembersResponse + 6, // 37: controlplane.v1.ProjectService.AddMember:output_type -> controlplane.v1.ProjectServiceAddMemberResponse + 8, // 38: controlplane.v1.ProjectService.RemoveMember:output_type -> controlplane.v1.ProjectServiceRemoveMemberResponse + 11, // 39: controlplane.v1.ProjectService.UpdateMemberRole:output_type -> controlplane.v1.ProjectServiceUpdateMemberRoleResponse + 13, // 40: controlplane.v1.ProjectService.ListPendingInvitations:output_type -> controlplane.v1.ProjectServiceListPendingInvitationsResponse + 35, // [35:41] is the sub-list for method output_type + 29, // [29:35] is the sub-list for method input_type + 29, // [29:29] is the sub-list for extension type_name + 29, // [29:29] is the sub-list for extension extendee + 0, // [0:29] is the sub-list for field type_name } func init() { file_controlplane_v1_project_proto_init() } @@ -1003,22 +1254,23 @@ func file_controlplane_v1_project_proto_init() { file_controlplane_v1_pagination_proto_init() file_controlplane_v1_response_messages_proto_init() file_controlplane_v1_shared_message_proto_init() - file_controlplane_v1_project_proto_msgTypes[2].OneofWrappers = []any{ + file_controlplane_v1_project_proto_msgTypes[0].OneofWrappers = []any{} + file_controlplane_v1_project_proto_msgTypes[4].OneofWrappers = []any{ (*ProjectMember_User)(nil), (*ProjectMember_Group)(nil), } - file_controlplane_v1_project_proto_msgTypes[7].OneofWrappers = []any{ + file_controlplane_v1_project_proto_msgTypes[9].OneofWrappers = []any{ (*ProjectMembershipReference_UserEmail)(nil), (*ProjectMembershipReference_GroupReference)(nil), } - file_controlplane_v1_project_proto_msgTypes[12].OneofWrappers = []any{} + file_controlplane_v1_project_proto_msgTypes[14].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_project_proto_rawDesc), len(file_controlplane_v1_project_proto_rawDesc)), NumEnums: 0, - NumMessages: 13, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/project.proto b/app/controlplane/api/controlplane/v1/project.proto index 9badf03d8..df63ef261 100644 --- a/app/controlplane/api/controlplane/v1/project.proto +++ b/app/controlplane/api/controlplane/v1/project.proto @@ -27,6 +27,8 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1"; service ProjectService { + // List the projects of the current organization that the caller can see + rpc List(ProjectServiceListRequest) returns (ProjectServiceListResponse) {} // Project membership management rpc ListMembers(ProjectServiceListMembersRequest) returns (ProjectServiceListMembersResponse); rpc AddMember(ProjectServiceAddMemberRequest) returns (ProjectServiceAddMemberResponse); @@ -35,6 +37,49 @@ service ProjectService { rpc ListPendingInvitations(ProjectServiceListPendingInvitationsRequest) returns (ProjectServiceListPendingInvitationsResponse) {} } +// ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization +message ProjectServiceListRequest { + // Filter by project name, case-insensitive substring match + optional string name = 1; + // Pagination parameters to limit and offset results + OffsetPaginationRequest pagination = 2; +} + +// ProjectServiceListResponse contains a paginated list of the organization's projects +message ProjectServiceListResponse { + // List of projects matching the request criteria + repeated ProjectItem projects = 1; + // Pagination information for the response + OffsetPaginationResponse pagination = 2; + // Whether the caller may create a project in this organization. It is a + // property of their organization role, not of any project, so it does not + // belong on the items: an organization contributor, for instance, can be an + // administrator of several projects and still create none. Clients offering + // to create one need this to avoid an option that always fails. + bool can_create_project = 3; + + // ProjectItem represents a project of the organization + message ProjectItem { + // Unique identifier of the project + string id = 1; + // Name of the project + string name = 2; + // Description of the project + string description = 3; + // Timestamp when the project was created + google.protobuf.Timestamp created_at = 4; + // Timestamp when the project was last updated + google.protobuf.Timestamp updated_at = 5; + // Whether the caller may create a workflow in this project. The listing is + // filtered by what the caller can see, which is not the same as what they + // can write to: a project viewer can read a project but not add a workflow + // to it. This is resolved server-side because a caller can hold several + // roles on the same project, directly and through products, and any one of + // them granting the permission is enough. + bool can_create_workflow = 6; + } +} + // ProjectServiceListMembersRequest contains the information needed to list members of a project message ProjectServiceListMembersRequest { // IdentityReference is used to specify the project by either its ID or name diff --git a/app/controlplane/api/controlplane/v1/project_grpc.pb.go b/app/controlplane/api/controlplane/v1/project_grpc.pb.go index 06f0ecee5..f7d874c7f 100644 --- a/app/controlplane/api/controlplane/v1/project_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/project_grpc.pb.go @@ -34,6 +34,7 @@ import ( const _ = grpc.SupportPackageIsVersion7 const ( + ProjectService_List_FullMethodName = "/controlplane.v1.ProjectService/List" ProjectService_ListMembers_FullMethodName = "/controlplane.v1.ProjectService/ListMembers" ProjectService_AddMember_FullMethodName = "/controlplane.v1.ProjectService/AddMember" ProjectService_RemoveMember_FullMethodName = "/controlplane.v1.ProjectService/RemoveMember" @@ -45,6 +46,8 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ProjectServiceClient interface { + // List the projects of the current organization that the caller can see + List(ctx context.Context, in *ProjectServiceListRequest, opts ...grpc.CallOption) (*ProjectServiceListResponse, error) // Project membership management ListMembers(ctx context.Context, in *ProjectServiceListMembersRequest, opts ...grpc.CallOption) (*ProjectServiceListMembersResponse, error) AddMember(ctx context.Context, in *ProjectServiceAddMemberRequest, opts ...grpc.CallOption) (*ProjectServiceAddMemberResponse, error) @@ -61,6 +64,15 @@ func NewProjectServiceClient(cc grpc.ClientConnInterface) ProjectServiceClient { return &projectServiceClient{cc} } +func (c *projectServiceClient) List(ctx context.Context, in *ProjectServiceListRequest, opts ...grpc.CallOption) (*ProjectServiceListResponse, error) { + out := new(ProjectServiceListResponse) + err := c.cc.Invoke(ctx, ProjectService_List_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *projectServiceClient) ListMembers(ctx context.Context, in *ProjectServiceListMembersRequest, opts ...grpc.CallOption) (*ProjectServiceListMembersResponse, error) { out := new(ProjectServiceListMembersResponse) err := c.cc.Invoke(ctx, ProjectService_ListMembers_FullMethodName, in, out, opts...) @@ -110,6 +122,8 @@ func (c *projectServiceClient) ListPendingInvitations(ctx context.Context, in *P // All implementations must embed UnimplementedProjectServiceServer // for forward compatibility type ProjectServiceServer interface { + // List the projects of the current organization that the caller can see + List(context.Context, *ProjectServiceListRequest) (*ProjectServiceListResponse, error) // Project membership management ListMembers(context.Context, *ProjectServiceListMembersRequest) (*ProjectServiceListMembersResponse, error) AddMember(context.Context, *ProjectServiceAddMemberRequest) (*ProjectServiceAddMemberResponse, error) @@ -123,6 +137,9 @@ type ProjectServiceServer interface { type UnimplementedProjectServiceServer struct { } +func (UnimplementedProjectServiceServer) List(context.Context, *ProjectServiceListRequest) (*ProjectServiceListResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method List not implemented") +} func (UnimplementedProjectServiceServer) ListMembers(context.Context, *ProjectServiceListMembersRequest) (*ProjectServiceListMembersResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListMembers not implemented") } @@ -151,6 +168,24 @@ func RegisterProjectServiceServer(s grpc.ServiceRegistrar, srv ProjectServiceSer s.RegisterService(&ProjectService_ServiceDesc, srv) } +func _ProjectService_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProjectServiceListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_List_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).List(ctx, req.(*ProjectServiceListRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _ProjectService_ListMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ProjectServiceListMembersRequest) if err := dec(in); err != nil { @@ -248,6 +283,10 @@ var ProjectService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "controlplane.v1.ProjectService", HandlerType: (*ProjectServiceServer)(nil), Methods: []grpc.MethodDesc{ + { + MethodName: "List", + Handler: _ProjectService_List_Handler, + }, { MethodName: "ListMembers", Handler: _ProjectService_ListMembers_Handler, diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts index 2d8e217b8..bd634ca62 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts @@ -15,6 +15,55 @@ import { export const protobufPackage = "controlplane.v1"; +/** ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization */ +export interface ProjectServiceListRequest { + /** Filter by project name, case-insensitive substring match */ + name?: + | string + | undefined; + /** Pagination parameters to limit and offset results */ + pagination?: OffsetPaginationRequest; +} + +/** ProjectServiceListResponse contains a paginated list of the organization's projects */ +export interface ProjectServiceListResponse { + /** List of projects matching the request criteria */ + projects: ProjectServiceListResponse_ProjectItem[]; + /** Pagination information for the response */ + pagination?: OffsetPaginationResponse; + /** + * Whether the caller may create a project in this organization. It is a + * property of their organization role, not of any project, so it does not + * belong on the items: an organization contributor, for instance, can be an + * administrator of several projects and still create none. Clients offering + * to create one need this to avoid an option that always fails. + */ + canCreateProject: boolean; +} + +/** ProjectItem represents a project of the organization */ +export interface ProjectServiceListResponse_ProjectItem { + /** Unique identifier of the project */ + id: string; + /** Name of the project */ + name: string; + /** Description of the project */ + description: string; + /** Timestamp when the project was created */ + createdAt?: Date; + /** Timestamp when the project was last updated */ + updatedAt?: Date; + /** + * Whether the caller may create a workflow in this project. The listing is + * filtered by what the caller can see, which is not the same as what they + * can write to: a project viewer can read a project but not add a workflow + * to it. This is resolved server-side because a caller can hold several + * roles on the same project, directly and through products, and any one of + * them granting the permission is enough. + */ + canCreateWorkflow: boolean; +} + /** ProjectServiceListMembersRequest contains the information needed to list members of a project */ export interface ProjectServiceListMembersRequest { /** IdentityReference is used to specify the project by either its ID or name */ @@ -131,6 +180,300 @@ export interface PendingProjectInvitation { invitationId: string; } +function createBaseProjectServiceListRequest(): ProjectServiceListRequest { + return { name: undefined, pagination: undefined }; +} + +export const ProjectServiceListRequest = { + encode(message: ProjectServiceListRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== undefined) { + writer.uint32(10).string(message.name); + } + if (message.pagination !== undefined) { + OffsetPaginationRequest.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = OffsetPaginationRequest.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListRequest { + return { + name: isSet(object.name) ? String(object.name) : undefined, + pagination: isSet(object.pagination) ? OffsetPaginationRequest.fromJSON(object.pagination) : undefined, + }; + }, + + toJSON(message: ProjectServiceListRequest): unknown { + const obj: any = {}; + message.name !== undefined && (obj.name = message.name); + message.pagination !== undefined && + (obj.pagination = message.pagination ? OffsetPaginationRequest.toJSON(message.pagination) : undefined); + return obj; + }, + + create, I>>(base?: I): ProjectServiceListRequest { + return ProjectServiceListRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ProjectServiceListRequest { + const message = createBaseProjectServiceListRequest(); + message.name = object.name ?? undefined; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? OffsetPaginationRequest.fromPartial(object.pagination) + : undefined; + return message; + }, +}; + +function createBaseProjectServiceListResponse(): ProjectServiceListResponse { + return { projects: [], pagination: undefined, canCreateProject: false }; +} + +export const ProjectServiceListResponse = { + encode(message: ProjectServiceListResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + for (const v of message.projects) { + ProjectServiceListResponse_ProjectItem.encode(v!, writer.uint32(10).fork()).ldelim(); + } + if (message.pagination !== undefined) { + OffsetPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } + if (message.canCreateProject === true) { + writer.uint32(24).bool(message.canCreateProject); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.projects.push(ProjectServiceListResponse_ProjectItem.decode(reader, reader.uint32())); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = OffsetPaginationResponse.decode(reader, reader.uint32()); + continue; + case 3: + if (tag !== 24) { + break; + } + + message.canCreateProject = reader.bool(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListResponse { + return { + projects: Array.isArray(object?.projects) + ? object.projects.map((e: any) => ProjectServiceListResponse_ProjectItem.fromJSON(e)) + : [], + pagination: isSet(object.pagination) ? OffsetPaginationResponse.fromJSON(object.pagination) : undefined, + canCreateProject: isSet(object.canCreateProject) ? Boolean(object.canCreateProject) : false, + }; + }, + + toJSON(message: ProjectServiceListResponse): unknown { + const obj: any = {}; + if (message.projects) { + obj.projects = message.projects.map((e) => e ? ProjectServiceListResponse_ProjectItem.toJSON(e) : undefined); + } else { + obj.projects = []; + } + message.pagination !== undefined && + (obj.pagination = message.pagination ? OffsetPaginationResponse.toJSON(message.pagination) : undefined); + message.canCreateProject !== undefined && (obj.canCreateProject = message.canCreateProject); + return obj; + }, + + create, I>>(base?: I): ProjectServiceListResponse { + return ProjectServiceListResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ProjectServiceListResponse { + const message = createBaseProjectServiceListResponse(); + message.projects = object.projects?.map((e) => ProjectServiceListResponse_ProjectItem.fromPartial(e)) || []; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? OffsetPaginationResponse.fromPartial(object.pagination) + : undefined; + message.canCreateProject = object.canCreateProject ?? false; + return message; + }, +}; + +function createBaseProjectServiceListResponse_ProjectItem(): ProjectServiceListResponse_ProjectItem { + return { id: "", name: "", description: "", createdAt: undefined, updatedAt: undefined, canCreateWorkflow: false }; +} + +export const ProjectServiceListResponse_ProjectItem = { + encode(message: ProjectServiceListResponse_ProjectItem, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + if (message.name !== "") { + writer.uint32(18).string(message.name); + } + if (message.description !== "") { + writer.uint32(26).string(message.description); + } + if (message.createdAt !== undefined) { + Timestamp.encode(toTimestamp(message.createdAt), writer.uint32(34).fork()).ldelim(); + } + if (message.updatedAt !== undefined) { + Timestamp.encode(toTimestamp(message.updatedAt), writer.uint32(42).fork()).ldelim(); + } + if (message.canCreateWorkflow === true) { + writer.uint32(48).bool(message.canCreateWorkflow); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListResponse_ProjectItem { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListResponse_ProjectItem(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.name = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.description = reader.string(); + continue; + case 4: + if (tag !== 34) { + break; + } + + message.createdAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + case 5: + if (tag !== 42) { + break; + } + + message.updatedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + case 6: + if (tag !== 48) { + break; + } + + message.canCreateWorkflow = reader.bool(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListResponse_ProjectItem { + return { + id: isSet(object.id) ? String(object.id) : "", + name: isSet(object.name) ? String(object.name) : "", + description: isSet(object.description) ? String(object.description) : "", + createdAt: isSet(object.createdAt) ? fromJsonTimestamp(object.createdAt) : undefined, + updatedAt: isSet(object.updatedAt) ? fromJsonTimestamp(object.updatedAt) : undefined, + canCreateWorkflow: isSet(object.canCreateWorkflow) ? Boolean(object.canCreateWorkflow) : false, + }; + }, + + toJSON(message: ProjectServiceListResponse_ProjectItem): unknown { + const obj: any = {}; + message.id !== undefined && (obj.id = message.id); + message.name !== undefined && (obj.name = message.name); + message.description !== undefined && (obj.description = message.description); + message.createdAt !== undefined && (obj.createdAt = message.createdAt.toISOString()); + message.updatedAt !== undefined && (obj.updatedAt = message.updatedAt.toISOString()); + message.canCreateWorkflow !== undefined && (obj.canCreateWorkflow = message.canCreateWorkflow); + return obj; + }, + + create, I>>( + base?: I, + ): ProjectServiceListResponse_ProjectItem { + return ProjectServiceListResponse_ProjectItem.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): ProjectServiceListResponse_ProjectItem { + const message = createBaseProjectServiceListResponse_ProjectItem(); + message.id = object.id ?? ""; + message.name = object.name ?? ""; + message.description = object.description ?? ""; + message.createdAt = object.createdAt ?? undefined; + message.updatedAt = object.updatedAt ?? undefined; + message.canCreateWorkflow = object.canCreateWorkflow ?? false; + return message; + }, +}; + function createBaseProjectServiceListMembersRequest(): ProjectServiceListMembersRequest { return { projectReference: undefined, pagination: undefined }; } @@ -1213,6 +1556,8 @@ export const PendingProjectInvitation = { }; export interface ProjectService { + /** List the projects of the current organization that the caller can see */ + List(request: DeepPartial, metadata?: grpc.Metadata): Promise; /** Project membership management */ ListMembers( request: DeepPartial, @@ -1241,6 +1586,7 @@ export class ProjectServiceClientImpl implements ProjectService { constructor(rpc: Rpc) { this.rpc = rpc; + this.List = this.List.bind(this); this.ListMembers = this.ListMembers.bind(this); this.AddMember = this.AddMember.bind(this); this.RemoveMember = this.RemoveMember.bind(this); @@ -1248,6 +1594,10 @@ export class ProjectServiceClientImpl implements ProjectService { this.ListPendingInvitations = this.ListPendingInvitations.bind(this); } + List(request: DeepPartial, metadata?: grpc.Metadata): Promise { + return this.rpc.unary(ProjectServiceListDesc, ProjectServiceListRequest.fromPartial(request), metadata); + } + ListMembers( request: DeepPartial, metadata?: grpc.Metadata, @@ -1302,6 +1652,29 @@ export class ProjectServiceClientImpl implements ProjectService { export const ProjectServiceDesc = { serviceName: "controlplane.v1.ProjectService" }; +export const ProjectServiceListDesc: UnaryMethodDefinitionish = { + methodName: "List", + service: ProjectServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return ProjectServiceListRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = ProjectServiceListResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + export const ProjectServiceListMembersDesc: UnaryMethodDefinitionish = { methodName: "ListMembers", service: ProjectServiceDesc, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json new file mode 100644 index 000000000..8adc2c34c --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json @@ -0,0 +1,18 @@ +{ + "$id": "controlplane.v1.ProjectServiceListRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization", + "properties": { + "name": { + "description": "Filter by project name, case-insensitive substring match", + "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationRequest.jsonschema.json", + "description": "Pagination parameters to limit and offset results" + } + }, + "title": "Project Service List Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json new file mode 100644 index 000000000..5e89d8983 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "controlplane.v1.ProjectServiceListRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization", + "properties": { + "name": { + "description": "Filter by project name, case-insensitive substring match", + "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationRequest.schema.json", + "description": "Pagination parameters to limit and offset results" + } + }, + "title": "Project Service List Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json new file mode 100644 index 000000000..2034bc1d3 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json @@ -0,0 +1,48 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectItem represents a project of the organization", + "patternProperties": { + "^(can_create_workflow)$": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, + "^(created_at)$": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was created" + }, + "^(updated_at)$": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was last updated" + } + }, + "properties": { + "canCreateWorkflow": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, + "createdAt": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was created" + }, + "description": { + "description": "Description of the project", + "type": "string" + }, + "id": { + "description": "Unique identifier of the project", + "type": "string" + }, + "name": { + "description": "Name of the project", + "type": "string" + }, + "updatedAt": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was last updated" + } + }, + "title": "Project Item", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json new file mode 100644 index 000000000..b02b051c5 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json @@ -0,0 +1,48 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectItem represents a project of the organization", + "patternProperties": { + "^(canCreateWorkflow)$": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, + "^(createdAt)$": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was created" + }, + "^(updatedAt)$": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was last updated" + } + }, + "properties": { + "can_create_workflow": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, + "created_at": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was created" + }, + "description": { + "description": "Description of the project", + "type": "string" + }, + "id": { + "description": "Unique identifier of the project", + "type": "string" + }, + "name": { + "description": "Name of the project", + "type": "string" + }, + "updated_at": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was last updated" + } + }, + "title": "Project Item", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json new file mode 100644 index 000000000..1e6c86acb --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json @@ -0,0 +1,31 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "patternProperties": { + "^(can_create_project)$": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + } + }, + "properties": { + "canCreateProject": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationResponse.jsonschema.json", + "description": "Pagination information for the response" + }, + "projects": { + "description": "List of projects matching the request criteria", + "items": { + "$ref": "controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json" + }, + "type": "array" + } + }, + "title": "Project Service List Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json new file mode 100644 index 000000000..423753995 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json @@ -0,0 +1,31 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "patternProperties": { + "^(canCreateProject)$": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + } + }, + "properties": { + "can_create_project": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationResponse.schema.json", + "description": "Pagination information for the response" + }, + "projects": { + "description": "List of projects matching the request criteria", + "items": { + "$ref": "controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json" + }, + "type": "array" + } + }, + "title": "Project Service List Response", + "type": "object" +} diff --git a/app/controlplane/internal/service/project.go b/app/controlplane/internal/service/project.go index 46db4bbcd..bfe8be24f 100644 --- a/app/controlplane/internal/service/project.go +++ b/app/controlplane/internal/service/project.go @@ -39,6 +39,62 @@ func NewProjectService(opts ...NewOpt) *ProjectService { } } +// List returns the projects of the current organization the caller can see. +func (s *ProjectService) List(ctx context.Context, req *pb.ProjectServiceListRequest) (*pb.ProjectServiceListResponse, error) { + currentOrg, err := requireCurrentOrg(ctx) + if err != nil { + return nil, err + } + + orgUUID, err := uuid.Parse(currentOrg.ID) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // Initialize the pagination options, with default values + paginationOpts, err := initializePaginationOpts(req.GetPagination()) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // visibleProjects is nil when RBAC does not apply, which the use case reads as + // "no restriction", and a possibly empty set of project IDs otherwise. + projects, total, err := s.projectUseCase.List(ctx, &biz.ProjectListOpts{ + OrganizationID: orgUUID, + Name: req.Name, + VisibleProjects: s.visibleProjects(ctx), + }, paginationOpts) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // Resolve, once per listing, which projects the caller may add a workflow to. + writable, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // Whether a new project is an option at all depends on the organization role + // rather than on any project, so it is answered once for the listing. + canCreateProject, err := s.canCreateProject(ctx) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + result := make([]*pb.ProjectServiceListResponse_ProjectItem, 0, len(projects)) + for _, p := range projects { + item := bizProjectToPb(p) + item.CanCreateWorkflow = writable[p.ID] + result = append(result, item) + } + + return &pb.ProjectServiceListResponse{ + Projects: result, + Pagination: paginationToPb(total, paginationOpts.Offset(), paginationOpts.Limit()), + CanCreateProject: canCreateProject, + }, nil +} + // ListMembers lists the members of a project. func (s *ProjectService) ListMembers(ctx context.Context, req *pb.ProjectServiceListMembersRequest) (*pb.ProjectServiceListMembersResponse, error) { currentOrg, err := requireCurrentOrg(ctx) @@ -431,6 +487,24 @@ func mapAuthzRoleToProjectMemberRole(role authz.Role) pb.ProjectMemberRole { } } +// bizProjectToPb converts a biz.Project to the item returned by List. +func bizProjectToPb(p *biz.Project) *pb.ProjectServiceListResponse_ProjectItem { + item := &pb.ProjectServiceListResponse_ProjectItem{ + Id: p.ID.String(), + Name: p.Name, + Description: p.Description, + } + + if p.CreatedAt != nil { + item.CreatedAt = timestamppb.New(*p.CreatedAt) + } + if p.UpdatedAt != nil { + item.UpdatedAt = timestamppb.New(*p.UpdatedAt) + } + + return item +} + // bizOrgInvitationToPendingProjectInvitationPb converts a biz.OrgInvitation to a pb.PendingProjectInvitation protobuf message. func bizOrgInvitationToPendingProjectInvitationPb(inv *biz.OrgInvitation) *pb.PendingProjectInvitation { base := &pb.PendingProjectInvitation{ diff --git a/app/controlplane/internal/service/project_permissions_test.go b/app/controlplane/internal/service/project_permissions_test.go new file mode 100644 index 000000000..6dbd3aa09 --- /dev/null +++ b/app/controlplane/internal/service/project_permissions_test.go @@ -0,0 +1,236 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "io" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" + + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestService builds a service with a real casbin enforcer, so the +// permission answers come from the same rules the enforcement path uses. +func newTestService(t *testing.T) *service { + t.Helper() + + logger := log.NewStdLogger(io.Discard) + enforcer, err := authz.NewCasbinEnforcer(&authz.Config{RolesMap: authz.RolesMap}) + require.NoError(t, err) + + return &service{ + log: log.NewHelper(logger), + authz: biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{ + CasbinEnforcer: enforcer, + Logger: logger, + }), + } +} + +// projectMembership is one of the caller's roles on a project. +func projectMembership(id uuid.UUID, role authz.Role) *entities.ResourceMembership { + return &entities.ResourceMembership{ + ResourceType: authz.ResourceTypeProject, + ResourceID: id, + Role: role, + } +} + +// rbacContext puts the caller's memberships on the context under a role that +// turns RBAC on, which is what makes the per-project check apply. +func rbacContext(memberships ...*entities.ResourceMembership) context.Context { + ctx := usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOrgMember)) + + return entities.WithMembership(ctx, &entities.Membership{Resources: memberships}) +} + +// TestProjectsAllowing covers which projects a listing may offer as a workflow +// target. Getting this wrong either hides projects the caller can use or, worse, +// offers ones that fail at creation time. +func TestProjectsAllowing(t *testing.T) { + admin, viewer, both := uuid.New(), uuid.New(), uuid.New() + + projects := []*biz.Project{ + {ID: admin, Name: "admin-project"}, + {ID: viewer, Name: "viewer-project"}, + {ID: both, Name: "both-project"}, + } + + testCases := []struct { + name string + ctx context.Context + wantAllowed map[uuid.UUID]bool + }{ + { + name: "a project admin may create a workflow, a viewer may not", + ctx: rbacContext( + projectMembership(admin, authz.RoleProjectAdmin), + projectMembership(viewer, authz.RoleProjectViewer), + ), + wantAllowed: map[uuid.UUID]bool{admin: true, viewer: false, both: false}, + }, + { + // A caller can hold several roles on one project, directly and + // through a product. Any one of them granting the permission is + // enough, which is how the enforcement path behaves. + name: "holding both roles on a project allows it, whatever the order", + ctx: rbacContext( + projectMembership(both, authz.RoleProjectViewer), + projectMembership(both, authz.RoleProjectAdmin), + ), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: true}, + }, + { + name: "the weaker role listed last does not revoke the stronger one", + ctx: rbacContext( + projectMembership(both, authz.RoleProjectAdmin), + projectMembership(both, authz.RoleProjectViewer), + ), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: true}, + }, + { + name: "no project memberships allows nothing", + ctx: rbacContext(), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: false}, + }, + } + + s := newTestService(t) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.projectsAllowing(tc.ctx, authz.PolicyWorkflowCreate, projects) + require.NoError(t, err) + + for id, want := range tc.wantAllowed { + assert.Equal(t, want, got[id], "project %s", id) + } + }) + } +} + +// TestCanCreateProject covers whether a listing may offer creating a project. +// The organization role decides it on its own, so an administrator of every +// project in the organization can still be unable to create one. +func TestCanCreateProject(t *testing.T) { + testCases := []struct { + name string + ctx context.Context + want bool + }{ + { + // An organization member is the role that owns project creation. + name: "an organization member may create projects", + ctx: rbacContext(), + want: true, + }, + { + // A contributor inherits the member's workflow permissions but not + // project creation, so offering it would always fail. + name: "an organization contributor may not, whatever it administers", + ctx: entities.WithMembership( + usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOrgContributor)), + &entities.Membership{Resources: []*entities.ResourceMembership{ + projectMembership(uuid.New(), authz.RoleProjectAdmin), + }}), + want: false, + }, + { + name: "an organization admin bypasses RBAC and may", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleAdmin)), + want: true, + }, + { + name: "an organization owner may", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOwner)), + want: true, + }, + { + // A viewer sits outside RBAC like the administrators do, but reads + // rather than writes: the API-level check refuses it a workflow, so + // offering to create a project for one is a dead end. + name: "an organization viewer may not, though RBAC does not narrow it", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleViewer)), + want: false, + }, + } + + s := newTestService(t) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.canCreateProject(tc.ctx) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// The reported answer and the enforced one cannot drift apart, because +// userCanCreateProject is canCreateProject plus an error: a test comparing them +// would compare the function to itself and pass whatever either did. What is +// worth pinning is that both agree with the create RPC as the middleware +// actually reaches it, which is the service-level RBAC test tracked in #3419. + +// TestProjectsAllowingWithoutRBAC covers the organization roles RBAC does not +// narrow. Whether they may create a workflow is decided by the role alone, so +// it is the same answer for every project: all of them, or none. +func TestProjectsAllowingWithoutRBAC(t *testing.T) { + testCases := []struct { + name string + role authz.Role + want bool + }{ + { + name: "an organization admin may create a workflow anywhere", + role: authz.RoleAdmin, + want: true, + }, + { + // An organization viewer may list projects, so it reaches here, but + // its role does not carry the permission. Reporting these as + // writable would offer a workflow the create call then refuses. + name: "an organization viewer may create a workflow nowhere", + role: authz.RoleViewer, + want: false, + }, + } + + s := newTestService(t) + one, two := uuid.New(), uuid.New() + projects := []*biz.Project{{ID: one}, {ID: two}} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.False(t, tc.role.RBACEnabled(), "precondition: this role bypasses RBAC") + ctx := usercontext.WithAuthzSubject(context.Background(), string(tc.role)) + + got, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) + require.NoError(t, err) + assert.Equal(t, tc.want, got[one]) + assert.Equal(t, tc.want, got[two]) + }) + } +} diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 9e72f1319..bed484190 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -245,6 +245,81 @@ func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resou return errors.Forbidden("forbidden", defaultMessage) } +// projectsAllowing reports, per project ID, whether the caller may perform op +// on it. It answers for a whole listing in one pass, so a client does not have +// to offer an action that would be refused the moment it is taken. +// +// It mirrors the two checks the operation itself would face. First the +// API-level one in the authz middleware, against the caller's organization +// role. Then, when RBAC applies, authorizeResource: a caller can hold several +// roles on the same project, directly and through products, and any one of them +// granting the permission is enough. Each distinct role is enforced once, not +// once per project. +func (s *service) projectsAllowing(ctx context.Context, op *authz.Policy, projects []*biz.Project) (map[uuid.UUID]bool, error) { + allowed := make(map[uuid.UUID]bool, len(projects)) + + // The organization role gates every path, so a role without the permission + // allows nothing. An organization viewer, for instance, may list projects + // but not create a workflow in any of them. Administrators skip the policy + // check here for the same reason the middleware skips it: their policies are + // not spelled out in full yet. + subject := usercontext.CurrentAuthzSubject(ctx) + if !authz.Role(subject).IsAdmin() { + orgAllows, err := s.authz.Enforce(ctx, subject, op) + if err != nil { + return nil, err + } + + if !orgAllows { + return allowed, nil + } + } + + // The organization role carries the permission and RBAC does not narrow it + // per project, so every visible project is fair game. + if !rbacEnabled(ctx) { + for _, p := range projects { + allowed[p.ID] = true + } + + return allowed, nil + } + + // An API token is scoped to a single project, and reaching here means the + // API-level check already accepted the operation for it. + if token := entities.CurrentAPIToken(ctx); token != nil { + for _, p := range projects { + allowed[p.ID] = token.ProjectID != nil && *token.ProjectID == p.ID + } + + return allowed, nil + } + + // roleGrants caches the enforcer's answer per role, so a caller with many + // project memberships still enforces each distinct role only once. + roleGrants := make(map[authz.Role]bool) + for _, rm := range entities.CurrentMembership(ctx).Resources { + if rm.ResourceType != authz.ResourceTypeProject { + continue + } + + if _, seen := roleGrants[rm.Role]; !seen { + grants, err := s.authz.Enforce(ctx, string(rm.Role), op) + if err != nil { + return nil, err + } + + roleGrants[rm.Role] = grants + } + + if roleGrants[rm.Role] { + allowed[rm.ResourceID] = true + } + } + + return allowed, nil +} + // userHasPermissionOnProject is a helper method that checks if a policy can be applied to a project. It looks for a project // by name in the given organization and ensures that the user has a role that allows that specific operation in the project. // check authorizeResource method @@ -275,20 +350,7 @@ func (s *service) userHasPermissionOnProject(ctx context.Context, orgID string, } func (s *service) userCanCreateProject(ctx context.Context) error { - // admins always can create projects - if !rbacEnabled(ctx) { - return nil - } - - // Only org tokens can create projects - if token := entities.CurrentAPIToken(ctx); token != nil { - if token.ProjectID != nil { - return errors.Forbidden("unauthorized", "you are not allowed to create projects") - } - } - - orgRole := usercontext.CurrentAuthzSubject(ctx) - pass, err := s.authz.Enforce(ctx, orgRole, authz.PolicyProjectCreate) + pass, err := s.canCreateProject(ctx) if err != nil { return handleUseCaseErr(err, s.log) } @@ -300,6 +362,33 @@ func (s *service) userCanCreateProject(ctx context.Context) error { return nil } +// canCreateProject reports what userCanCreateProject enforces, so a listing can +// tell a client whether creating one is worth offering. The two must answer +// alike: an option that the create call then refuses is the dead end the answer +// exists to avoid. Its error is raw, for the caller to convert at the boundary. +func (s *service) canCreateProject(ctx context.Context) (bool, error) { + if !rbacEnabled(ctx) { + // An API token outside RBAC acts for the whole organization. + if token := entities.CurrentAPIToken(ctx); token != nil { + return true, nil + } + + // The roles outside RBAC are the administrators and the organization + // viewer, and only the former may create anything. Answering yes for a + // viewer would offer an option the API-level check refuses. + return authz.Role(usercontext.CurrentAuthzSubject(ctx)).IsAdmin(), nil + } + + // Only org tokens can create projects + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID != nil { + return false, nil + } + + orgRole := usercontext.CurrentAuthzSubject(ctx) + + return s.authz.Enforce(ctx, orgRole, authz.PolicyProjectCreate) +} + // visibleProjects returns projects where the user has any role (currently ProjectAdmin and ProjectViewer) func (s *service) visibleProjects(ctx context.Context) []uuid.UUID { if !rbacEnabled(ctx) { diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index fe230e52c..471338e60 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -151,6 +151,7 @@ var ( PolicyWorkflowDelete = &Policy{ResourceWorkflow, ActionDelete} // Projects PolicyProjectCreate = &Policy{ResourceProject, ActionCreate} + PolicyProjectList = &Policy{ResourceProject, ActionList} // Organization PolicyOrganizationCreate = &Policy{Organization, ActionCreate} @@ -237,6 +238,8 @@ var RolesMap = map[Role][]*Policy{ // Workflow PolicyWorkflowList, PolicyWorkflowRead, + // Project + PolicyProjectList, // Organization PolicyOrganizationRead, @@ -259,6 +262,9 @@ var RolesMap = map[Role][]*Policy{ PolicyWorkflowUpdate, PolicyWorkflowDelete, + // Listing projects is RBAC-filtered to the ones the contributor can see + PolicyProjectList, + PolicyWorkflowRunList, PolicyWorkflowRunRead, @@ -433,6 +439,7 @@ var ServerOperationsMap = map[string]*OperationPolicy{ "/controlplane.v1.GroupService/UpdateMemberMaintainerStatus": {}, // Project Memberships + "/controlplane.v1.ProjectService/List": {Policies: []*Policy{PolicyProjectList}}, "/controlplane.v1.ProjectService/ListMembers": {Policies: []*Policy{PolicyProjectListMemberships}}, "/controlplane.v1.ProjectService/AddMember": {Policies: []*Policy{PolicyProjectAddMemberships}}, "/controlplane.v1.ProjectService/RemoveMember": {Policies: []*Policy{PolicyProjectRemoveMemberships}}, diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index 24f2dea1b..24c02bed4 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -38,6 +38,9 @@ type ProjectsRepo interface { FindProjectByOrgIDAndID(ctx context.Context, orgID uuid.UUID, projectID uuid.UUID) (*Project, error) Create(ctx context.Context, orgID uuid.UUID, name string) (*Project, error) ListProjectsByOrgID(ctx context.Context, orgID uuid.UUID) ([]*Project, error) + // List retrieves the projects of an organization matching filterOpts, plus the + // total number of matches before pagination. + List(ctx context.Context, filterOpts *ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*Project, int, error) // ListMembers retrieves a list of members in a project, optionally filtered by admin status. ListMembers(ctx context.Context, orgID uuid.UUID, projectID uuid.UUID, paginationOpts *pagination.OffsetPaginationOpts) ([]*ProjectMembership, int, error) // AddMemberToProject adds a user or group to a project with a specific role. @@ -73,6 +76,8 @@ type Project struct { ID uuid.UUID // Name is the name of the project Name string + // Description is the optional, human-provided description of the project + Description string // OrgID is the organization that this project belongs to OrgID uuid.UUID // CreatedAt is the time when the project was created @@ -81,6 +86,18 @@ type Project struct { UpdatedAt *time.Time } +// ProjectListOpts filters a project listing. +type ProjectListOpts struct { + // OrganizationID scopes the listing to one organization. Required. + OrganizationID uuid.UUID + // Name filters by project name, case-insensitive substring match. + Name *string + // VisibleProjects are the projects the caller is allowed to see when RBAC + // applies. A nil slice means no restriction; an empty one means nothing is + // visible. + VisibleProjects []uuid.UUID +} + // ProjectMembership represents a membership of a user or group in a project. type ProjectMembership struct { // User is the user who is a member of the project (nil for group memberships). @@ -233,6 +250,23 @@ func (uc *ProjectUseCase) Create(ctx context.Context, orgID, name string) (*Proj return project, nil } +// List returns the projects of an organization the caller is allowed to see, +// plus the total number of matches before pagination is applied. +func (uc *ProjectUseCase) List(ctx context.Context, filterOpts *ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*Project, int, error) { + ctx, span := otelx.Start(ctx, projectTracer, "ProjectUseCase.List") + defer span.End() + + if filterOpts == nil || filterOpts.OrganizationID == uuid.Nil { + return nil, 0, NewErrValidationStr("organization ID is required to list projects") + } + + if paginationOpts == nil { + paginationOpts = pagination.NewDefaultOffsetPaginationOpts() + } + + return uc.projectsRepository.List(ctx, filterOpts, paginationOpts) +} + // ListMembers lists the members of a project with pagination. func (uc *ProjectUseCase) ListMembers(ctx context.Context, orgID uuid.UUID, projectRef *IdentityReference, paginationOpts *pagination.OffsetPaginationOpts) ([]*ProjectMembership, int, error) { ctx, span := otelx.Start(ctx, projectTracer, "ProjectUseCase.ListMembers") diff --git a/app/controlplane/pkg/biz/project_list_integration_test.go b/app/controlplane/pkg/biz/project_list_integration_test.go new file mode 100644 index 000000000..f91a39bb9 --- /dev/null +++ b/app/controlplane/pkg/biz/project_list_integration_test.go @@ -0,0 +1,190 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Project names the table cases share. +const ( + projAlphaService = "alpha-service" + projBetaService = "beta-service" + projGammaAPI = "gamma-api" +) + +func TestProjectList(t *testing.T) { + suite.Run(t, new(projectListIntegrationTestSuite)) +} + +type projectListIntegrationTestSuite struct { + testhelpers.UseCasesEachTestSuite + org *biz.Organization + otherOrg *biz.Organization + // projects in org, created in a deliberately unsorted order + alpha, beta, gamma *biz.Project +} + +func (s *projectListIntegrationTestSuite) SetupTest() { + var err error + ctx := context.Background() + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + + s.org, err = s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + s.otherOrg, err = s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + + s.gamma, err = s.Project.Create(ctx, s.org.ID, projGammaAPI) + require.NoError(s.T(), err) + s.alpha, err = s.Project.Create(ctx, s.org.ID, projAlphaService) + require.NoError(s.T(), err) + s.beta, err = s.Project.Create(ctx, s.org.ID, projBetaService) + require.NoError(s.T(), err) + + _, err = s.Project.Create(ctx, s.otherOrg.ID, "other-org-project") + require.NoError(s.T(), err) +} + +func (s *projectListIntegrationTestSuite) TearDownTest() { + ctx := context.Background() + _, _ = s.Data.DB.Project.Delete().Exec(ctx) +} + +func (s *projectListIntegrationTestSuite) TestList() { + ctx := context.Background() + orgID := uuid.MustParse(s.org.ID) + name := func(v string) *string { return &v } + + testCases := []struct { + name string + opts *biz.ProjectListOpts + // wantNames is the expected result, in order + wantNames []string + // wantCount is the total number of matches, before pagination + wantCount int + }{ + { + name: "nil visible projects lists every project in the organization, sorted by name", + opts: &biz.ProjectListOpts{OrganizationID: orgID}, + wantNames: []string{projAlphaService, projBetaService, projGammaAPI}, + wantCount: 3, + }, + { + name: "visible projects restricts the result to that set", + opts: &biz.ProjectListOpts{OrganizationID: orgID, VisibleProjects: []uuid.UUID{s.gamma.ID, s.alpha.ID}}, + wantNames: []string{projAlphaService, projGammaAPI}, + wantCount: 2, + }, + { + name: "an empty visible projects set means nothing is visible, not everything", + opts: &biz.ProjectListOpts{OrganizationID: orgID, VisibleProjects: []uuid.UUID{}}, + wantNames: []string{}, + wantCount: 0, + }, + { + name: "name filter matches a substring", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("service")}, + wantNames: []string{projAlphaService, projBetaService}, + wantCount: 2, + }, + { + name: "name filter is case insensitive", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("SERVICE")}, + wantNames: []string{projAlphaService, projBetaService}, + wantCount: 2, + }, + { + name: "name filter and visible projects are combined", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("service"), VisibleProjects: []uuid.UUID{s.beta.ID}}, + wantNames: []string{projBetaService}, + wantCount: 1, + }, + { + name: "another organization's projects are never returned", + opts: &biz.ProjectListOpts{OrganizationID: uuid.MustParse(s.otherOrg.ID)}, + wantNames: []string{"other-org-project"}, + wantCount: 1, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + got, count, err := s.Project.List(ctx, tc.opts, nil) + s.NoError(err) + s.Equal(tc.wantCount, count) + + gotNames := make([]string, 0, len(got)) + for _, p := range got { + gotNames = append(gotNames, p.Name) + } + s.Equal(tc.wantNames, gotNames) + }) + } +} + +func (s *projectListIntegrationTestSuite) TestListPagination() { + ctx := context.Background() + opts := &biz.ProjectListOpts{OrganizationID: uuid.MustParse(s.org.ID)} + + s.Run("the count is the total number of matches, not the page size", func() { + paginationOpts, err := pagination.NewOffsetPaginationOpts(1, 2) + require.NoError(s.T(), err) + + got, count, err := s.Project.List(ctx, opts, paginationOpts) + s.NoError(err) + s.Equal(3, count) + s.Len(got, 2) + s.Equal(projAlphaService, got[0].Name) + s.Equal(projBetaService, got[1].Name) + }) + + s.Run("the second page continues where the first ended", func() { + paginationOpts, err := pagination.NewOffsetPaginationOpts(2, 2) + require.NoError(s.T(), err) + + got, count, err := s.Project.List(ctx, opts, paginationOpts) + s.NoError(err) + s.Equal(3, count) + s.Len(got, 1) + s.Equal(projGammaAPI, got[0].Name) + }) +} + +func (s *projectListIntegrationTestSuite) TestListValidation() { + ctx := context.Background() + + s.Run("the organization is required", func() { + _, _, err := s.Project.List(ctx, &biz.ProjectListOpts{}, nil) + s.Error(err) + s.True(biz.IsErrValidation(err)) + }) + + s.Run("the options are required", func() { + _, _, err := s.Project.List(ctx, nil, nil) + s.Error(err) + s.True(biz.IsErrValidation(err)) + }) +} diff --git a/app/controlplane/pkg/data/project.go b/app/controlplane/pkg/data/project.go index f00ca302c..5a6f856d3 100644 --- a/app/controlplane/pkg/data/project.go +++ b/app/controlplane/pkg/data/project.go @@ -111,6 +111,49 @@ func (r *ProjectRepo) ListProjectsByOrgID(ctx context.Context, orgID uuid.UUID) return res, nil } +// List returns the projects of an organization matching filterOpts, ordered by +// name, plus the total number of matches before pagination. +func (r *ProjectRepo) List(ctx context.Context, filterOpts *biz.ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*biz.Project, int, error) { + ctx, span := otelx.Start(ctx, projectRepoTracer, "ProjectRepo.List") + defer span.End() + + q := r.data.DB.Project.Query().Where( + project.OrganizationID(filterOpts.OrganizationID), + project.DeletedAtIsNil(), + ) + + // A nil set means RBAC does not restrict the caller. An empty one means they + // can see nothing, which project.IDIn() with no arguments expresses. + if filterOpts.VisibleProjects != nil { + q = q.Where(project.IDIn(filterOpts.VisibleProjects...)) + } + + if filterOpts.Name != nil && *filterOpts.Name != "" { + q = q.Where(project.NameContainsFold(*filterOpts.Name)) + } + + count, err := q.Count(ctx) + if err != nil { + return nil, 0, fmt.Errorf("counting projects: %w", err) + } + + prs, err := q. + Order(ent.Asc(project.FieldName)). + Offset(paginationOpts.Offset()). + Limit(paginationOpts.Limit()). + All(ctx) + if err != nil { + return nil, 0, fmt.Errorf("listing projects: %w", err) + } + + res := make([]*biz.Project, 0, len(prs)) + for _, p := range prs { + res = append(res, entProjectToBiz(p)) + } + + return res, count, nil +} + func (r *ProjectRepo) Create(ctx context.Context, orgID uuid.UUID, name string) (*biz.Project, error) { ctx, span := otelx.Start(ctx, projectRepoTracer, "ProjectRepo.Create") defer span.End() @@ -375,11 +418,12 @@ func (r *ProjectRepo) queryMembership(orgID uuid.UUID, projectID uuid.UUID, memb // entProjectToBiz converts an ent.Project to a biz.Project func entProjectToBiz(pro *ent.Project) *biz.Project { return &biz.Project{ - ID: pro.ID, - Name: pro.Name, - OrgID: pro.OrganizationID, - CreatedAt: &pro.CreatedAt, - UpdatedAt: &pro.UpdatedAt, + ID: pro.ID, + Name: pro.Name, + Description: pro.Description, + OrgID: pro.OrganizationID, + CreatedAt: &pro.CreatedAt, + UpdatedAt: &pro.UpdatedAt, } } diff --git a/go.mod b/go.mod index 09cbb1f5b..880694dea 100644 --- a/go.mod +++ b/go.mod @@ -106,6 +106,7 @@ require ( require github.com/vektah/gqlparser/v2 v2.5.36 require ( + charm.land/huh/v2 v2.0.3 github.com/XSAM/otelsql v0.43.0 github.com/betterleaks/betterleaks v1.8.1 github.com/go-git/go-billy/v6 v6.0.0-alpha.2 @@ -119,6 +120,9 @@ require ( require ( cel.dev/expr v0.25.2 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go/auth v0.23.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/kms v1.33.0 // indirect @@ -143,6 +147,7 @@ require ( github.com/antithesishq/antithesis-sdk-go v0.7.2-default-no-op // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect @@ -158,8 +163,18 @@ require ( github.com/bodgit/windows v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/casbin/govaluate v1.3.0 // indirect + github.com/catppuccin/go v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -235,6 +250,7 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae // indirect github.com/miekg/dns v1.1.62 // indirect @@ -244,12 +260,14 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minlz v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/client v0.5.1 // indirect github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect @@ -271,6 +289,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rrethy/ahocorasick v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -301,6 +320,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/go.sum b/go.sum index 561122a50..8df81f3d0 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,14 @@ buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -94,6 +102,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -146,6 +156,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= @@ -191,6 +203,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 h1:yU/9y2r7s9kSUPbHXbpQTa4LA8kt github.com/aws/aws-sdk-go-v2/service/sts v1.45.7/go.mod h1:0lQTDEBArMevQXpxu443LVGjKxxEeSsSnrw9n8YiTMg= github.com/aws/smithy-go v1.27.9 h1:flT/ACSU1ksz3V+8wj8kN8DOB9tsc/ggWPTJXIieRpw= github.com/aws/smithy-go v1.27.9/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -225,6 +239,8 @@ github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/ github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -237,11 +253,37 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= @@ -798,6 +840,8 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -851,6 +895,8 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -884,6 +930,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -1049,6 +1097,8 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -1242,6 +1292,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=