diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 028740612..c0d1c292e 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -13,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -29,12 +30,15 @@ type CreateOptions struct { ProblemStatement string BackOff backoff.BackOff BaseBranch string + Prompter prompter.Prompter } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, CapiClient: shared.CapiClientFunc(f), + Config: f.Config, + Prompter: f.Prompter, } var fromFileName string @@ -51,7 +55,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return err } - // Populate ProblemStatement from either arg or file + // Gather arg inputs for ProblemStatement if len(args) > 0 { opts.ProblemStatement = args[0] } else if fromFileName != "" { @@ -66,10 +70,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.ProblemStatement = trimmed } - if opts.ProblemStatement == "" { - return cmdutil.FlagErrorf("a task description is required") - } - if runF != nil { return runF(opts) } @@ -85,6 +85,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task with problem statement from stdin $ echo "build me a new app" | gh agent-task create -F - + # Create a task with an editor prompt (interactive) + $ gh agent-task create + # Select a different base branch for the PR $ gh agent-task create "fix errors" --base branch `), @@ -106,6 +109,23 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } + if opts.ProblemStatement == "" { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") + } + + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) + if err != nil { + return err + } + + trimmed := strings.TrimSpace(desc) + if trimmed == "" { + return cmdutil.FlagErrorf("a task description is required") + } + opts.ProblemStatement = trimmed + } + client, err := opts.CapiClient() if err != nil { return err diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 00134792f..edf03f5c9 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -12,6 +12,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -38,8 +39,7 @@ func TestNewCmdCreate(t *testing.T) { wantErr string }{ { - name: "no args nor file", - wantErr: "a task description is required", + name: "no args nor file returns no error (prompting path)", }, { name: "arg only success", @@ -157,13 +157,16 @@ func Test_createRun(t *testing.T) { } tests := []struct { - name string - capiStubs func(*testing.T, *capi.CapiClientMock) - baseRepoFunc func() (ghrepo.Interface, error) - baseBranch string - wantStdout string - wantStdErr string - wantErr string + name string + capiStubs func(*testing.T, *capi.CapiClientMock) + baseRepoFunc func() (ghrepo.Interface, error) + baseBranch string + isTTY bool + prompterMock *prompter.PrompterMock + problemStatement string + wantStdout string + wantStdErr string + wantErr string }{ { name: "missing repo returns error", @@ -171,9 +174,47 @@ func Test_createRun(t *testing.T) { wantErr: "a repository is required; re-run in a repository or supply one with --repo owner/name", }, { - name: "base branch included in create payload", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - baseBranch: "feature", + name: "non-interactive empty description returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "", + wantErr: "a task description or -F is required when running non-interactively", + }, + { + name: "interactive prompt success", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + isTTY: true, + problemStatement: "", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "From editor", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + prompterMock: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + return "From editor", nil + }, + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "interactive prompt empty returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + isTTY: true, + problemStatement: "", + prompterMock: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return " ", nil + }, + }, + wantErr: "a task description is required", + }, + { + name: "base branch included in create payload", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + baseBranch: "feature", + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -192,8 +233,9 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "create task API failure returns error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "create task API failure returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -206,8 +248,9 @@ func Test_createRun(t *testing.T) { wantErr: "some error", }, { - name: "get job API failure surfaces error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "get job API failure surfaces error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -224,8 +267,9 @@ func Test_createRun(t *testing.T) { wantStdout: "job job123 queued. View progress: https://github.com/copilot/agents\n", }, { - name: "success with immediate PR", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "success with immediate PR", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -238,8 +282,9 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "success with delayed PR after polling", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "success with delayed PR after polling", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -258,8 +303,9 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "fallback after timeout returns link to global agents page", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "fallback after timeout returns link to global agents page", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -289,17 +335,24 @@ func Test_createRun(t *testing.T) { } ios, _, stdout, stderr := iostreams.Test() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetStdoutTTY(true) + } + opts := &CreateOptions{ IO: ios, - ProblemStatement: "Do the thing", + ProblemStatement: tt.problemStatement, BaseRepo: tt.baseRepoFunc, BaseBranch: tt.baseBranch, + Prompter: tt.prompterMock, CapiClient: func() (capi.CapiClient, error) { return capiClientMock, nil }, } - // A backoff with no internal between retries to keep tests fast, + // A backoff with no interval between retries to keep tests fast, // and also a max number of retries so we don't infinitely poll. opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3)