From 563809362b00fe826ddac99b853eb9160f1153ce Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:30:23 -0600 Subject: [PATCH 1/4] Add interactive prompt for task description in agent-task create Enhances the agent-task create command to prompt users for a task description interactively if none is provided and the terminal supports prompting. Updates tests to cover interactive and non-interactive scenarios, including error handling for empty input and prompt failures. --- pkg/cmd/agent-task/create/create.go | 33 ++++++-- pkg/cmd/agent-task/create/create_test.go | 101 +++++++++++++++++------ 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 028740612..58f18751d 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,14 @@ 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), + Prompter: f.Prompter, } var fromFileName string @@ -51,7 +54,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 +69,7 @@ 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") - } - + opts.Config = f.Config 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,26 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } + // Prompt for ProblemStatement if not provided non-interactively + if opts.ProblemStatement == "" && opts.IO.CanPrompt() { + if opts.Prompter == nil { + return cmdutil.FlagErrorf("interactive prompting is not available") + } + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", "", false) + if err != nil { + return err + } + trimmed := strings.TrimSpace(desc) + if trimmed == "" { + return cmdutil.FlagErrorf("a task description is required") + } + opts.ProblemStatement = trimmed + } + + if opts.ProblemStatement == "" { + return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") + } + 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) From 6945fc018385e928c5c1ea81d0f1b6d53a96f9ef Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:08:04 -0600 Subject: [PATCH 2/4] Still prompt for task desc with -F --- pkg/cmd/agent-task/create/create.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 58f18751d..799847bf4 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -110,11 +110,8 @@ func createRun(opts *CreateOptions) error { } // Prompt for ProblemStatement if not provided non-interactively - if opts.ProblemStatement == "" && opts.IO.CanPrompt() { - if opts.Prompter == nil { - return cmdutil.FlagErrorf("interactive prompting is not available") - } - desc, err := opts.Prompter.MarkdownEditor("Enter the task description", "", false) + if opts.Prompter != nil && opts.IO.CanPrompt() { + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) if err != nil { return err } From b463395d481b736f1a121ea18d687e77d6c7a2ad Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 12:45:34 +0100 Subject: [PATCH 3/4] fix(agent-task create): only prompt for problem statement if not provided Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 799847bf4..7c1d04efc 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -109,12 +109,16 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } - // Prompt for ProblemStatement if not provided non-interactively - if opts.Prompter != nil && opts.IO.CanPrompt() { + 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") @@ -122,10 +126,6 @@ func createRun(opts *CreateOptions) error { opts.ProblemStatement = trimmed } - if opts.ProblemStatement == "" { - return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") - } - client, err := opts.CapiClient() if err != nil { return err From 4cebd35791631dc5f862b6b78b7c1e58c53d78c1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 12:48:24 +0100 Subject: [PATCH 4/4] refactor(agent-task create): assign `Config` at instantiation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7c1d04efc..c0d1c292e 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -37,6 +37,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, CapiClient: shared.CapiClientFunc(f), + Config: f.Config, Prompter: f.Prompter, } @@ -69,7 +70,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.ProblemStatement = trimmed } - opts.Config = f.Config if runF != nil { return runF(opts) }