diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index caeb02fa7..0e3c43c08 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -5,9 +5,11 @@ import ( "fmt" "time" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -25,14 +27,17 @@ type ListOptions struct { Limit int CapiClient func() (*capi.CAPIClient, error) BaseRepo func() (ghrepo.Interface, error) + Web bool + Browser browser.Browser } // NewCmdList creates the list command func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ - IO: f.IOStreams, - Config: f.Config, - Limit: defaultLimit, + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -41,9 +46,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Support -R/--repo override - if f != nil { - opts.BaseRepo = f.BaseRepo - } + + opts.BaseRepo = f.BaseRepo + if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } @@ -59,6 +64,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") opts.CapiClient = func() (*capi.CAPIClient, error) { cfg, err := opts.Config() @@ -77,6 +83,18 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } func listRun(opts *ListOptions) error { + if opts.Web { + // Currently the web GUI does not have a page that supports filtering + // based on repo, so we just open the agents dashboard with no args. + // If that page is ever added in the future, we should route to that + // page instead of the global one when --repo is set. + const webURL = "https://github.com/copilot/agents" + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + } + return opts.Browser.Browse(webURL) + } + if opts.Limit <= 0 { opts.Limit = defaultLimit } @@ -93,6 +111,8 @@ func listRun(opts *ListOptions) error { var repo ghrepo.Interface if opts.BaseRepo != nil { + // We swallow this error because when CWD is not a repo and + // the --repo flag is not set, we use the global/user session listing. repo, _ = opts.BaseRepo() } @@ -110,8 +130,7 @@ func listRun(opts *ListOptions) error { opts.IO.StopProgressIndicator() if len(sessions) == 0 { - fmt.Fprintln(opts.IO.Out, "no agent tasks found") - return nil + return cmdutil.NewNoResultsError("no agent tasks found") } cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 2d27436d2..ef3cf8a7b 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" @@ -44,6 +45,19 @@ func TestNewCmdList(t *testing.T) { args: "--limit 0", wantErr: "invalid limit: 0", }, + { + name: "negative limit", + args: "--limit -5", + wantErr: "invalid limit: -5", + }, + { + name: "web flag", + args: "--web", + wantOpts: ListOptions{ + Limit: defaultLimit, + Web: true, + }, + }, } for _, tt := range tests { @@ -62,30 +76,32 @@ func TestNewCmdList(t *testing.T) { } require.NoError(t, err) assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + assert.Equal(t, tt.wantOpts.Web, gotOpts.Web) }) } } func Test_listRun(t *testing.T) { - sixHours, _ := time.ParseDuration("6h") - sixHoursAgo := time.Now().Add(-sixHours) - createdAt := sixHoursAgo.Format(time.RFC3339) + createdAt := time.Now().Add(-6 * time.Hour).Format(time.RFC3339) // 6h ago tests := []struct { - name string - tty bool - stubs func(*httpmock.Registry) - baseRepo ghrepo.Interface - baseRepoErr error - limit int - wantOut string - wantErr error + name string + tty bool + stubs func(*httpmock.Registry) + baseRepo ghrepo.Interface + baseRepoErr error + limit int + web bool + wantOut string + wantErr error + wantStderr string + wantBrowserURL string }{ { name: "no sessions", tty: true, stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, - wantOut: "no agent tasks found\n", + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { name: "limit truncates sessions", @@ -143,15 +159,14 @@ func Test_listRun(t *testing.T) { tty: true, stubs: func(reg *httpmock.Registry) { registerRepoEmptySessionsMock(reg, "OWNER", "REPO") }, baseRepo: ghrepo.New("OWNER", "REPO"), - wantOut: "no agent tasks found\n", + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { name: "repo resolution error does not surface", tty: true, baseRepoErr: errors.New("ambiguous repo"), - wantErr: nil, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, - wantOut: "no agent tasks found\n", }, { name: "repo scoped many sessions (tty)", @@ -168,6 +183,23 @@ func Test_listRun(t *testing.T) { r6 #306 OWNER/REPO mystery about 6 hours ago `), }, + { + name: "web mode", + tty: true, + web: true, + wantOut: "", + wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", + wantBrowserURL: "https://github.com/copilot/agents", + }, + { + name: "web mode with repo still uses global URL, even when --repo is set", + tty: true, + web: true, + baseRepo: ghrepo.New("OWNER", "REPO"), + wantOut: "", + wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", + wantBrowserURL: "https://github.com/copilot/agents", + }, } for _, tt := range tests { @@ -181,16 +213,28 @@ func Test_listRun(t *testing.T) { cfg.Set("github.com", "oauth_token", "OTOKEN") authCfg := cfg.Authentication() - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) + var br *browser.Stub + if tt.web { + br = &browser.Stub{} + } + httpClient := &http.Client{Transport: reg} capiClient := capi.NewCAPIClient(httpClient, authCfg) opts := &ListOptions{ - IO: ios, - Config: func() (gh.Config, error) { return cfg, nil }, - Limit: tt.limit, - CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, + IO: ios, + Config: func() (gh.Config, error) { return cfg, nil }, + Limit: tt.limit, + Web: tt.web, + Browser: br, + CapiClient: func() (*capi.CAPIClient, error) { + if tt.web { + require.FailNow(t, "CapiClient was called with --web") + } + return capiClient, nil + }, } if tt.baseRepo != nil || tt.baseRepoErr != nil { baseRepo := tt.baseRepo @@ -207,6 +251,10 @@ func Test_listRun(t *testing.T) { } got := stdout.String() require.Equal(t, tt.wantOut, got) + require.Equal(t, tt.wantStderr, stderr.String()) + if tt.web { + br.Verify(t, tt.wantBrowserURL) + } reg.Verify(t) }) }