Merge pull request #11700 from cli/kw/1017-gh-agent-task-create-prompts-for-task-description-via-editor

`gh agent-task create`: prompt for task description using editor
This commit is contained in:
Kynan Ware 2025-09-10 11:16:27 -06:00 committed by GitHub
commit 7763060ba2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 29 deletions

View file

@ -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

View file

@ -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)