diff --git a/cmd/src/abc.go b/cmd/src/abc.go new file mode 100644 index 0000000000..5d2b019556 --- /dev/null +++ b/cmd/src/abc.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/urfave/cli/v3" +) + +var abcCommand = clicompat.Wrap(&cli.Command{ + Name: "abc", + Usage: "manages agentic batch changes", + Commands: []*cli.Command{ + clicompat.Wrap(&cli.Command{ + Name: "variables", + Usage: "manage workflow instance variables", + Commands: []*cli.Command{ + abcVariablesSetCommand, + abcVariablesDeleteCommand, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) + }, + }), + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) + }, +}) diff --git a/cmd/src/abc_variables_delete.go b/cmd/src/abc_variables_delete.go new file mode 100644 index 0000000000..764d57991c --- /dev/null +++ b/cmd/src/abc_variables_delete.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + "io" + "slices" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/urfave/cli/v3" +) + +var abcVariablesDeleteCommand = clicompat.Wrap(&cli.Command{ + Name: "delete", + Usage: "Delete variables on a workflow instance", + UsageText: "src abc variables delete [options] [ ...]", + DisableSliceFlagSeparator: true, + Description: ` +Delete workflow instance variables + +Examples: + + Delete a variable from a workflow instance: + + $ src abc variables delete QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ== approval + + Delete multiple variables in one request: + + $ src abc variables delete QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ== --var approval --var checkpoints +`, + Flags: clicompat.WithAPIFlags( + &cli.StringSliceFlag{ + Name: "var", + Usage: "Variable name to delete. Repeat for multiple names.", + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + if !cmd.Args().Present() { + return cmderrors.Usage("must provide a workflow instance ID") + } + + instanceID := cmd.Args().First() + client := cfg.apiClient(clicompat.APIFlagsFromCmd(cmd), cmd.Writer) + variableNames := append(cmd.Args().Tail(), cmd.StringSlice("var")...) + + return runABCVariablesDelete(ctx, client, instanceID, variableNames, cmd.Writer) + }, +}) + +func runABCVariablesDelete(ctx context.Context, client api.Client, instanceID string, variableNames []string, output io.Writer) error { + if len(variableNames) == 0 { + return cmderrors.Usage("must provide at least one variable name") + } + + if slices.Contains(variableNames, "") { + return cmderrors.Usage("variable names must not be empty") + } + + variables := make([]map[string]string, 0, len(variableNames)) + for _, key := range variableNames { + variables = append(variables, map[string]string{ + "key": key, + "value": "null", + }) + } + + ok, err := updateABCWorkflowInstanceVariables(ctx, client, instanceID, variables) + if err != nil || !ok { + return err + } + + _, err = fmt.Fprintf(output, "Removed variables %q from workflow instance %q.\n", variableNames, instanceID) + return err +} diff --git a/cmd/src/abc_variables_delete_test.go b/cmd/src/abc_variables_delete_test.go new file mode 100644 index 0000000000..fdad13f72e --- /dev/null +++ b/cmd/src/abc_variables_delete_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "context" + "io" + "testing" + + mockapi "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRunABCVariablesDelete(t *testing.T) { + t.Parallel() + + client := new(mockapi.Client) + request := &mockapi.Request{Response: `{"data":{"updateAgenticWorkflowInstanceVariables":{"id":"workflow"}}}`} + output := &bytes.Buffer{} + variableNames := []string{"approval", "checkpoints", "prompt"} + + client.On("NewRequest", updateABCWorkflowInstanceVariablesMutation, map[string]any{ + "instanceID": "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", + "variables": []map[string]string{ + {"key": "approval", "value": "null"}, + {"key": "checkpoints", "value": "null"}, + {"key": "prompt", "value": "null"}, + }, + }).Return(request).Once() + request.On("Do", context.Background(), mock.Anything).Return(true, nil).Once() + + err := runABCVariablesDelete(context.Background(), client, "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", variableNames, output) + require.NoError(t, err) + require.Equal(t, "Removed variables [\"approval\" \"checkpoints\" \"prompt\"] from workflow instance \"QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==\".\n", output.String()) + + client.AssertExpectations(t) + request.AssertExpectations(t) +} + +func TestRunABCVariablesDeleteRejectsEmptyVariableName(t *testing.T) { + t.Parallel() + + err := runABCVariablesDelete(context.Background(), nil, "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", []string{"approval", ""}, io.Discard) + require.ErrorContains(t, err, "variable names must not be empty") +} + +func TestRunABCVariablesDeleteSuppressesSuccessMessageWhenRequestDoesNotExecute(t *testing.T) { + t.Parallel() + + client := new(mockapi.Client) + request := &mockapi.Request{} + output := &bytes.Buffer{} + variableNames := []string{"approval", "checkpoints", "prompt"} + + client.On("NewRequest", updateABCWorkflowInstanceVariablesMutation, map[string]any{ + "instanceID": "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", + "variables": []map[string]string{ + {"key": "approval", "value": "null"}, + {"key": "checkpoints", "value": "null"}, + {"key": "prompt", "value": "null"}, + }, + }).Return(request).Once() + request.On("Do", context.Background(), mock.Anything).Return(false, nil).Once() + + err := runABCVariablesDelete(context.Background(), client, "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", variableNames, output) + require.NoError(t, err) + require.Empty(t, output.String()) + + client.AssertExpectations(t) + request.AssertExpectations(t) +} diff --git a/cmd/src/abc_variables_set.go b/cmd/src/abc_variables_set.go new file mode 100644 index 0000000000..ea5d43c5a8 --- /dev/null +++ b/cmd/src/abc_variables_set.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/urfave/cli/v3" +) + +const updateABCWorkflowInstanceVariablesMutation = `mutation UpdateAgenticWorkflowInstanceVariables( + $instanceID: ID!, + $variables: [AgenticWorkflowInstanceVariableInput!]!, +) { + updateAgenticWorkflowInstanceVariables(instanceID: $instanceID, variables: $variables) { + id + } +}` + +var abcVariablesSetCommand = clicompat.Wrap(&cli.Command{ + Name: "set", + UsageText: "src abc variables set [options] [= ...]", + Usage: "Set variables on a workflow instance", + DisableSliceFlagSeparator: true, + Description: ` +Set workflow instance variables + +Examples: + + Set a string variable on a workflow instance: + + $ src abc variables set QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ== prompt="tighten the review criteria" + + Set multiple variables in one request: + + $ src abc variables set QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ== --var prompt="tighten the review criteria" --var checkpoints='[1,2,3]' + + Set a structured JSON value: + + $ src abc variables set QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ== checkpoints='[1,2,3]' + +NOTE: Values are interpreted as JSON literals when valid. Otherwise they are sent as plain strings. +`, + Flags: clicompat.WithAPIFlags( + &cli.StringSliceFlag{ + Name: "var", + Usage: "Variable assignment in = form. Repeat to set multiple variables.", + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + if !cmd.Args().Present() { + return cmderrors.Usage("must provide a workflow instance ID") + } + + instanceID := cmd.Args().First() + client := cfg.apiClient(clicompat.APIFlagsFromCmd(cmd), cmd.Writer) + abcVariables, err := parseABCVariables(cmd.Args().Tail(), cmd.StringSlice("var")) + if err != nil { + return err + } + return runABCVariablesSet(ctx, client, instanceID, abcVariables, cmd.Writer) + }, +}) + +func parseABCVariables(positional []string, flagged []string) (map[string]string, error) { + rawVariables := append(positional, flagged...) + if len(rawVariables) == 0 { + return nil, cmderrors.Usage("must provide at least one variable assignment") + } + + variables := make(map[string]string, len(rawVariables)) + for _, v := range rawVariables { + name, rawValue, ok := strings.Cut(v, "=") + if !ok || name == "" { + return nil, cmderrors.Usagef("invalid variable assignment %q: must be in = form", v) + } + + value, remove, err := marshalABCVariableValue(rawValue) + if err != nil { + return nil, err + } + if remove { + return nil, cmderrors.Usagef("invalid variable assignment %q: use 'src abc variables delete %s' to remove a variable", rawValue, name) + } + + variables[name] = value + } + + return variables, nil +} + +func runABCVariablesSet(ctx context.Context, client api.Client, instanceID string, variables map[string]string, output io.Writer) error { + graphqlVariables := make([]map[string]string, 0, len(variables)) + keys := make([]string, 0, len(variables)) + for k := range variables { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + graphqlVariables = append(graphqlVariables, map[string]string{ + "key": k, + "value": variables[k], + }) + } + + ok, err := updateABCWorkflowInstanceVariables(ctx, client, instanceID, graphqlVariables) + if err != nil || !ok { + return err + } + + _, err = fmt.Fprintf(output, "Updated %d variables on workflow instance %q.\n", len(variables), instanceID) + return err +} + +func updateABCWorkflowInstanceVariables(ctx context.Context, client api.Client, instanceID string, variables []map[string]string) (bool, error) { + var result struct { + UpdateAgenticWorkflowInstanceVariables struct { + ID string `json:"id"` + } `json:"updateAgenticWorkflowInstanceVariables"` + } + if ok, err := client.NewRequest(updateABCWorkflowInstanceVariablesMutation, map[string]any{ + "instanceID": instanceID, + "variables": variables, + }).Do(ctx, &result); err != nil || !ok { + return ok, err + } + + return true, nil +} + +func marshalABCVariableValue(raw string) (value string, remove bool, err error) { + // Try to compact valid JSON literals first so numbers, arrays, and objects are sent unchanged. + // A bare null is detected separately so the CLI can require the explicit delete command. + // If compacting doesn't work for the given value, fall back to string encoding. + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(raw)); err == nil { + value := compact.String() + return value, value == "null", nil + } + + encoded, err := json.Marshal(raw) + if err != nil { + return "", false, err + } + + return string(encoded), false, nil +} diff --git a/cmd/src/abc_variables_set_test.go b/cmd/src/abc_variables_set_test.go new file mode 100644 index 0000000000..744234161e --- /dev/null +++ b/cmd/src/abc_variables_set_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "context" + "testing" + + "github.com/google/go-cmp/cmp" + mockapi "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +// If we were to do a json marshalling roundtrip, it may break large integer literals. +// This test is here to demonstrate that the compaction approach is working well. +func TestMarshalABCVariableValuePreservesLargeIntegerLiteral(t *testing.T) { + t.Parallel() + + value, remove, err := marshalABCVariableValue("9007199254740993") + if err != nil { + t.Fatalf("marshalABCVariableValue returned error: %v", err) + } + if remove { + t.Fatal("marshalABCVariableValue unexpectedly marked value for removal") + } + if value != "9007199254740993" { + t.Fatalf("marshalABCVariableValue = %q, want %q", value, "9007199254740993") + } +} + +func TestParseABCVariables(t *testing.T) { + t.Parallel() + + variables, err := parseABCVariables( + []string{"prompt=tighten the review criteria", `title="test"`}, + []string{"checkpoints=[1,2,3]"}, + ) + if err != nil { + t.Fatalf("parseABCVariables returned error: %v", err) + } + + if diff := cmp.Diff(variables, map[string]string{ + "prompt": "\"tighten the review criteria\"", + "title": "\"test\"", + "checkpoints": "[1,2,3]", + }); diff != "" { + t.Errorf("err: %v", diff) + } +} + +func TestABCVariablesSetVarFlagPreservesCommas(t *testing.T) { + t.Parallel() + + var got []string + cmd := &cli.Command{ + Name: "set", + DisableSliceFlagSeparator: abcVariablesSetCommand.DisableSliceFlagSeparator, + Flags: []cli.Flag{ + &cli.StringSliceFlag{Name: "var"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + got = cmd.StringSlice("var") + return nil + }, + } + + err := cmd.Run(context.Background(), []string{"set", "--var", "checkpoints=[1,2,3]"}) + require.NoError(t, err) + require.Equal(t, []string{"checkpoints=[1,2,3]"}, got) +} + +func TestRunABCVariablesSet(t *testing.T) { + t.Parallel() + + client := new(mockapi.Client) + request := &mockapi.Request{Response: `{"data":{"updateAgenticWorkflowInstanceVariables":{"id":"workflow"}}}`} + output := &bytes.Buffer{} + variables := map[string]string{ + "prompt": `"tighten the review criteria"`, + "checkpoints": "[1,2,3]", + } + + client.On("NewRequest", updateABCWorkflowInstanceVariablesMutation, map[string]any{ + "instanceID": "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", + "variables": []map[string]string{ + {"key": "checkpoints", "value": "[1,2,3]"}, + {"key": "prompt", "value": `"tighten the review criteria"`}, + }, + }).Return(request).Once() + request.On("Do", context.Background(), mock.Anything).Return(true, nil).Once() + + err := runABCVariablesSet(context.Background(), client, "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", variables, output) + require.NoError(t, err) + require.Equal(t, "Updated 2 variables on workflow instance \"QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==\".\n", output.String()) + + client.AssertExpectations(t) + request.AssertExpectations(t) +} + +func TestRunABCVariablesSetSuppressesSuccessMessageWhenRequestDoesNotExecute(t *testing.T) { + t.Parallel() + + client := new(mockapi.Client) + request := &mockapi.Request{} + output := &bytes.Buffer{} + + client.On("NewRequest", updateABCWorkflowInstanceVariablesMutation, map[string]any{ + "instanceID": "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", + "variables": []map[string]string{{"key": "prompt", "value": `"tighten the review criteria"`}}, + }).Return(request).Once() + request.On("Do", context.Background(), mock.Anything).Return(false, nil).Once() + + err := runABCVariablesSet(context.Background(), client, "QWdlbnRpY1dvcmtmbG93SW5zdGFuY2U6MQ==", map[string]string{"prompt": `"tighten the review criteria"`}, output) + require.NoError(t, err) + require.Empty(t, output.String()) + + client.AssertExpectations(t) + request.AssertExpectations(t) +} diff --git a/cmd/src/main.go b/cmd/src/main.go index 8499d6465f..68478c9276 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -50,6 +50,7 @@ The options are: The commands are: + abc manages agentic batch changes auth authentication helper commands api interacts with the Sourcegraph GraphQL API batch manages batch changes diff --git a/cmd/src/run_migration_compat.go b/cmd/src/run_migration_compat.go index 399cab11f6..36d4fae9f0 100644 --- a/cmd/src/run_migration_compat.go +++ b/cmd/src/run_migration_compat.go @@ -16,6 +16,7 @@ import ( ) var migratedCommands = map[string]*cli.Command{ + "abc": abcCommand, "version": versionCommand, } diff --git a/internal/clicompat/help.go b/internal/clicompat/help.go index 02cf15b66b..fa9235572d 100644 --- a/internal/clicompat/help.go +++ b/internal/clicompat/help.go @@ -1,6 +1,13 @@ package clicompat -import "github.com/urfave/cli/v3" +import ( + "context" + "fmt" + + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/urfave/cli/v3" +) // Wrap sets common options on a sub commands to ensure consistency for help and error handling func Wrap(cmd *cli.Command) *cli.Command { @@ -9,5 +16,21 @@ func Wrap(cmd *cli.Command) *cli.Command { } cmd.OnUsageError = OnUsageError + cmd.Action = wrapWithHelpOnUsageError(cmd.Action) return cmd } + +func wrapWithHelpOnUsageError(action cli.ActionFunc) cli.ActionFunc { + if action == nil { + return nil + } + + return func(ctx context.Context, cmd *cli.Command) error { + err := action(ctx, cmd) + if err != nil && errors.HasType[*cmderrors.UsageError](err) { + _, _ = fmt.Fprintf(cmd.Root().ErrWriter, "error: %s\n---\n", err) + cli.DefaultPrintHelp(cmd.Root().ErrWriter, cmd.CustomHelpTemplate, cmd) + } + return err + } +}